AI Filters & Guardrails
PII redaction + drift detection sit on the same request/response
path. /tutorial/12-ai-filters/
Your AI bill is growing. Your LLM spend per tenant is something you can compute from provider invoices. Your LLM spend per feature or per paying customer is a guess — because nothing links the outbound call to the business context that triggered it.
BusinessMetadata is the primitive that closes that loop. You tag
each request with tenant id, customer id, session revenue, session
cost, and the kind of event it belongs to; the framework publishes
those tags onto SLF4J MDC for the duration of the turn; any
observability backend that consumes MDC (Dynatrace, Datadog,
OpenTelemetry log exporters, plain Logback JSON appenders) propagates
them onto the active span.
session.stream(request.withMetadata(Map.of( BusinessMetadata.TENANT_ID, "acme-corp", BusinessMetadata.CUSTOMER_ID, "cust-42", BusinessMetadata.CUSTOMER_SEGMENT, "enterprise", BusinessMetadata.SESSION_REVENUE, 4500.00, BusinessMetadata.SESSION_COST, 12.40, BusinessMetadata.SESSION_CURRENCY, "USD", BusinessMetadata.SESSION_ID, session.id(), BusinessMetadata.EVENT_KIND, BusinessMetadata.EventKind.BILLING_ENQUIRY.wireName(), BusinessMetadata.EVENT_SUBJECT, "invoice-2026-03")));On the wire those land in AiRequest.metadata, which
AiEndpointHandler copies onto SLF4J MDC via applyBusinessMdc on
the dispatching virtual thread. Every log record emitted during the
turn — pipeline, runtime, tool calls, guardrails — carries the tags.
When the turn completes, MDC is cleared in finally so the next turn
on the same VT pool starts clean.
The published keys map 1:1 onto OpenTelemetry semantic-convention
attribute names under the business.* namespace. Point your log
exporter at MDC and the span attributes appear automatically.
business.tenant.id → acme-corpbusiness.customer.id → cust-42business.customer.segment → enterprisebusiness.session.revenue → 4500.00business.session.cost → 12.40business.session.currency → USDbusiness.session.id → sess-9f21...business.event.kind → billing_enquirybusiness.event.subject → invoice-2026-03Now every Micrometer ai.tokens.* meter, every pipeline log line,
every AgentLifecycleListener span — all of them are joinable with
the business KPI. Dashboards like “cost per paying customer per
conversation” become a group-by query.
BusinessMetadata.EventKind is a small enum of canonical event tags:
| Enum | Wire name |
|---|---|
NEW_CONVERSATION | new_conversation |
RETURNING_USER | returning_user |
PURCHASE | purchase |
SUPPORT_ESCALATION | support_escalation |
CHURN_RISK | churn_risk |
BILLING_ENQUIRY | billing_enquiry |
OTHER | other |
Use .wireName() when writing, EventKind.fromWire(raw) when
reading — unknown wire strings normalize to OTHER so a typo cannot
pollute the dashboard with cardinality-exploding values.
Logback JSON (logback.xml):
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender"> <encoder class="net.logstash.logback.encoder.LogstashEncoder"> <includeMdcKeyName>business.tenant.id</includeMdcKeyName> <includeMdcKeyName>business.customer.id</includeMdcKeyName> <includeMdcKeyName>business.session.revenue</includeMdcKeyName> <includeMdcKeyName>business.session.cost</includeMdcKeyName> <includeMdcKeyName>business.event.kind</includeMdcKeyName> </encoder></appender>OpenTelemetry Java agent already propagates MDC onto span attributes by default — no extra wiring required.
Dynatrace / Datadog — any MDC-aware log processor picks the tags up automatically; they appear as custom attributes on the correlated span.
AI Filters & Guardrails
PII redaction + drift detection sit on the same request/response
path. /tutorial/12-ai-filters/
Ground every turn in real facts
Sibling primitive — injects deterministic facts into the system
prompt on every turn. /tutorial/28-fact-resolver/
Foundation Primitives Tour
How the other seven primitives fit together.
/tutorial/26-foundation-primitives/