Custom Observers
TramAI exposes engine lifecycle events through EngineEventObserver, letting you build custom monitoring, alerting, and metrics without depending on OpenTelemetry.
What This Covers
- EngineEventObserver SPI
- Observable engine events and their attributes
- Custom metrics recording
- Event-driven behavior patterns
- Standalone and Spring Boot registration
When to Use Custom Observers
Use EngineEventObserver when you need:
- custom alerting for DLP rejections or circuit breaker events
- cost tracking and token budget monitoring
- custom metrics in a non-OTel format (Prometheus, Micrometer, custom dashboard)
- operational logging for security-relevant events
- event-driven behavior (pausing workflows, triggering remediation)
For full OpenTelemetry tracing, use OpenTelemetryOperationObserver from tramai-observability instead.
Minimum Setup
SPI Interface
fun interface EngineEventObserver {
fun onEngineEvent(name: String, attributes: Map<String, Any?>)
}
A fun interface with a single method — implement it inline or as a class.
Standalone Registration
val tramai = Tramai {
provider(OpenAiProvider(System.getenv("OPENAI_API_KEY")), name = "openai", default = true)
model("gpt-4o", "openai")
engineEventObserver(EngineEventObserver { name, attributes ->
println("Engine event: $name $attributes")
})
}
Spring Boot Registration
@Bean
fun loggingEngineEventObserver(): EngineEventObserver = EngineEventObserver { name, attributes ->
log.info("Engine event: {} {}", name, attributes)
}
At most one EngineEventObserver bean is supported. If more than one is found, auto-configuration throws at startup.
Observable Events
DLP Events
| Event name | When it fires | Attributes |
|---|---|---|
dlp.rejection | DLP interceptor rejected output | operationInterface, operationMethod, providerId, modelName, reason |
dlp.redaction | DLP interceptor redacted content | redactionCount, ruleIds, contentType |
Circuit Breaker Events
| Event name | When it fires | Attributes |
|---|---|---|
circuit_breaker.opened | Circuit breaker tripped | providerId, failureCount, durationMs |
circuit_breaker.closed | Circuit breaker reset after cooldown | providerId, openDurationMs |
circuit_breaker.half_open | Circuit breaker in test state | providerId |
Token Budget Events
| Event name | When it fires | Attributes |
|---|---|---|
token_budget.warning | Soft token limit crossed | current, limit, operationInterface, operationMethod |
token_budget.exceeded | Hard token limit crossed (operation failed) | current, limit, operationInterface, operationMethod |
Retry and Fallback Events
| Event name | When it fires | Attributes |
|---|---|---|
retry.scheduled | Retry attempt scheduled | attempt, maxRetries, delayMs, reason |
retry.exhausted | All retry attempts exhausted | attempts, reason |
fallback.activated | Fallback provider selected | originalProvider, originalModel, fallbackProvider, fallbackModel |
fallback.failed | Fallback also failed | provider, model, reason |
Example: Security Alerting
val securityAlertObserver = EngineEventObserver { name, attributes ->
when (name) {
"dlp.rejection" -> {
val source = "${attributes["operationInterface"]}.${attributes["operationMethod"]}"
val reason = attributes["reason"] ?: "unknown"
securityAlert("DLP block in $source: $reason")
}
"circuit_breaker.opened" -> {
val provider = attributes["providerId"] ?: "unknown"
securityAlert("Provider $provider circuit breaker opened")
}
}
}
Example: Custom Metrics
Record engine events as Prometheus or Micrometer metrics:
class MetricsEngineEventObserver(
private val meterRegistry: MeterRegistry,
) : EngineEventObserver {
private val dlpRejections = meterRegistry.counter("tramai.dlp.rejections")
private val tokenWarnings = meterRegistry.counter("tramai.token_budget.warnings")
private val circuitBreakers = meterRegistry.counter("tramai.circuit_breaker.opens")
override fun onEngineEvent(name: String, attributes: Map<String, Any?>) {
when (name) {
"dlp.rejection" -> dlpRejections.increment()
"token_budget.warning" -> tokenWarnings.increment()
"circuit_breaker.opened" -> circuitBreakers.increment()
}
}
}
Registration:
@Bean
fun metricsEngineEventObserver(registry: MeterRegistry): EngineEventObserver =
MetricsEngineEventObserver(registry)
Example: Event-Driven Behavior
Trigger external actions when events occur:
class RemediationEngineEventObserver(
private val pager: PagerDutyClient,
private val workflowPauser: WorkflowPauser,
) : EngineEventObserver {
override fun onEngineEvent(name: String, attributes: Map<String, Any?>) {
when (name) {
"circuit_breaker.opened" -> {
val provider = attributes["providerId"] as? String ?: return
pager.trigger("Provider $provider circuit breaker opened")
}
"token_budget.exceeded" -> {
val method = "${attributes["operationInterface"]}.${attributes["operationMethod"]}"
workflowPauser.pauseOperations(method)
}
}
}
}
Relationship to OpenTelemetry
| Feature | EngineEventObserver | OpenTelemetryOperationObserver |
|---|---|---|
| Engine events | Direct callback | Mapped to OTel events |
| Latency spans | No | Yes |
| Token usage metrics | Via custom code | Built-in |
| Exporter dependency | None | Requires OTel SDK + exporter |
| Best for | Custom alerting, small deployments, non-OTel stacks | Full observability pipeline |
Both can coexist — the engine dispatches events to all registered observers.
Limitations
- At most one
EngineEventObserverbean in Spring Boot auto-configuration - The observer receives events synchronously — heavy processing should be dispatched to a separate thread
- Event names and attribute keys are not yet documented as API-stable (use them, but expect possible renaming)
- No built-in event buffering or batching (implement in your observer if needed)
Next Steps
- Custom Interceptors — DLP, redaction, and operation interceptors
- Observability — OpenTelemetry for TramAI
- Production Hardening — token budgets, circuit breakers
