Custom Observers
TramAI exposes observation through dev.tramai.core.observation. The primary surface is OperationObserver, which the engine calls once per provider attempt and which returns an OperationObservation for that attempt. Engine-owned resilience and routing events arrive through the same observation object, and EngineEventObserver provides a coarser engine-level hook.
What This Covers
- The
OperationObserver/OperationObservationpair - Observable engine events and their catalogue identities
- Custom metrics and alerting
- Diagnostic channels for provider, tool, and structured-output failures
- Standalone and Spring Boot registration
When To Use It
Use OperationObserver when you need:
- custom alerting on retries, circuit opens, DLP rejections, or token budget breaches
- cost tracking and token budget monitoring
- custom metrics in a non-OTel format (Prometheus, Micrometer, a custom dashboard)
- operational logging for security-relevant events
- per-attempt assertions in tests
For full OpenTelemetry tracing, use OpenTelemetryOperationObserver from tramai-observability — see Observability. For application tests, RecordingOperationObserver from tramai-testing is usually what you want.
The Observation Pair
OperationObserver is a fun interface (tramai-core/src/main/kotlin/dev/tramai/core/observation/OperationObservation.kt:24):
fun interface OperationObserver {
fun onCallStarted(context: OperationCallContext): OperationObservation
}
It starts observation for a single provider attempt. OperationCallContext carries serviceInterface, methodName, providerId, requestedModel, and the zero-based attempt index.
The returned OperationObservation is a plain interface with these callbacks (OperationObservation.kt:34):
| Callback | When |
|---|---|
onProviderResponse(response: ModelResponse) | A provider response arrived, before structured parsing is known |
onProviderFailure(error: Throwable) | The provider call failed |
onStructuredParseFailure(rawResponse, errorSummary) | Structured parsing or validation failed and a retry may follow |
onEngineEvent(name: String, attributes: Map<String, Any?>) | An engine-owned resilience or routing event occurred |
onEngineEvent(event: RuntimeEvent) | Catalogue-validated form; defaults to routing through the pair above |
onCallCompleted(parseSuccess: Boolean?) | The attempt ended. parseSuccess is null for raw string and unit operations |
onCallCancelled() | The attempt was cancelled externally; defaults to ending the call as a non-error terminal |
onEngineEvent, onCallCancelled, and the typed overload have defaults, so a minimal implementation only fills in what it needs.
onStructuredParseFailure is an ordinary observability surface: it no longer receives raw diagnostic material. The structured-output diagnostic observer is the sole privileged channel.
Minimum Setup
import dev.tramai.core.observation.OperationObservation
import dev.tramai.core.observation.OperationObserver
import dev.tramai.core.observation.OperationCallContext
class MyObserver : OperationObserver {
override fun onCallStarted(context: OperationCallContext): OperationObservation =
object : OperationObservation {
override fun onProviderResponse(response: ModelResponse) = Unit
override fun onProviderFailure(error: Throwable) = Unit
override fun onStructuredParseFailure(rawResponse: String, errorSummary: String) = Unit
override fun onCallCompleted(parseSuccess: Boolean?) = Unit
}
}
Standalone Registration
val tramai = Tramai {
provider(OpenAiProvider(System.getenv("OPENAI_API_KEY")), name = "openai", default = true)
model("gpt-4o", "openai")
observer(MyObserver())
}
Spring Boot Registration
@Bean
fun tramaiObserver(): OperationObserver = MyObserver()
TramAI's auto-configuration picks up OperationObserver, OperationInterceptor, DlpInterceptor, DlpRedactionAuditEmitter, and EngineEventObserver beans. DlpInterceptor and EngineEventObserver are single-bean surfaces; OperationInterceptor beans are composed in order.
Observable Engine Events
Event identities are declared in RuntimeEventCatalogue in tramai-core and referenced at compile time through the RuntimeEvents registry, so names and attribute keys are a stable contract in 0.6.0. Verified names include:
| Event name | When it fires |
|---|---|
tramai.route.selected | A provider route was selected |
tramai.retry.scheduled | A retry attempt was scheduled |
tramai.circuit.opened | A provider circuit breaker opened |
tramai.dlp.inspection_failed | DLP inspection failed |
tramai.dlp.tool_result_rejected | A tool result was rejected by DLP |
tramai.token_budget.usage_unavailable | Usage data was unavailable for budget accounting |
tramai.token_budget.soft_limit_exceeded | Soft token budget crossed |
tramai.token_budget.hard_limit_exceeded | Hard token budget crossed |
tramai.parse.failure | Structured parsing failed for an attempt |
Attribute keys are the canonical RuntimeAttributeKey names in RuntimeAttributes.kt — for example tramai.event.name, provider_id, retry_index, delay_millis, failure_type, and reasonCode.
Example: Security Alerting
class SecurityAlertObserver : OperationObserver {
override fun onCallStarted(context: OperationCallContext): OperationObservation =
object : OperationObservation {
override fun onProviderResponse(response: ModelResponse) = Unit
override fun onProviderFailure(error: Throwable) = Unit
override fun onStructuredParseFailure(rawResponse: String, errorSummary: String) = Unit
override fun onCallCompleted(parseSuccess: Boolean?) = Unit
override fun onEngineEvent(name: String, attributes: Map<String, Any?>) {
when (name) {
"tramai.dlp.tool_result_rejected", "tramai.dlp.inspection_failed" ->
alertSecurityTeam("$name: $attributes")
"tramai.circuit.opened" ->
alertPlatformTeam("Provider circuit opened: ${attributes["providerId"]}")
else -> Unit
}
}
}
}
Example: Custom Metrics
class MetricsObserver(
private val meterRegistry: MeterRegistry,
) : OperationObserver {
private val attempts = meterRegistry.counter("tramai.operation.attempts.local")
private val parseFailures = meterRegistry.counter("tramai.parse.failures.local")
private val circuitOpens = meterRegistry.counter("tramai.circuit.opens.local")
override fun onCallStarted(context: OperationCallContext): OperationObservation =
object : OperationObservation {
override fun onProviderResponse(response: ModelResponse) = Unit
override fun onProviderFailure(error: Throwable) = Unit
override fun onStructuredParseFailure(rawResponse: String, errorSummary: String) {
parseFailures.increment()
}
override fun onCallCompleted(parseSuccess: Boolean?) {
attempts.increment()
}
override fun onEngineEvent(name: String, attributes: Map<String, Any?>) {
if (name == "tramai.circuit.opened") circuitOpens.increment()
}
}
}
Diagnostic Channels
Three narrow diagnostic surfaces carry sanitized failure detail that the ordinary observation path deliberately does not receive. Each has a NoOp default:
| Surface | Carries |
|---|---|
ProviderFailureDiagnosticObserver | Provider failure code, HTTP status, retryability, retry-after, and a bounded error-body preview |
ToolFailureDiagnosticObserver | Tool failure detail |
StructuredOutputFailureDiagnosticObserver | Structured-output failure classification detail |
Register the provider-side one through the builder with toolFailureDiagnosticObserver(...); defaults are NoOpProviderFailureDiagnosticObserver, NoOpToolFailureDiagnosticObserver, and NoOpStructuredOutputFailureDiagnosticObserver.
Relationship to OpenTelemetry
| Feature | OperationObserver | OpenTelemetryOperationObserver |
|---|---|---|
| Per-attempt spans | No | Yes |
| Token usage metrics | Via your own code | Built-in |
| Exporter dependency | None | Requires the OTel SDK and an exporter |
| Best for | Custom alerting, small deployments, non-OTel stacks | Full observability pipeline |
OpenTelemetryOperationObserver is itself an OperationObserver, so you can compose: register it alongside your own observer, and the engine dispatches to both. See Observability.
0.6.0 Constraints
- No sensitive-payload logging. Do not log prompts, responses, tool arguments, tokens, or credentials.
verifyStaticSafetyGuardsbans sensitive-payload logging in production code. - No
System.out/System.err. Use a logger. - Cancellation must propagate. Call
rethrowIfCancellation()fromdev.tramai.core.coroutines.rethrowIfCancellationin anycatch (e: Exception)block before wrapping or logging.onCallCancelled()is the observation hook for a cancelled attempt — it is not an error path. - Observers are on the hot path. Heavy processing should be dispatched off the calling thread.
Limitations
- The
(name, attributes)onEngineEventcallback is synchronous — do not block the invocation flow in it. - There is no built-in event buffering or batching; implement it in your observer if you need it.
- At most one
EngineEventObserverbean is supported in Spring Boot.
Next Steps
- Custom Interceptors — DLP, redaction, and request/response rewriting
- Observability — OpenTelemetry wiring
- Adding an Event — declare a new catalogue event
- Production Hardening — token budgets and circuit breakers
