Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ node_modules/
.imposter
bin/
/.vscode/
/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ import io.gatehill.imposter.plugin.config.capture.CaptureConfigHolder
import io.gatehill.imposter.plugin.config.capture.ItemCaptureConfig
import io.gatehill.imposter.plugin.config.security.SecurityConfig
import io.gatehill.imposter.plugin.config.security.SecurityConfigHolder
import java.util.UUID
import java.util.*

/**
* Base configuration for plugins and sub-resources.
Expand All @@ -68,6 +68,9 @@ abstract class AbstractResourceConfig : BasicResourceConfig, SecurityConfigHolde
@JsonProperty("response")
override val responseConfig = ResponseConfig()

@JsonProperty("log")
var log: String? = null

@JsonIgnore
override var isInterceptor: Boolean = false

Expand All @@ -78,6 +81,6 @@ abstract class AbstractResourceConfig : BasicResourceConfig, SecurityConfigHolde
override val resourceId by lazy { UUID.randomUUID().toString() }

override fun toString(): String {
return "AbstractResourceConfig(path=$path, securityConfig=$securityConfig, captureConfig=$captureConfig, responseConfig=$responseConfig, continueToNext=$continueToNext)"
return "AbstractResourceConfig(path=$path, securityConfig=$securityConfig, captureConfig=$captureConfig, responseConfig=$responseConfig, log=$log, continueToNext=$continueToNext)"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import io.gatehill.imposter.lifecycle.EngineLifecycleHooks
import io.gatehill.imposter.lifecycle.EngineLifecycleListener
import io.gatehill.imposter.plugin.config.ContentTypedConfig
import io.gatehill.imposter.plugin.config.PluginConfig
import io.gatehill.imposter.plugin.config.resource.AbstractResourceConfig
import io.gatehill.imposter.plugin.config.resource.BasicResourceConfig
import io.gatehill.imposter.plugin.config.resource.ResourceConfig
import io.gatehill.imposter.script.ResponseBehaviour
Expand Down Expand Up @@ -227,6 +228,17 @@ class ResponseServiceImpl @Inject constructor(
} else {
origResponseData
}

// Process custom log message if present
if (resourceConfig is AbstractResourceConfig && resourceConfig.log != null) {
val logMessage = if (template) {
PlaceholderUtil.replace(resourceConfig.log!!, httpExchange, PlaceholderUtil.templateEvaluators)
} else {
resourceConfig.log!!
}
LOGGER.info("Resource log: {}", logMessage)
}

response.end(responseData)
}

Expand Down
33 changes: 33 additions & 0 deletions docs/metrics_logs_telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,39 @@ Imposter can log a JSON summary of each request, such as the following:

To enable this, set the environment variable `IMPOSTER_LOG_SUMMARY=true`.

### Resource and interceptor logging

Resources and interceptors can include custom log messages that are processed using the template engine. This allows you to log contextual information about requests that match your resources.

Add a `log` property to any resource or interceptor:

```yaml
plugin: rest
resources:
- path: /users/{id}
method: GET
log: "User lookup for ID: ${context.request.pathParams.id} from ${context.request.headers.X-Client-ID:-unknown client}"
response:
content: '{"id": "${context.request.pathParams.id}", "name": "Test User"}'
statusCode: 200
template: true

interceptors:
- path: /secured/*
method: GET
log: "Secured endpoint accessed by ${context.request.headers.User-Agent} with trace ID: ${context.request.headers.X-Trace-ID:-none provided}"
response:
statusCode: 401
content: "Unauthorized"
continue: false
```

The log message supports all template features including:
- Path parameters, query parameters, and headers from the request
- Random data generation and date/time functions
- Default values with the `:-` syntax
- JSON and XML processing with JSONPath and XPath

#### Logging request/response headers

You can optionally include request and response headers in the JSON summary such as:
Expand Down
179 changes: 179 additions & 0 deletions server/src/test/java/io/gatehill/imposter/server/ResourceLogTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/*
* Copyright (c) 2024.
*
* This file is part of Imposter.
*
* "Commons Clause" License Condition v1.0
*
* The Software is provided to you by the Licensor under the License, as
* defined below, subject to the following condition.
*
* Without limiting other conditions in the License, the grant of rights
* under the License will not include, and the License does not grant to
* you, the right to Sell the Software.
*
* For purposes of the foregoing, "Sell" means practicing any or all of
* the rights granted to you under the License to provide to third parties,
* for a fee or other consideration (including without limitation fees for
* hosting or consulting/support services related to the Software), a
* product or service whose value derives, entirely or substantially, from
* the functionality of the Software. Any license notice or attribution
* required by the License must also include this Commons Clause License
* Condition notice.
*
* Software: Imposter
*
* License: GNU Lesser General Public License version 3
*
* Licensor: Peter Cornish
*
* Imposter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Imposter is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Imposter. If not, see <https://www.gnu.org/licenses/>.
*/
package io.gatehill.imposter.server

import io.gatehill.imposter.plugin.test.TestPluginImpl
import io.gatehill.imposter.service.ResponseServiceImpl
import io.restassured.RestAssured
import io.vertx.core.Vertx
import io.vertx.junit5.VertxTestContext
import org.apache.logging.log4j.Level
import org.apache.logging.log4j.LogManager
import org.apache.logging.log4j.core.LogEvent
import org.apache.logging.log4j.core.Logger
import org.apache.logging.log4j.core.appender.AbstractAppender
import org.apache.logging.log4j.core.config.Property
import org.apache.logging.log4j.core.layout.PatternLayout
import org.hamcrest.Matchers.equalTo
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.util.*
import java.util.concurrent.CopyOnWriteArrayList

/**
* Tests for resource logging functionality.
*/
class ResourceLogTest : BaseVerticleTest() {
override val pluginClass = TestPluginImpl::class.java

private lateinit var memoryAppender: MemoryAppender
private lateinit var logger: Logger

@BeforeEach
override fun setUp(vertx: Vertx, testContext: VertxTestContext) {
super.setUp(vertx, testContext)
RestAssured.baseURI = "http://$host:$listenPort"
RestAssured.enableLoggingOfRequestAndResponseIfValidationFails()

// Setup logging capture
logger = LogManager.getLogger(ResponseServiceImpl::class.java) as Logger
memoryAppender = MemoryAppender("MemoryAppender-${UUID.randomUUID()}")
memoryAppender.start()
logger.addAppender(memoryAppender)
}

@AfterEach
fun tearDownLogger() {
logger.removeAppender(memoryAppender)
memoryAppender.stop()
}

override val testConfigDirs = listOf(
"/resource-log"
)

@Test
fun `resource should log message with path parameters`() {
// Clear logs before test
memoryAppender.clear()

// Make the request
RestAssured.given().`when`()
.get("/resource-log/123")
.then()
.statusCode(200)
.body(equalTo("resource_logged"))

// Verify log message was captured
val logEvents = memoryAppender.getLogEvents()
assertTrue(logEvents.any { event ->
event.level == Level.INFO &&
event.message.formattedMessage.contains("Resource log: Resource log message for ID: 123")
}, "Expected log message with path parameter not found")
}

@Test
fun `interceptor should log message`() {
// Clear logs before test
memoryAppender.clear()

// Make the request
RestAssured.given().`when`()
.get("/interceptor-log")
.then()
.statusCode(401)
.body(equalTo("Unauthorized"))

// Verify log message was captured
val logEvents = memoryAppender.getLogEvents()
assertTrue(logEvents.any { event ->
event.level == Level.INFO &&
event.message.formattedMessage.contains("Resource log: Interceptor log message")
}, "Expected interceptor log message not found")
}

@Test
fun `request without log property should not log custom message`() {
// Clear logs before test
memoryAppender.clear()

// Make the request to a path without log property
RestAssured.given().`when`()
.get("/simple")
.then()
.statusCode(200)
.body(equalTo("simple"))

// Verify no custom log message was captured
val logEvents = memoryAppender.getLogEvents()
assertTrue(logEvents.none { event ->
event.level == Level.INFO &&
event.message.formattedMessage.contains("Resource log:")
}, "Custom log message found when none was expected")
}

/**
* Custom in-memory appender for capturing log events during tests.
*/
class MemoryAppender(name: String) : AbstractAppender(
name,
null,
PatternLayout.createDefaultLayout(),
false,
Property.EMPTY_ARRAY
) {
private val logEvents = CopyOnWriteArrayList<LogEvent>()

override fun append(event: LogEvent) {
logEvents.add(event.toImmutable())
}

fun getLogEvents(): List<LogEvent> = logEvents.toList()

fun clear() {
logEvents.clear()
}
}
}
27 changes: 27 additions & 0 deletions server/src/test/resources/resource-log/test-plugin-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
plugin: "io.gatehill.imposter.plugin.test.TestPluginImpl"

interceptors:
- path: /interceptor-log
method: GET
log: "Interceptor log message"
continue: false
response:
statusCode: 401
content: "Unauthorized"

resources:
- path: /resource-log/{id}
method: GET
log: "Resource log message for ID: ${context.request.pathParams.id}"
response:
content: "resource_logged"

- path: /simple
method: GET
response:
content: "simple"

- path: /*
method: GET
response:
content: "default"
Loading