diff --git a/.gitignore b/.gitignore
index 7fbcde3bd..edced24aa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,3 +12,4 @@ node_modules/
.imposter
bin/
/.vscode/
+/CLAUDE.md
diff --git a/core/api/src/main/java/io/gatehill/imposter/plugin/config/resource/AbstractResourceConfig.kt b/core/api/src/main/java/io/gatehill/imposter/plugin/config/resource/AbstractResourceConfig.kt
index f3bfd8a46..5035054d1 100644
--- a/core/api/src/main/java/io/gatehill/imposter/plugin/config/resource/AbstractResourceConfig.kt
+++ b/core/api/src/main/java/io/gatehill/imposter/plugin/config/resource/AbstractResourceConfig.kt
@@ -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.
@@ -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
@@ -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)"
}
}
diff --git a/core/engine/src/main/java/io/gatehill/imposter/service/ResponseServiceImpl.kt b/core/engine/src/main/java/io/gatehill/imposter/service/ResponseServiceImpl.kt
index 1bb950fa7..ea47413c1 100644
--- a/core/engine/src/main/java/io/gatehill/imposter/service/ResponseServiceImpl.kt
+++ b/core/engine/src/main/java/io/gatehill/imposter/service/ResponseServiceImpl.kt
@@ -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
@@ -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)
}
diff --git a/docs/metrics_logs_telemetry.md b/docs/metrics_logs_telemetry.md
index ae03375da..f2fff1404 100644
--- a/docs/metrics_logs_telemetry.md
+++ b/docs/metrics_logs_telemetry.md
@@ -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:
diff --git a/server/src/test/java/io/gatehill/imposter/server/ResourceLogTest.kt b/server/src/test/java/io/gatehill/imposter/server/ResourceLogTest.kt
new file mode 100644
index 000000000..c06da063f
--- /dev/null
+++ b/server/src/test/java/io/gatehill/imposter/server/ResourceLogTest.kt
@@ -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 .
+ */
+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()
+
+ override fun append(event: LogEvent) {
+ logEvents.add(event.toImmutable())
+ }
+
+ fun getLogEvents(): List = logEvents.toList()
+
+ fun clear() {
+ logEvents.clear()
+ }
+ }
+}
\ No newline at end of file
diff --git a/server/src/test/resources/resource-log/test-plugin-config.yaml b/server/src/test/resources/resource-log/test-plugin-config.yaml
new file mode 100644
index 000000000..9225cd6c9
--- /dev/null
+++ b/server/src/test/resources/resource-log/test-plugin-config.yaml
@@ -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"
\ No newline at end of file