Skip to content

Tag agent calls with business outcomes

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-corp
business.customer.id → cust-42
business.customer.segment → enterprise
business.session.revenue → 4500.00
business.session.cost → 12.40
business.session.currency → USD
business.session.id → sess-9f21...
business.event.kind → billing_enquiry
business.event.subject → invoice-2026-03

Now 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:

EnumWire name
NEW_CONVERSATIONnew_conversation
RETURNING_USERreturning_user
PURCHASEpurchase
SUPPORT_ESCALATIONsupport_escalation
CHURN_RISKchurn_risk
BILLING_ENQUIRYbilling_enquiry
OTHERother

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/