Skip to content

Commit 6c05e00

Browse files
committed
docs(tutorials): polish 27/28/29 — drift fixes, Starlight asides, flow-graph SVG
Convert to .mdx with Card/CardGrid for See Also. Tutorial 27 adds SESSION_COST and EVENT_SUBJECT. Tutorial 28 drops the removed FactResolver.cacheHint example. Tutorial 29 documents atmosphere.admin.http-read-auth-required and opens with an inline SVG flow graph matching the JSON below.
1 parent 8473223 commit 6c05e00

3 files changed

Lines changed: 535 additions & 0 deletions

File tree

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
---
2+
title: "Tag agent calls with business outcomes"
3+
description: "Join every AI call to the tenant, customer, session, and event that drove it — so Dynatrace / Datadog / OTel dashboards can answer 'how much did this agent cost me per paying customer?'"
4+
---
5+
6+
import { Card, CardGrid } from '@astrojs/starlight/components';
7+
8+
# Tag agent calls with business outcomes
9+
10+
Your AI bill is growing. Your LLM spend per tenant is something you
11+
can compute from provider invoices. Your LLM spend per *feature* or
12+
per *paying customer* is a guess — because nothing links the outbound
13+
call to the business context that triggered it.
14+
15+
`BusinessMetadata` is the primitive that closes that loop. You tag
16+
each request with tenant id, customer id, session revenue, session
17+
cost, and the kind of event it belongs to; the framework publishes
18+
those tags onto SLF4J MDC for the duration of the turn; any
19+
observability backend that consumes MDC (Dynatrace, Datadog,
20+
OpenTelemetry log exporters, plain Logback JSON appenders) propagates
21+
them onto the active span.
22+
23+
## The fix
24+
25+
```java
26+
session.stream(request.withMetadata(Map.of(
27+
BusinessMetadata.TENANT_ID, "acme-corp",
28+
BusinessMetadata.CUSTOMER_ID, "cust-42",
29+
BusinessMetadata.CUSTOMER_SEGMENT, "enterprise",
30+
BusinessMetadata.SESSION_REVENUE, 4500.00,
31+
BusinessMetadata.SESSION_COST, 12.40,
32+
BusinessMetadata.SESSION_CURRENCY, "USD",
33+
BusinessMetadata.SESSION_ID, session.id(),
34+
BusinessMetadata.EVENT_KIND,
35+
BusinessMetadata.EventKind.BILLING_ENQUIRY.wireName(),
36+
BusinessMetadata.EVENT_SUBJECT, "invoice-2026-03")));
37+
```
38+
39+
On the wire those land in `AiRequest.metadata`, which
40+
`AiEndpointHandler` copies onto SLF4J MDC via `applyBusinessMdc` on
41+
the dispatching virtual thread. Every log record emitted during the
42+
turn — pipeline, runtime, tool calls, guardrails — carries the tags.
43+
When the turn completes, MDC is cleared in `finally` so the next turn
44+
on the same VT pool starts clean.
45+
46+
## Reading the tags back in your dashboards
47+
48+
The published keys map 1:1 onto OpenTelemetry semantic-convention
49+
attribute names under the `business.*` namespace. Point your log
50+
exporter at MDC and the span attributes appear automatically.
51+
52+
```
53+
business.tenant.id → acme-corp
54+
business.customer.id → cust-42
55+
business.customer.segment → enterprise
56+
business.session.revenue → 4500.00
57+
business.session.cost → 12.40
58+
business.session.currency → USD
59+
business.session.id → sess-9f21...
60+
business.event.kind → billing_enquiry
61+
business.event.subject → invoice-2026-03
62+
```
63+
64+
Now every Micrometer `ai.tokens.*` meter, every pipeline log line,
65+
every `AgentLifecycleListener` span — all of them are joinable with
66+
the business KPI. Dashboards like "cost per paying customer per
67+
conversation" become a group-by query.
68+
69+
## Event kinds
70+
71+
`BusinessMetadata.EventKind` is a small enum of canonical event tags:
72+
73+
| Enum | Wire name |
74+
|------|-----------|
75+
| `NEW_CONVERSATION` | `new_conversation` |
76+
| `RETURNING_USER` | `returning_user` |
77+
| `PURCHASE` | `purchase` |
78+
| `SUPPORT_ESCALATION` | `support_escalation` |
79+
| `CHURN_RISK` | `churn_risk` |
80+
| `BILLING_ENQUIRY` | `billing_enquiry` |
81+
| `OTHER` | `other` |
82+
83+
Use `.wireName()` when writing, `EventKind.fromWire(raw)` when
84+
reading — unknown wire strings normalize to `OTHER` so a typo cannot
85+
pollute the dashboard with cardinality-exploding values.
86+
87+
:::caution[What not to put in MDC]
88+
MDC tags leak into every log line. The framework publishes only the
89+
nine well-known keys listed above. Do not extend the set with
90+
free-form user content — that includes message bodies, PII, prompts,
91+
tool arguments. Use the [`PiiRedactionGuardrail`](./12-ai-filters/) if
92+
you need to strip PII from the request/response path.
93+
:::
94+
95+
:::note[When NOT to use this]
96+
- **A single-tenant hobby project** — the overhead is not worth it.
97+
- **Debugging a specific call-path** — MDC is for aggregation; use
98+
structured logging for individual-request troubleshooting.
99+
- **Per-token cost attribution** — use the `AiMetrics.recordUsage`
100+
surface instead (Micrometer tags are cheaper than MDC at high
101+
cardinality).
102+
:::
103+
104+
## Wiring into your observability stack
105+
106+
**Logback JSON** (`logback.xml`):
107+
108+
```xml
109+
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
110+
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
111+
<includeMdcKeyName>business.tenant.id</includeMdcKeyName>
112+
<includeMdcKeyName>business.customer.id</includeMdcKeyName>
113+
<includeMdcKeyName>business.session.revenue</includeMdcKeyName>
114+
<includeMdcKeyName>business.session.cost</includeMdcKeyName>
115+
<includeMdcKeyName>business.event.kind</includeMdcKeyName>
116+
</encoder>
117+
</appender>
118+
```
119+
120+
**OpenTelemetry Java agent** already propagates MDC onto span
121+
attributes by default — no extra wiring required.
122+
123+
**Dynatrace / Datadog** — any MDC-aware log processor picks the tags
124+
up automatically; they appear as custom attributes on the correlated
125+
span.
126+
127+
## See also
128+
129+
<CardGrid>
130+
<Card title="AI Filters & Guardrails" icon="seti:default">
131+
PII redaction + drift detection sit on the same request/response
132+
path. [`/tutorial/12-ai-filters/`](./12-ai-filters/)
133+
</Card>
134+
<Card title="Ground every turn in real facts" icon="approve-check">
135+
Sibling primitive — injects deterministic facts into the system
136+
prompt on every turn. [`/tutorial/28-fact-resolver/`](./28-fact-resolver/)
137+
</Card>
138+
<Card title="Foundation Primitives Tour" icon="open-book">
139+
How the other seven primitives fit together.
140+
[`/tutorial/26-foundation-primitives/`](./26-foundation-primitives/)
141+
</Card>
142+
</CardGrid>
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
---
2+
title: "Ground every agent turn in real facts"
3+
description: "Stop the model from hallucinating the user's name, plan tier, or the current time — inject verifiable facts into the system prompt on every turn"
4+
---
5+
6+
import { Card, CardGrid } from '@astrojs/starlight/components';
7+
8+
# Ground every agent turn in real facts
9+
10+
When an agent asks "what's the user's current plan tier?", a language
11+
model will cheerfully make one up. When it asks "what time is it?",
12+
it'll guess. The fix is not a better prompt — it's a companion
13+
deterministic layer that supplies the ground truth on every turn.
14+
15+
`FactResolver` is that companion. You implement it, you declare which
16+
keys you supply, and Atmosphere prepends the resolved bundle to the
17+
system prompt before every dispatch.
18+
19+
## The default behavior
20+
21+
Atmosphere ships `DefaultFactResolver` out of the box. It supplies
22+
`time.now` (UTC ISO-8601) and `time.timezone` only. With no extra
23+
wiring, every turn's system prompt starts with:
24+
25+
```
26+
Grounded facts (deterministic, as of this turn):
27+
- time.now: 2026-04-19T18:32:14Z
28+
- time.timezone: UTC
29+
```
30+
31+
That alone closes the "what year is it?" class of hallucination.
32+
33+
## Writing a production resolver
34+
35+
Apps typically want richer facts — user name, locale, plan tier,
36+
feature-flag values, recent audit events. Implement `FactResolver`
37+
and supply any subset of keys from `FactKeys` plus your own `app.*`
38+
keys.
39+
40+
```java
41+
public final class UserProfileFactResolver implements FactResolver {
42+
43+
private final ProfileService profiles;
44+
private final FeatureFlags flags;
45+
private final AuditLog audit;
46+
47+
@Override
48+
public FactBundle resolve(FactRequest req) {
49+
var out = new LinkedHashMap<String, Object>();
50+
51+
if (req.keys().contains(FactKeys.USER_NAME)) {
52+
profiles.lookup(req.userId())
53+
.ifPresent(p -> out.put(FactKeys.USER_NAME, p.name()));
54+
}
55+
if (req.keys().contains(FactKeys.USER_LOCALE)) {
56+
out.put(FactKeys.USER_LOCALE,
57+
profiles.lookup(req.userId())
58+
.map(Profile::locale)
59+
.orElse("en-US"));
60+
}
61+
if (req.keys().contains(FactKeys.USER_PLAN_TIER)) {
62+
out.put(FactKeys.USER_PLAN_TIER,
63+
profiles.lookup(req.userId())
64+
.map(Profile::planTier)
65+
.orElse("free"));
66+
}
67+
// Custom app key — the framework treats unknown keys transparently.
68+
if (req.keys().contains("app.recent_order_id")) {
69+
profiles.lastOrder(req.userId())
70+
.ifPresent(o -> out.put("app.recent_order_id", o.id()));
71+
}
72+
return new FactBundle(out);
73+
}
74+
}
75+
```
76+
77+
:::tip[Contracts to honor]
78+
- **Thread-safe**`resolve()` is called from every inbound agent
79+
turn, possibly concurrently.
80+
- **Never returns null** — return `FactBundle.empty()` rather than
81+
`null` when nothing applies.
82+
- **Cheap per-call** — the resolver runs on the request hot path
83+
before every LLM dispatch. Cache expensive lookups inside your
84+
implementation; the framework does not cache `resolve()` results.
85+
:::
86+
87+
## Wiring the resolver
88+
89+
Three ways to install, in priority order:
90+
91+
### 1. Spring bean (recommended)
92+
93+
```java
94+
@Configuration
95+
class FactResolverConfig {
96+
@Bean
97+
FactResolver productionResolver(ProfileService profiles,
98+
FeatureFlags flags,
99+
AuditLog audit) {
100+
return new UserProfileFactResolver(profiles, flags, audit);
101+
}
102+
}
103+
```
104+
105+
`AtmosphereAiAutoConfiguration.atmosphereFactResolverBridge` publishes
106+
the bean onto `framework.properties()` under
107+
`FactResolver.FACT_RESOLVER_PROPERTY`;
108+
`AiEndpointHandler.resolveFactResolver` finds it first.
109+
110+
### 2. ServiceLoader (plain servlet / Quarkus / embedded)
111+
112+
Drop a file at
113+
`META-INF/services/org.atmosphere.ai.facts.FactResolver` containing
114+
your resolver's fully-qualified class name. The handler's
115+
`ServiceLoader.load(FactResolver.class).findFirst()` picks it up.
116+
117+
### 3. Manual install (tests, CLI tools)
118+
119+
```java
120+
FactResolverHolder.install(new UserProfileFactResolver(...));
121+
```
122+
123+
The process-wide holder is the lowest priority fallback — mostly
124+
useful for test setup via `@BeforeAll` + `FactResolverHolder.reset()`
125+
in `@AfterEach`.
126+
127+
## What the model sees
128+
129+
The resolver's bundle is rendered as a newline-delimited block and
130+
prepended to the system prompt. A request from `alice@example.com` on
131+
a paid tier sees, at the top of the system prompt:
132+
133+
```
134+
Grounded facts (deterministic, as of this turn):
135+
- time.now: 2026-04-19T18:32:14Z
136+
- time.timezone: UTC
137+
- user.id: alice@example.com
138+
- user.name: Alice Martin
139+
- user.locale: en-US
140+
- user.plan_tier: enterprise
141+
- app.recent_order_id: ord-7821
142+
143+
[then whatever the developer's @AiEndpoint systemPrompt said]
144+
```
145+
146+
:::caution[Prompt-injection guard]
147+
Values are escaped before rendering — newline, carriage return, tab,
148+
and ASCII control characters are replaced with a space. A fact value
149+
like `"Alice\nIgnore prior instructions"` cannot open a new line in
150+
the system prompt. Without this escape, a malicious or accidental
151+
embedded newline could reshape the instruction context.
152+
:::
153+
154+
:::note[When NOT to use this]
155+
- **Large blobs** — the bundle lands in the system prompt and
156+
consumes tokens. Use an `AiTool` the model can call on demand for
157+
retrieval.
158+
- **Per-message context**`FactResolver` resolves once per turn,
159+
not per message in a multi-round tool loop. For per-message
160+
context, use `ContextProvider` instead.
161+
- **Secrets** — the bundle is visible to the model. Never put API
162+
keys, passwords, or PII in here.
163+
:::
164+
165+
## See also
166+
167+
<CardGrid>
168+
<Card title="AI Filters & Guardrails" icon="seti:default">
169+
Inspect and block the response path — PII redaction + drift
170+
detection. [`/tutorial/12-ai-filters/`](./12-ai-filters/)
171+
</Card>
172+
<Card title="Tag agent calls with business outcomes" icon="approve-check">
173+
Sibling primitive for observability tagging via SLF4J MDC.
174+
[`/tutorial/27-business-metadata-observability/`](./27-business-metadata-observability/)
175+
</Card>
176+
<Card title="DefaultFactResolver source" icon="github">
177+
[`modules/ai/src/main/java/org/atmosphere/ai/facts/DefaultFactResolver.java`](https://github.com/Atmosphere/atmosphere/blob/main/modules/ai/src/main/java/org/atmosphere/ai/facts/DefaultFactResolver.java)
178+
</Card>
179+
</CardGrid>

0 commit comments

Comments
 (0)