Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
At 09:12 in this synthetic deploy-policy window, the dashboard for a large language model (LLM) service looks healthy: every endpoint response is 200, first-token latency stays below the 500 ms warning, and cost stays under the fixture budget. Two answers still contain unsupported deploy approvals. Status and latency show completion, not safety. A trace that joins claim-gate outcome to served route exposes the failure.
The last lesson built incident-answerer-v1: a claim gate that refused to invent a recovery time missing from the versioned incident record. Each request also wrote a trace with evidence version, route, and first failed stage. Keep that contract on the parallel deploy-answerer-v1 path, then watch a window. One trace answers what happened to one request; window metrics answer whether failure rate or latency shifted.
Observability joins those views. Monitoring compresses many events into rates, percentiles, and alerts. Request evidence lets you start from an alert and reconstruct why one call failed. You need both because a fast 200 can still be unsafe.

The gate already emits the fields you need
Start with serving decision, not dashboard. Prior lesson already recorded fields that let you ask both “what happened?” and “where did containment fail?”:
| Serving question | Event field | Why it matters |
|---|---|---|
| What answer route was chosen? | route | Shows serve, shorten, review, or abstain behavior |
| Did an unsupported claim reach the operator? | unsafe_claim_served | Direct safety invariant |
| Where did a request first go wrong? | first_failed_stage | Separates evidence, generation, and serving faults |
| Which protection boundary leaked a known failure? | escape_stage | Distinguishes root cause from failed containment |
| Which record and prompt were used? | evidence_version, prompt_template_id | Makes a failure reproducible |
| Was the request usable and affordable? | token and timing fields | Measures experience and cost beside quality |
These are synthetic events from parallel deploy-policy path, built on prior lab's trace contract. Four requests show strict gate containing an unsupported draft; two show a regressed template serving an invented deploy approval. They aren't claims about production traffic.
Before running cell, predict page signal: abstentions should remain safe, while any unsafe_claim_served=True row should be visible as an unsafe serve. Six rows make that distinction inspectable.
1from dataclasses import dataclass, replace
2
3@dataclass(frozen=True)
4class TraceEvent:
5 request_id: str
6 case_id: str
7 evidence_version: str | None
8 prompt_template_id: str
9 route: str
10 first_failed_stage: str
11 escape_stage: str | None
12 unsafe_claim_served: bool
13 input_tokens: int
14 cached_input_tokens: int
15 output_tokens: int
16 ttft_ms: int
17 total_ms: int
18
19current_window = [
20 TraceEvent("req_201", "clean_policy", "deploy-policy@v17", "deploy-answerer-v1",
21 "serve", "passed", None, False, 188, 80, 27, 172, 410),
22 TraceEvent("req_202", "invented_approval", "deploy-policy@v17", "deploy-answerer-v1",
23 "abstain", "claim_generation", None, False, 196, 80, 20, 181, 395),
24 TraceEvent("req_203", "wrong_freeze_status", "deploy-policy@v17", "deploy-answerer-v1",
25 "abstain", "claim_generation", None, False, 192, 80, 21, 188, 402),
26 TraceEvent("req_204", "missing_policy", None, "deploy-answerer-v1",
27 "abstain", "evidence_admission", None, False, 160, 64, 15, 205, 382),
28 TraceEvent("req_205", "invented_approval", "deploy-policy@v17", "deploy-answerer-v1.1-regression",
29 "serve", "claim_generation", "serving_gate", True, 198, 80, 29, 218, 430),
30 TraceEvent("req_206", "invented_approval", "deploy-policy@v17", "deploy-answerer-v1.1-regression",
31 "serve", "claim_generation", "serving_gate", True, 201, 80, 30, 240, 455),
32]
33
34print(f"events={len(current_window)}")
35for event in current_window:
36 verdict = "UNSAFE SERVE" if event.unsafe_claim_served else "safe route"
37 print(f"{event.request_id}: {event.route:7} {verdict}")1events=6
2req_201: serve safe route
3req_202: abstain safe route
4req_203: abstain safe route
5req_204: abstain safe route
6req_205: serve UNSAFE SERVE
7req_206: serve UNSAFE SERVEAn abstention isn't automatically a failure. When evidence doesn't establish a deploy approval, withholding is safe product behavior. Page-worthy event is an unsupported factual claim reaching an operator.
Rows show event-level evidence. Next, turn them into a window metric while keeping one violating request attached for diagnosis.

Keep signal boundaries clear. Traces answer which request and which step. Metrics answer how often and how badly across a window. Durable logs keep reduced, privacy-safe evidence after trace retention. One artifact can't replace all three.
Turn request evidence into quality metrics
Metric is useful only when its numerator matches invariant it claims to protect. A generic "answer quality score" could hide two unsafe deploy claims inside many pleasant responses. Start with counters derived directly from claim-gate outcomes:
| Metric | Numerator / denominator | Interpretation |
|---|---|---|
| Unsafe-serve rate | Requests with any unsupported served claim / requests | Operator-facing factual safety failure |
| Safe-route rate | Requests without unsafe served claims / requests | Gate effectiveness, including justified abstention |
| Abstention rate | Abstained requests / requests | Product usefulness signal, not a safety failure by itself |
| Failure-stage count | Requests by first failed stage | Where engineers should investigate first |
| Escape-stage count | Unsafe requests by leaked protection boundary | Where containment failed after the first defect |
Before running cell, predict summary: 2 of 6 requests should page as unsafe, 3 should abstain safely, and all 4 unsupported drafts should point to claim_generation. Cell makes those counts reproducible.
1from collections import Counter
2from dataclasses import dataclass
3
4@dataclass(frozen=True)
5class QualityWindow:
6 requests: int
7 unsafe_serves: int
8 abstentions: int
9 failure_stages: Counter
10 escape_stages: Counter
11
12 @property
13 def unsafe_serve_rate(self) -> float:
14 return self.unsafe_serves / self.requests
15
16 @property
17 def safe_route_rate(self) -> float:
18 return (self.requests - self.unsafe_serves) / self.requests
19
20def quality_window(events: list[TraceEvent]) -> QualityWindow:
21 return QualityWindow(
22 requests=len(events),
23 unsafe_serves=sum(event.unsafe_claim_served for event in events),
24 abstentions=sum(event.route == "abstain" for event in events),
25 failure_stages=Counter(event.first_failed_stage for event in events),
26 escape_stages=Counter(
27 event.escape_stage for event in events if event.escape_stage is not None
28 ),
29 )
30
31quality = quality_window(current_window)
32print(f"unsafe_serve_rate={quality.unsafe_serve_rate:.1%}")
33print(f"safe_route_rate={quality.safe_route_rate:.1%}")
34print(f"abstentions={quality.abstentions}")
35print(f"claim_generation_failures={quality.failure_stages['claim_generation']}")
36print(f"serving_gate_escapes={quality.escape_stages['serving_gate']}")
37
38assert quality.unsafe_serves == 2
39assert quality.failure_stages["claim_generation"] == 4
40assert quality.escape_stages["serving_gate"] == 21unsafe_serve_rate=33.3%
2safe_route_rate=66.7%
3abstentions=3
4claim_generation_failures=4
5serving_gate_escapes=2The counts do more than say "quality is down." All four unsupported drafts first failed at claim_generation. The strict template safely withheld two; the regressed template let two escape through serving_gate. That evidence points at a gate or template rollback before anyone tunes retrieval.
Read those stage fields as a small diagnosis tree. If first_failed_stage points at claim generation and escape_stage is empty, generation failed but gate contained it. If escape_stage is set, follow that boundary first, then return to root stage. One pair of fields separates mitigation from root-cause work.

An answer returns HTTP 200 in 500 ms but cites the wrong runbook. Which signal catches the failure?
Answer
A product-quality check tied to expected evidence or a reviewed outcome. Transport success and latency show that the request completed, not that the answer was correct.
Measure responsiveness without hiding correctness
Quality is first invariant here, but operators also notice sluggish answers. For a streamed response:
- Time to first token (TTFT) is the delay before the operator sees output.
- Post-first-token throughput measures how quickly the remaining output arrives.
- End-to-end latency includes the entire response.
Be precise with token arithmetic. If TTFT marks arrival of first token, only output_tokens - 1 tokens arrive in time after TTFT. Counting all output tokens in that interval slightly overstates throughput. For req_206, predict before running cell: 30 output tokens mean 29 arrive after first, over 215 ms.
The fixture also reports p95 TTFT: 95% of measured requests begin output at or below that first-token delay. With only six requests, nearest-rank p95 is the slowest observation. This teaches the calculation, not a production budget.
1import math
2
3def nearest_rank_percentile(values: list[int], percentile: float) -> int:
4 ordered = sorted(values)
5 rank = max(1, math.ceil(percentile * len(ordered)))
6 return ordered[rank - 1]
7
8def post_first_token_tps(event: TraceEvent) -> float:
9 remaining_tokens = max(event.output_tokens - 1, 0)
10 if remaining_tokens == 0:
11 return 0.0
12 remaining_ms = event.total_ms - event.ttft_ms
13 if remaining_ms <= 0:
14 raise ValueError("total_ms must exceed ttft_ms after the first token")
15 return remaining_tokens / (remaining_ms / 1000)
16
17p95_ttft_ms = nearest_rank_percentile([event.ttft_ms for event in current_window], 0.95)
18example_tps = post_first_token_tps(current_window[-1])
19malformed_timing = replace(current_window[-1], total_ms=240)
20
21print(f"p95_ttft_ms={p95_ttft_ms}")
22print(f"req_206_post_first_tps={example_tps:.1f}")
23print(f"quality_safe={quality.unsafe_serves == 0}")
24try:
25 post_first_token_tps(malformed_timing)
26except ValueError as error:
27 print(f"invalid_timing={error}")
28else:
29 raise AssertionError("malformed timing event should fail")
30
31assert p95_ttft_ms == 240
32assert quality.unsafe_serves > 01p95_ttft_ms=240
2req_206_post_first_tps=134.9
3quality_safe=False
4invalid_timing=total_ms must exceed ttft_ms after the first tokenThis window is fast and unsafe. A latency-only dashboard would celebrate release that needs rollback.
Treat malformed timing as instrumentation failure. If a multi-token response reports total_ms <= ttft_ms, repair timer before trusting throughput. A fallback denominator would turn bad telemetry into a believable number.
OpenTelemetry (OTel) GenAI conventions remain Development and now live in a dedicated repository rather than a frozen 1.41.x release. Pin convention your instrumentation emits.[1]
Last versioned snapshot and current docs use gen_ai.client.operation.time_to_first_chunk for client first-chunk latency and gen_ai.server.time_to_first_token for model-server first-token latency. Streaming spans can also record gen_ai.response.time_to_first_chunk in seconds.[2][3]
This lab's ttft_ms is an application-owned, operator-visible field. Write down where timer starts and stops before comparing it with a server metric.
One request is a tree of spans
A flat event row can page, but it can't show which nested step invented deploy approval. Retrieval, generation, and serving gate have different owners. Without one parent trace, debugging becomes timestamp correlation and guesswork.
OpenTelemetry encodes that causal shape with parent-child spans: one trace id for user request and child spans for nested work.[4] Cell rebuilds req_205 as a tiny in-process tracer, so structure is visible without an SDK. Inference spans follow {operation} {model}; retrieval spans follow {operation} {data_source.id}; serving gate keeps a product name because it isn't a GenAI operation.[3]
Predict tree before running cell: request is root; retrieval, chat, and serving_gate are siblings. Chat should carry first failure; serving gate should carry escape.
1from contextlib import contextmanager
2from dataclasses import dataclass
3
4@dataclass
5class Span:
6 name: str
7 parent: str | None
8 attributes: dict[str, str | int | bool]
9
10class Tracer:
11 def __init__(self) -> None:
12 self.spans: list[Span] = []
13 self._stack: list[str] = []
14
15 @contextmanager
16 def start(self, name: str, **attributes: str | int | bool):
17 parent = self._stack[-1] if self._stack else None
18 self.spans.append(Span(name, parent, dict(attributes)))
19 self._stack.append(name)
20 try:
21 yield
22 finally:
23 self._stack.pop()
24
25tracer = Tracer()
26unsafe = next(event for event in current_window if event.unsafe_claim_served)
27with tracer.start("deploy-answer.request", **{"platform.request.id": unsafe.request_id}):
28 with tracer.start(
29 "retrieval deploy-policy",
30 **{
31 "gen_ai.operation.name": "retrieval",
32 "platform.evidence.version": unsafe.evidence_version or "",
33 },
34 ):
35 pass
36 with tracer.start(
37 "chat deploy-assistant-prod",
38 **{
39 "gen_ai.operation.name": "chat",
40 "gen_ai.provider.name": "platform.internal",
41 "gen_ai.request.model": "deploy-assistant-prod",
42 "gen_ai.usage.input_tokens": unsafe.input_tokens,
43 "gen_ai.usage.output_tokens": unsafe.output_tokens,
44 "platform.failure.stage": unsafe.first_failed_stage,
45 },
46 ):
47 pass
48 with tracer.start(
49 "serving_gate",
50 **{
51 "platform.answer.route": unsafe.route,
52 "platform.answer.unsafe_claim_served": unsafe.unsafe_claim_served,
53 "platform.failure.escape_stage": unsafe.escape_stage or "",
54 },
55 ):
56 pass
57
58parent_of = {span.name: span.parent for span in tracer.spans}
59print(f"root={unsafe.request_id}")
60for span in tracer.spans:
61 print(f"{span.name} parent={parent_of[span.name]}")
62
63assert unsafe.request_id == "req_205"
64assert parent_of == {
65 "deploy-answer.request": None,
66 "retrieval deploy-policy": "deploy-answer.request",
67 "chat deploy-assistant-prod": "deploy-answer.request",
68 "serving_gate": "deploy-answer.request",
69}
70assert tracer.spans[-1].attributes["platform.answer.unsafe_claim_served"] is True1root=req_205
2deploy-answer.request parent=None
3retrieval deploy-policy parent=deploy-answer.request
4chat deploy-assistant-prod parent=deploy-answer.request
5serving_gate parent=deploy-answer.requestChat span owns first failed stage. Serving-gate span owns escape. Together they explain same unsafe row without putting operator message on trace.
Why should retrieval, generation, and the serving gate share one root request trace?
Answer
The parent-child tree preserves causality across the request path. An operator can connect a bad answer to the retrieval result, the model call, or the gate leak instead of correlating separate logs by guesswork.
Keep standard telemetry and product decisions together
Standard fields identify model operation. On inference span, gen_ai.operation.name and gen_ai.provider.name are required. gen_ai.request.model is required when model id is available. Synthetic service uses custom provider because no real vendor is involved. Use well-known provider value when one applies.[3]
Product fields answer different questions: which evidence version supported answer, whether route blocked claim, where first failure occurred, and which protection boundary leaked it. Keep those custom attributes beside standard telemetry.
Prompt, output-message, and system-instruction attributes are opt-in in OTel because they may contain sensitive content. Store stable identifiers and safe outcomes by default; raw text capture needs deliberate governance.[3]
Before running cell, predict what durable attribute map omits: raw input messages, output messages, and system instructions. Keep enough IDs and stages to reproduce decision path without copying operator text.
1def trace_attributes(event: TraceEvent) -> dict[str, str | int | bool]:
2 attributes: dict[str, str | int | bool] = {
3 "gen_ai.operation.name": "chat",
4 "gen_ai.provider.name": "platform.internal",
5 "gen_ai.request.model": "deploy-assistant-prod",
6 "gen_ai.usage.input_tokens": event.input_tokens,
7 "gen_ai.usage.cache_read.input_tokens": event.cached_input_tokens,
8 "gen_ai.usage.output_tokens": event.output_tokens,
9 "platform.answer.route": event.route,
10 "platform.answer.unsafe_claim_served": event.unsafe_claim_served,
11 "platform.failure.stage": event.first_failed_stage,
12 "platform.prompt.template_id": event.prompt_template_id,
13 }
14 if event.evidence_version is not None:
15 attributes["platform.evidence.version"] = event.evidence_version
16 if event.escape_stage is not None:
17 attributes["platform.failure.escape_stage"] = event.escape_stage
18 return attributes
19
20unsafe_attributes = trace_attributes(current_window[-1])
21print(f"operation={unsafe_attributes['gen_ai.operation.name']}")
22print(f"template={unsafe_attributes['platform.prompt.template_id']}")
23print(f"failed_stage={unsafe_attributes['platform.failure.stage']}")
24print(f"escape_stage={unsafe_attributes['platform.failure.escape_stage']}")
25print(f"cached_input_tokens={unsafe_attributes['gen_ai.usage.cache_read.input_tokens']}")
26print(f"raw_messages_logged={'gen_ai.input.messages' in unsafe_attributes}")
27
28assert unsafe_attributes["platform.answer.unsafe_claim_served"] is True
29assert unsafe_attributes["platform.failure.escape_stage"] == "serving_gate"
30assert unsafe_attributes["gen_ai.provider.name"] == "platform.internal"
31assert unsafe_attributes["gen_ai.usage.cache_read.input_tokens"] == 80
32assert "gen_ai.input.messages" not in unsafe_attributes
33assert "gen_ai.output.messages" not in unsafe_attributes
34assert "gen_ai.system_instructions" not in unsafe_attributes1operation=chat
2template=deploy-answerer-v1.1-regression
3failed_stage=claim_generation
4escape_stage=serving_gate
5cached_input_tokens=80
6raw_messages_logged=Falsegen_ai.* attributes let tracing backends recognize model call, including recommended cache-read counts when provider reports them. platform.* attributes explain business decision. Neither requires putting operator's full message into durable trace.
Attribute cost with a versioned rate card
Cost belongs in same window, but baking today's provider prices into application logic or tutorial makes both go stale. Store usage counts on trace, including cached input when provider reports it, then evaluate them with versioned rate card. Rates below are synthetic fixture values chosen to exercise calculation.
Predict req_206 before running cell: 201 total input minus 80 cached leaves 121 uncached; fixture rates should produce $0.000279. Second malformed event should be rejected, not clamped.
1@dataclass(frozen=True)
2class RateCard:
3 version: str
4 input_per_million: float
5 cached_input_per_million: float
6 output_per_million: float
7
8def estimate_cost(event: TraceEvent, card: RateCard) -> float:
9 if event.input_tokens < 0 or event.output_tokens < 0:
10 raise ValueError("token counts must be non-negative")
11 if not 0 <= event.cached_input_tokens <= event.input_tokens:
12 raise ValueError("cached_input_tokens must be between 0 and input_tokens")
13 uncached_input = event.input_tokens - event.cached_input_tokens
14 return (
15 uncached_input * card.input_per_million
16 + event.cached_input_tokens * card.cached_input_per_million
17 + event.output_tokens * card.output_per_million
18 ) / 1_000_000
19
20card = RateCard(
21 version="internal-fixture-2026-05-27",
22 input_per_million=1.00,
23 cached_input_per_million=0.10,
24 output_per_million=5.00,
25)
26cost_by_request = {
27 event.request_id: estimate_cost(event, card)
28 for event in current_window
29}
30total_cost = sum(cost_by_request.values())
31malformed_usage = replace(current_window[-1], cached_input_tokens=202)
32
33print(f"rate_card={card.version}")
34print(f"window_cost_usd={total_cost:.6f}")
35print(f"req_206_cost_usd={cost_by_request['req_206']:.6f}")
36try:
37 estimate_cost(malformed_usage, card)
38except ValueError as error:
39 print(f"invalid_usage={error}")
40else:
41 raise AssertionError("malformed usage event should fail")
42
43assert card.version.startswith("internal-fixture")
44assert total_cost > 01rate_card=internal-fixture-2026-05-27
2window_cost_usd=0.001427
3req_206_cost_usd=0.000279
4invalid_usage=cached_input_tokens must be between 0 and input_tokensVersioning separates observation from repricing. If provider changes cached-token pricing or adds service-tier surcharge, old traces keep usage while rate-card version explains estimate. Reject impossible counters rather than clamping them into plausible-looking cost.
Store durable debugging evidence without operator text
A trace may contain an incident identifier, email address, or full operator request. That context can help solve today's incident, but it shouldn't become default long-lived record.
For this workflow, durable logs can retain:
| Retain by default | Avoid by default |
|---|---|
| Request ID, evidence version, prompt template ID | Full operator message |
| Route, first failure stage, escape stage, claim-verdict counts | Full generated answer |
| Token counts, latency, rate-card version | Unredacted incident or contact details |
| Redacted preview when needed | Raw retrieved documents |
Record below keeps redacted preview for incident exemplar while leaving raw payload storage unset. Predict what survives: IDs, versions, route, stages, and safe preview. Full message and raw payload stay out.
1import re
2
3@dataclass(frozen=True)
4class DurableRecord:
5 request_id: str
6 prompt_template_id: str
7 evidence_version: str | None
8 route: str
9 failed_stage: str
10 escape_stage: str | None
11 redacted_preview: str
12 raw_payload_ref: str | None
13 scrub_policy: str
14
15def redact_operator_text(text: str) -> str:
16 text = re.sub(r"#[A-Z0-9]+", "[INCIDENT_ID]", text)
17 return re.sub(r"\b[\w.+-]+@[\w.-]+\.\w+\b", "[EMAIL]", text)
18
19def durable_record(event: TraceEvent, operator_text: str) -> DurableRecord:
20 return DurableRecord(
21 request_id=event.request_id,
22 prompt_template_id=event.prompt_template_id,
23 evidence_version=event.evidence_version,
24 route=event.route,
25 failed_stage=event.first_failed_stage,
26 escape_stage=event.escape_stage,
27 redacted_preview=redact_operator_text(operator_text),
28 raw_payload_ref=None,
29 scrub_policy="platform-pii-v1",
30 )
31
32record = durable_record(
33 current_window[-1],
34 "Email me deploy approval for incident #INC10234 at [email protected]",
35)
36print(record.redacted_preview)
37print(f"raw_payload_stored={record.raw_payload_ref is not None}")
38print(f"scrub_policy={record.scrub_policy}")
39print(f"escape_stage={record.escape_stage}")
40
41assert "#INC10234" not in record.redacted_preview
42assert "[email protected]" not in record.redacted_preview1Email me deploy approval for incident [INCIDENT_ID] at [EMAIL]
2raw_payload_stored=False
3scrub_policy=platform-pii-v1
4escape_stage=serving_gateRegex is intentionally narrow: it makes fixture readable, but it isn't a production scrubber. Before durable storage, minimize collected attributes, review what instrumentation libraries emit, and apply centrally managed Collector processors or equivalent scrub pipeline.[5]
That boundary matters operationally. A trace is short-lived investigation context; durable log is deliberately reduced evidence for later diagnosis. Copying everything into both raises privacy and storage risk without adding a new answer to incident question.

Alert on decisions engineers can act on
Not every metric deserves a page. A long answer or higher cost might warrant investigation. An unsupported deploy claim that escaped hard gate violates product invariant and should page immediately.
Thresholds belong to a service-level objective (SLO) or another owned service contract, not a generic article. This lab's synthetic policy says:
- Any unsafe served deploy claim is a page.
- TTFT above 500 ms is a warning for this interactive answer path.
- Estimated cost above the fixture budget is a warning, using the rate card named in the event window.
Predict findings before running cell: two unsafe serves should produce one page; p95 TTFT is 240 ms, below 500; cost is below $0.002. Result should contain one action, not three noisy warnings.
1@dataclass(frozen=True)
2class MonitorPolicy:
3 policy_id: str
4 max_unsafe_serves: int
5 max_p95_ttft_ms: int
6 max_window_cost_usd: float
7
8def evaluate_alerts(
9 events: list[TraceEvent],
10 quality: QualityWindow,
11 cost_usd: float,
12 policy: MonitorPolicy,
13) -> list[str]:
14 p95 = nearest_rank_percentile([event.ttft_ms for event in events], 0.95)
15 findings: list[str] = []
16 if quality.unsafe_serves > policy.max_unsafe_serves:
17 findings.append(
18 f"PAGE unsafe_claim_served={quality.unsafe_serves}/{quality.requests}"
19 )
20 if p95 > policy.max_p95_ttft_ms:
21 findings.append(f"WARN p95_ttft_ms={p95}")
22 if cost_usd > policy.max_window_cost_usd:
23 findings.append(f"WARN window_cost_usd={cost_usd:.6f}")
24 return findings
25
26policy = MonitorPolicy(
27 policy_id="deploy-grounding-slo-v1",
28 max_unsafe_serves=0,
29 max_p95_ttft_ms=500,
30 max_window_cost_usd=0.002,
31)
32findings = evaluate_alerts(current_window, quality, total_cost, policy)
33
34for finding in findings:
35 print(finding)
36print(f"latency_warning={any('ttft' in item for item in findings)}")
37print(f"cost_warning={any('cost' in item for item in findings)}")
38
39assert findings == ["PAGE unsafe_claim_served=2/6"]1PAGE unsafe_claim_served=2/6
2latency_warning=False
3cost_warning=FalsePage is actionable because it names broken invariant. It doesn't page merely because judge score drifted or latency was noisy.
Six synthetic requests demonstrate alert logic, not production latency or cost budget. Set warning thresholds from representative traffic and revisit them as workload shape changes. Zero-tolerance safety invariant is different: known unsupported deploy claim must not be served.
Attach an exemplar and a first investigation step
On-call engineer shouldn't begin by searching arbitrary logs. For invariant page, attach one violating trace with prompt template and source version, then state immediate check. Read evidence in order: invariant breach, first failed stage, escape stage, version IDs, first check. That sequence separates root defect from containment leak.
1@dataclass(frozen=True)
2class IncidentCard:
3 policy_id: str
4 summary: str
5 exemplar_request_id: str
6 template_id: str
7 evidence_version: str | None
8 first_failed_stage: str
9 escape_stage: str | None
10 first_check: str
11
12def incident_card(events: list[TraceEvent], policy: MonitorPolicy) -> IncidentCard:
13 exemplar = next(event for event in events if event.unsafe_claim_served)
14 return IncidentCard(
15 policy_id=policy.policy_id,
16 summary="Unsupported deploy claim reached operator response.",
17 exemplar_request_id=exemplar.request_id,
18 template_id=exemplar.prompt_template_id,
19 evidence_version=exemplar.evidence_version,
20 first_failed_stage=exemplar.first_failed_stage,
21 escape_stage=exemplar.escape_stage,
22 first_check="Compare serving-gate template with deploy-answerer-v1.",
23 )
24
25card_view = incident_card(current_window, policy)
26print(f"exemplar={card_view.exemplar_request_id}")
27print(f"template={card_view.template_id}")
28print(f"first_stage={card_view.first_failed_stage}")
29print(f"escape_stage={card_view.escape_stage}")
30print(f"first_check={card_view.first_check}")
31
32assert card_view.first_failed_stage == "claim_generation"
33assert card_view.escape_stage == "serving_gate"1exemplar=req_205
2template=deploy-answerer-v1.1-regression
3first_stage=claim_generation
4escape_stage=serving_gate
5first_check=Compare serving-gate template with deploy-answerer-v1.Request still needs deeper review, but investigation is narrow: generation produced unsupported deploy approval; serving gate let it escape. Compare template or gate configuration with strict version that abstained on same failure case.
An alert says only grounding_rate < 95%. What evidence makes it actionable?
Answer
Attach a representative trace or exemplar, affected version and slice, threshold history, and a first investigation step. Every alert should enable a concrete engineering decision and name the evidence behind it.
Extend the contract when answers call tools
Central example is factual answer gate. If product later lets agent open rollback tickets or trigger CI jobs, apply same discipline to tool actions. OTel names those spans execute_tool {gen_ai.tool.name}.[3]
Tool arguments and results are opt-in because they can carry secrets. This lab logs redacted parameter hash instead of raw payload. Keep state you control: tool name, hash, result status, progress marker, retry count, and stop reason. Don't depend on hidden reasoning text.
Predict loop decision before running cell: same tool and hash aren't enough to prove a stall; repeated progress marker is missing evidence of movement.

1@dataclass(frozen=True)
2class ToolStep:
3 tool_name: str
4 params_hash: str
5 progress_marker: str
6
7def stalled_repeat(steps: list[ToolStep]) -> bool:
8 if len(steps) < 2:
9 return False
10 last, previous = steps[-1], steps[-2]
11 return (
12 last.tool_name == previous.tool_name
13 and last.params_hash == previous.params_hash
14 and last.progress_marker == previous.progress_marker
15 )
16
17steps = [
18 ToolStep("lookup_deploy_status", "deploy-redacted:v1", "scan:v17"),
19 ToolStep("lookup_deploy_status", "deploy-redacted:v1", "scan:v17"),
20]
21
22print(f"tool={steps[-1].tool_name}")
23print(f"stalled_repeat={stalled_repeat(steps)}")
24print("route=stop_and_review" if stalled_repeat(steps) else "route=continue")
25
26assert stalled_repeat(steps) is True1tool=lookup_deploy_status
2stalled_repeat=True
3route=stop_and_reviewPreserve the evidence for the next fix
An alert tells you something is wrong now. The next engineering question is whether a proposed fix is better. That requires an experiment record linking the failing window, proposed template, policy, and rerun results.
1@dataclass(frozen=True)
2class ExperimentHandoff:
3 incident_policy_id: str
4 failing_template_id: str
5 exemplar_request_id: str
6 evidence_version: str | None
7 candidate_template_id: str
8 required_metric: str
9 promotion_status: str
10
11handoff = ExperimentHandoff(
12 incident_policy_id=card_view.policy_id,
13 failing_template_id=card_view.template_id,
14 exemplar_request_id=card_view.exemplar_request_id,
15 evidence_version=card_view.evidence_version,
16 candidate_template_id="deploy-answerer-v1.2-fix",
17 required_metric="unsafe_claim_served == 0 on regression and holdout windows",
18 promotion_status="BLOCKED_PENDING_EVALUATION",
19)
20
21print(f"candidate={handoff.candidate_template_id}")
22print(f"required_metric={handoff.required_metric}")
23print(f"promotion={handoff.promotion_status}")
24
25assert handoff.promotion_status == "BLOCKED_PENDING_EVALUATION"1candidate=deploy-answerer-v1.2-fix
2required_metric=unsafe_claim_served == 0 on regression and holdout windows
3promotion=BLOCKED_PENDING_EVALUATIONMonitoring doesn't approve proposed fix. It creates precise starting point for next controlled run: what broke, which candidate claims to fix it, and which metric must pass.
Keep handoff small: incident policy, failing template, exemplar request, evidence version, candidate, and required safety metric. Experiment tracking can consume those identifiers without copying raw traces or inventing new failure label.
Sample judges. Keep the hard gate unsampled
Paging path assumes claim-gate events already exist on every request. Other production surfaces have hard gates on only a subset of traffic, or none at all. Choose sampling by signal class:
| Signal class | Sampling habit | Why |
|---|---|---|
| Hard safety invariants (unsafe serve, policy violation) | Always-on counters and exemplars | One miss must page; keep the page path unsampled |
| Expensive judges / LLM-as-judge quality scores | Head sample a fixed rate (for example 1 to 10%), or tail-sample errors and high latency first | Cost grows with volume; pin the judge version like experiment tracking |
| High-cardinality attributes (raw prompts, full tool payloads) | Default off; sample only under an investigation flag | Protects privacy and trace storage |
| Agent tool loops | Always log controlled state deltas (tool name, param hash, progress); sample full nested spans at a lower rate if needed | Loop detection needs the state markers more than every attribute |
Treat continuous online evaluation as a separate, versioned scorer on sampled set, not a substitute for hard gate. Always-on invariant still pages; sample feeds trends and holdout comparison for next experiment.