Skip to content
Merged
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Changed

- Telemetry agent support: an `AgentHandlerProtocol` bean (for example guard-agent-jvm's `GuardAgentTelemetry`) is now injected into `GuardMiddleware` by the auto-configuration, adopted into the engine composite on initialization, and stopped through the new `GuardMiddleware.shutdown()` when the context closes. Before this, `guard.enable-agent=true` always degraded with a "W7 artifact" message, because the middleware had no way to receive a handler. The degrade path now only applies when no handler bean is registered. The pinned guard-core-jvm engine ref moves to 9067b7b, whose agent telemetry wiring fix (Guard-Core/guard-core-jvm#48) the handler needs to receive anything.
- `GuardMiddleware.shutdown()` now also closes the middleware's own redis handler, and stops the injected agent handler directly when initialization never ran (a lazily initialized app that saw no traffic), so an agent created with `cfg.redis` no longer leaks its persistence connection and daemon thread. It runs under the initialization mutex and is safe to call twice. The README documents that exactly one `AgentHandlerProtocol` bean is supported and adds the guard-agent-jvm dependency line.
- Dependencies: netty is pinned to 4.1.139.Final through `platform(io.netty:netty-bom)`. lettuce-core 6.8.2 declares netty 4.1.125, which carries twelve advisories fixed by 4.1.137 at the latest. The pin is declared here as well as in the guard-core-jvm facade, so this lockfile stays the same whichever engine revision the build resolves. The four netty osv-scanner suppressions are removed; the Spring Boot test-scope suppressions stay.

## [1.0.0] - 2026-10-13
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,11 @@ service container); redis-backed tests skip when no server answers.

## Telemetry agent

The adapter never builds the agent itself. Register a guard-agent-jvm handler as an `AgentHandlerProtocol` bean and set `guard.enable-agent=true` (with `guard.agent-api-key`, which the engine config requires when the agent is enabled). The auto-configuration hands the bean to `GuardMiddleware`, which starts it on initialization and stops it, flushing its buffer, when the application context closes:
The adapter never builds the agent itself. Register a guard-agent-jvm handler as an `AgentHandlerProtocol` bean and set `guard.enable-agent=true` (with `guard.agent-api-key`, which the engine config requires when the agent is enabled). The auto-configuration hands the bean to `GuardMiddleware`, which starts it on initialization and stops it, flushing its buffer, when the application context closes. Exactly one `AgentHandlerProtocol` bean is used: two beans fail startup with `NoUniqueBeanDefinitionException` unless one is `@Primary`. Add the agent to your own build:

```kotlin
implementation("com.guard-core:guard-agent-jvm:3.2.3")
```

```kotlin
import com.guardcore.agent.GuardAgent
Expand Down
22 changes: 20 additions & 2 deletions src/main/kotlin/com/guardcore/spring/GuardMiddleware.kt
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ public class GuardMiddleware(

private var initialized: Boolean = false

private var shutDown: Boolean = false

init {
// Host-side seam registration (the facade's documented host contract, CoreTestSupport.kt:132 /
// GuardCore.kt:15): wires the detection-engine seams (user-agent matcher G-5, straddle overlap)
Expand Down Expand Up @@ -439,10 +441,26 @@ public class GuardMiddleware(
/**
* Python `shutdown_agent_integrations` on application shutdown: stops the composite, which stops
* the injected agent and flushes its buffer. Spring calls it as the bean's inferred destroy
* method.
* method. Runs under [initMutex] so it cannot interleave with a first-request [initialize]. When
* no composite was built (initialization never ran or stopped early), the injected handler is
* stopped directly (its stop is idempotent). It also closes the middleware's own [redisHandler],
* which Spring constructs and therefore owns. Safe to call twice.
*/
public fun shutdown() {
kotlinx.coroutines.runBlocking { handlerInitializer.shutdownAgentIntegrations() }
kotlinx.coroutines.runBlocking {
initMutex.withLock {
if (shutDown) {
return@withLock
}
shutDown = true
if (handlerInitializer.compositeHandler != null) {
handlerInitializer.shutdownAgentIntegrations()
} else {
injectedAgentHandler?.stop()
}
redisHandler?.close()
}
}
}

/**
Expand Down
77 changes: 77 additions & 0 deletions src/test/kotlin/com/guardcore/spring/GuardAgentWiringTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@ package com.guardcore.spring
import com.guardcore.config.SecurityConfig
import com.guardcore.config.SecurityConfigBuilder
import com.guardcore.core.events.AgentTelemetryMember
import com.guardcore.errors.GuardRedisError
import com.guardcore.protocols.AgentHandlerProtocol
import com.guardcore.protocols.RedisHandlerProtocol
import kotlinx.coroutines.runBlocking
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Assumptions.assumeTrue
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.WebApplicationType
import org.springframework.boot.builder.SpringApplicationBuilder
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.TestConfiguration
Expand Down Expand Up @@ -98,6 +103,78 @@ public class GuardAgentWiringTest {
middleware.shutdown()
assertThat(telemetry.stops).isEqualTo(1)
}

@Test
public fun `shutdown without initialize stops the injected handler once`() {
val telemetry = RecordingTelemetry()
val middleware = GuardMiddleware(agentConfig(), injectedAgentHandler = telemetry)
middleware.shutdown()
assertThat(telemetry.stops).isEqualTo(1)
assertThat(telemetry.starts).isZero()
}

@Test
public fun `shutdown twice stops the handler once`() {
val telemetry = RecordingTelemetry()
val middleware = GuardMiddleware(agentConfig(), injectedAgentHandler = telemetry)
runBlocking { middleware.initialize() }
middleware.shutdown()
middleware.shutdown()
assertThat(telemetry.stops).isEqualTo(1)

val never = RecordingTelemetry()
val fresh = GuardMiddleware(agentConfig(), injectedAgentHandler = never)
fresh.shutdown()
fresh.shutdown()
assertThat(never.stops).isEqualTo(1)
}

@Test
public fun `shutdown closes the middleware redis handler and is safe twice`() {
assumeTrue(
RedisRateLimitIntegrationTest.redisAvailable(),
"live redis on localhost:6379 required",
)
val config =
SecurityConfigBuilder()
.apply {
enableRedis = true
redisUrl = "redis://localhost:6379"
redisPrefix = "guard-sb-shutdown-it"
}
.build()
val middleware = GuardMiddleware(config)
val redis = middleware.redisHandler!!
runBlocking { redis.withConnection { it } }

middleware.shutdown()
middleware.shutdown()

assertThatThrownBy { runBlocking { redis.withConnection { it } } }
.isInstanceOf(GuardRedisError::class.java)
.hasMessageContaining("closed")
}

@Test
public fun `closing the Spring context stops the agent bean`() {
val context =
SpringApplicationBuilder(GuardTestApp::class.java, AgentBeanConfig::class.java)
.web(WebApplicationType.SERVLET)
.properties(
"server.port=0",
"guard.enable-redis=false",
"guard.enable-agent=true",
"guard.agent-api-key=test-key",
)
.run()
val telemetry = context.getBean(RecordingTelemetry::class.java)
runBlocking { context.getBean(GuardMiddleware::class.java).initialize() }
assertThat(telemetry.starts).isEqualTo(1)
assertThat(telemetry.stops).isZero()

context.close()
assertThat(telemetry.stops).isEqualTo(1)
}
}

@TestConfiguration
Expand Down
Loading