TramAI - governed AI workflows for Java and Kotlin

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 auditing
  • DlpInterceptor — model output and tool result scanning
  • EngineEventObserver — 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

SurfaceInterceptsBest for
OperationInterceptorRequest messages and provider responsesPII masking, redaction, auditing
DlpInterceptorModel outputs and tool resultsData loss prevention, compliance scanning
EngineEventObserverEngine-owned resilience and routing eventsMonitoring, 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 nameWhen it fires
tramai.route.selectedA provider route was selected for a request
tramai.retry.scheduledA retry attempt was scheduled
tramai.circuit.openedA provider circuit breaker opened
tramai.dlp.inspection_failedDLP inspection failed
tramai.dlp.tool_result_rejectedA tool result was rejected by DLP
tramai.token_budget.usage_unavailableUsage data was not available for budget accounting
tramai.token_budget.soft_limit_exceededThe soft token budget limit was crossed
tramai.token_budget.hard_limit_exceededThe 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

ImplementationModulePurpose
NoOpOperationInterceptortramai-coreDefault pass-through interceptor
CompositeOperationInterceptortramai-coreChains interceptors in order
NoOpDlpInterceptortramai-coreDefault pass-through DLP
NoOpDlpRedactionAuditEmittertramai-coreDefault no-op redaction audit
RuleBasedDlpInterceptortramai-securityConfigurable regex-based DLP
NoOpEngineEventObservertramai-engineDefault no-op engine event observer

Execution Order

Within one provider attempt:

  1. OperationInterceptor.interceptRequest — modify messages before the provider call
  2. Provider call
  3. DlpInterceptor.inspect — scan model output or tool result
  4. OperationInterceptor.interceptResponse — modify the provider response
  5. EngineEventObserver.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. verifyStaticSafetyGuards bans 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, call e.rethrowIfCancellation() from dev.tramai.core.coroutines.rethrowIfCancellation before wrapping, logging, or swallowing. CancellationException must 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 DlpInterceptor bean and at most one EngineEventObserver bean; multiple OperationInterceptor beans are composed in order.
  • DLP interceptors fire per provider response, not per stream chunk.

Next Steps