Custom Interceptors
TramAI exposes three hook surfaces for inspecting and modifying data in the execution pipeline: OperationInterceptor for request and response rewriting, DlpInterceptor for output and tool-result scanning, and EngineEventObserver for engine-owned lifecycle events.
What This Covers
OperationInterceptor— request/response hooks for PII masking and auditingDlpInterceptor— model output and tool result scanningEngineEventObserver— engine-level event hooks- Interceptor ordering and composition
- Spring Boot wiring
- The 0.6.0 constraints every interceptor must respect
When To Use Each Surface
| Surface | Intercepts | Best for |
|---|---|---|
OperationInterceptor | Request messages and provider responses | PII masking, redaction, auditing |
DlpInterceptor | Model outputs and tool results | Data loss prevention, compliance scanning |
EngineEventObserver | Engine-owned resilience and routing events | Monitoring, alerting, custom metrics |
OperationInterceptor
Interface: dev.tramai.core.observation.OperationInterceptor (tramai-core/src/main/kotlin/dev/tramai/core/observation/OperationInterceptor.kt:11)
Both methods have pass-through defaults, so you only override the side you care about:
interface OperationInterceptor {
fun interceptRequest(
context: OperationCallContext,
messages: List<Message>,
): List<Message> = messages
fun interceptResponse(
context: OperationCallContext,
response: ModelResponse,
): ModelResponse = response
}
The context is OperationCallContext(serviceInterface, methodName, providerId, requestedModel, attempt) — one instance per provider attempt.
Minimum Setup
val cardMasking = object : OperationInterceptor {
private val cardPattern = Regex("\\b\\d{16}\\b")
override fun interceptRequest(
context: OperationCallContext,
messages: List<Message>,
): List<Message> = messages.map { message ->
message.copy(content = message.content.replace(cardPattern, "[CARD]"))
}
override fun interceptResponse(
context: OperationCallContext,
response: ModelResponse,
): ModelResponse = response.copy(
content = response.content.replace(cardPattern, "[CARD]"),
)
}
Standalone Registration
val tramai = Tramai {
provider(OpenAiProvider(System.getenv("OPENAI_API_KEY")), name = "openai", default = true)
model("gpt-4o", "openai")
interceptor(cardMasking)
}
Composition
Multiple interceptors are chained with CompositeOperationInterceptor (OperationInterceptor.kt:34), which folds them in order: each interceptRequest output becomes the next interceptor's input. NoOpOperationInterceptor (:52) is the default when none is registered.
DlpInterceptor
Interface: dev.tramai.core.security.DlpInterceptor (tramai-core/src/main/kotlin/dev/tramai/core/security/DlpInterceptor.kt)
A single-method interface — note it is not a fun interface, so use an object expression or a named class:
interface DlpInterceptor {
fun inspect(context: DlpContext, text: String): DlpResult
}
DlpContext
data class DlpContext(
val contentType: DlpContentType, // MODEL_OUTPUT or TOOL_RESULT
val contentLocation: DlpContentLocation, // MODEL_RESPONSE_CONTENT, TOOL_MESSAGE_CONTENT, TOOL_MESSAGE_TEXT_RUN
val operationInterface: String,
val operationMethod: String,
val providerId: String? = null,
val modelName: String? = null,
val toolName: String? = null,
val correlationId: String,
val workflowRunId: String? = null,
val dataClassification: DataClassification? = null,
val classificationSource: ClassificationSource? = null,
)
DlpResult
data class DlpResult(
val sanitizedText: String,
val redactions: List<DlpRedaction> = emptyList(),
) {
val hasRedactions: Boolean get() = redactions.isNotEmpty()
}
data class DlpRedaction(val ruleId: String, val replacementCount: Int)
sanitizedText is authoritative. DlpRedaction carries the rule identifier and replacement count only — raw matched values are deliberately excluded so sensitive data cannot leak through audit or exception paths.
Example: Regex Redaction
val ssnDlp = object : DlpInterceptor {
private val ssnPattern = Regex("\\b\\d{3}-\\d{2}-\\d{4}\\b")
private val cardPattern = Regex("\\b(?:\\d[ -]*?){13,16}\\b")
override fun inspect(context: DlpContext, text: String): DlpResult {
var sanitized = text
var redactions = 0
ssnPattern.findAll(sanitized).forEach { redactions++ }
sanitized = ssnPattern.replace(sanitized, "[SSN-REDACTED]")
sanitized = cardPattern.replace(sanitized, "[CARD-REDACTED]")
return DlpResult(sanitized, listOf(DlpRedaction("ssn-and-card", redactions)))
}
}
Example: Rejecting Classified Output
DlpInspectionException is deliberately distinct from a provider failure: it does not count toward provider circuit breakers and does not trigger fallback or retry.
val policyDlp = object : DlpInterceptor {
override fun inspect(context: DlpContext, text: String): DlpResult {
if (context.contentType == DlpContentType.MODEL_OUTPUT && containsClassified(text)) {
throw DlpInspectionException("Model output contains classified data")
}
return DlpResult(text)
}
}
Registration
Standalone:
val tramai = Tramai {
dlp(policyDlp)
}
Spring Boot: define exactly one DlpInterceptor bean, or none. More than one is a startup failure — TramaiAutoConfiguration rejects it with "Define exactly one DlpInterceptor bean or none." You can attach a DlpRedactionAuditEmitter bean with dlpRedactionAudit(...); the default is NoOpDlpRedactionAuditEmitter.
A ready-made regex interceptor ships in tramai-security: RuleBasedDlpInterceptor with RuleBasedDlpConfiguration.
EngineEventObserver
Interface: dev.tramai.engine.EngineEventObserver (tramai-engine/src/main/kotlin/dev/tramai/engine/EngineEventObserver.kt:5)
interface EngineEventObserver {
fun onEngineEvent(name: String, attributes: Map<String, Any?>)
fun onEngineEvent(event: RuntimeEvent) {
onEngineEvent(event.name, event.attributes())
}
}
Implementing the (name, attributes) form is enough — the typed RuntimeEvent overload defaults to routing through it.
Observable Events
Event identities come from RuntimeEventCatalogue in tramai-core. Verified names include:
| Event name | When it fires |
|---|---|
tramai.route.selected | A provider route was selected for a request |
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 not available for budget accounting |
tramai.token_budget.soft_limit_exceeded | The soft token budget limit was crossed |
tramai.token_budget.hard_limit_exceeded | The hard token budget limit was crossed |
Example: Alerting
val alertObserver = object : EngineEventObserver {
override fun onEngineEvent(name: String, attributes: Map<String, Any?>) {
when (name) {
"tramai.dlp.inspection_failed", "tramai.dlp.tool_result_rejected" ->
alertSecurityTeam("$name: $attributes")
"tramai.circuit.opened" ->
alertPlatformTeam("Provider circuit opened: ${attributes["providerId"]}")
else -> Unit
}
}
}
Registration
val tramai = Tramai {
engineEventObserver(alertObserver)
}
Spring Boot: at most one EngineEventObserver bean. Multiple beans are rejected at startup.
Built-in Implementations
| Implementation | Module | Purpose |
|---|---|---|
NoOpOperationInterceptor | tramai-core | Default pass-through interceptor |
CompositeOperationInterceptor | tramai-core | Chains interceptors in order |
NoOpDlpInterceptor | tramai-core | Default pass-through DLP |
NoOpDlpRedactionAuditEmitter | tramai-core | Default no-op redaction audit |
RuleBasedDlpInterceptor | tramai-security | Configurable regex-based DLP |
NoOpEngineEventObserver | tramai-engine | Default no-op engine event observer |
Execution Order
Within one provider attempt:
OperationInterceptor.interceptRequest— modify messages before the provider call- Provider call
DlpInterceptor.inspect— scan model output or tool resultOperationInterceptor.interceptResponse— modify the provider responseEngineEventObserver.onEngineEvent— engine-owned events for the attempt
0.6.0 Constraints
Interceptors and observers run on the hot path, inside verifyStaticSafetyGuards and verifyCancellationSafety. Every interceptor must respect all four rules:
- No sensitive-payload logging. Never log, echo, or re-emit raw prompts, model responses, tool arguments, tokens, or credentials.
verifyStaticSafetyGuardsbans sensitive-payload logging in production code. - No
System.out/System.err. Use a logger. Print statements in production code fail the static safety gate. - Cancellation must propagate. In any
catch (e: Exception)block, calle.rethrowIfCancellation()fromdev.tramai.core.coroutines.rethrowIfCancellationbefore wrapping, logging, or swallowing.CancellationExceptionmust never be converted into an ordinary failure. - No unbounded reads. Read response bodies through the bounded helpers; raw unbounded stream reads are banned.
import dev.tramai.core.coroutines.rethrowIfCancellation
override fun interceptRequest(context: OperationCallContext, messages: List<Message>): List<Message> =
try {
messages.map { mask(it) }
} catch (e: Exception) {
e.rethrowIfCancellation()
messages
}
Limitations
- A DLP failure does not count toward circuit breakers and does not trigger retry or fallback.
- Spring Boot supports at most one
DlpInterceptorbean and at most oneEngineEventObserverbean; multipleOperationInterceptorbeans are composed in order. - DLP interceptors fire per provider response, not per stream chunk.
Next Steps
- Custom Observers — the observation surfaces
- Custom Providers — implement the provider SPI
- Adding an Event — declare a new runtime event
- Production Hardening — token budgets and circuit breakers
