Turn claim-level answer traces into production metrics, actionable alerts, privacy-safe debugging records, and reproducible incident evidence.
deploy-answerer-v1, a large language model (LLM) answer path, refused to promise a deploy approval that wasn't present in a ReleaseOps record. It also emitted a trace: evidence version, route, and first failed stage. One safe trace proves one request behaved correctly. It doesn't tell you whether the service is still safe across live traffic.
Suppose a prompt rollback silently bypasses that gate for two operators. The endpoint still returns 200. Answers remain fluent. Latency stays fast. Only the trace events reveal that unsupported deploy approvals escaped into served messages.
Monitoring reduces many trace events into rates, percentiles, and alerts. Observability lets you start from an alert and reconstruct why a particular request failed. An LLM application needs both: aggregates to detect a change and request evidence to diagnose it.
Avoid beginning with a dashboard wish list. Begin with the decision boundary from the serving path:
| 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 |
The examples use synthetic events from the same deploy-policy answer path as the prior lab. The last two events model a regression; they aren't claims about real production traffic.
The first cell creates six requests. Four are safe outcomes from a strict gate: a supported answer or a blocked factual claim. Two come from a regressed template that served an invented deploy approval.
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 SERVENotice the distinction: an abstention isn't automatically a failure. When evidence doesn't establish a deploy approval, abstention is the safe product behavior. The event you need to page on is an unsupported factual claim that was served.
An aggregate must preserve the invariant it claims to monitor. A generic "answer quality score" could hide two unsafe deploy claims inside many pleasant responses. For this answer path, start with metrics directly derived 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 |
The next cell aggregates the synthetic production window.
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.
Quality is the first invariant here, but operators also notice sluggish answers. For a streamed response:
Be precise with token arithmetic. If TTFT marks arrival of the first token, only output_tokens - 1 tokens arrive in the time after TTFT. Counting all output tokens in that interval slightly overstates throughput.
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 the exact release that should be rolled back.
Don't hide malformed timing events behind a tiny fallback denominator. If a multi-token response reports total_ms <= ttft_ms, fix the instrumentation before trusting its throughput.
OpenTelemetry (OTel) semantic conventions 1.41.0 distinguish client first-chunk latency (gen_ai.client.operation.time_to_first_chunk) from model-server first-token latency (gen_ai.server.time_to_first_token). This lab's ttft_ms is an application-owned operator-visible field, so document exactly where your timer starts and stops. The GenAI convention is still marked Development; pin the convention emitted by your instrumentation instead of treating its field set as permanent.[1][2]
A simple request-response model is a flat timeline. But a tool-using agent, RAG pipeline, or multi-step assistant behaves like a tree: a root user operation spawns retrieval queries, multiple LLM thinking loops, and external tool execution spans.
To reconstruct what happened, OpenTelemetry uses parent-child span relationships. The root span represents the user-facing request, while child spans capture nested operations such as retrieval, model generation, and tool execution.[3]
Here is how you initialize and link these spans programmatically using the OpenTelemetry API:
1import time
2from opentelemetry import trace
3from opentelemetry.sdk.trace import TracerProvider
4from opentelemetry.sdk.trace.export import SimpleSpanProcessor
5from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
6
7provider = TracerProvider()
8exporter = InMemorySpanExporter()
9provider.add_span_processor(SimpleSpanProcessor(exporter))
10trace.set_tracer_provider(provider)
11tracer = trace.get_tracer("agent-service")
12
13def execute_agent_loop(query: str) -> None:
14 with tracer.start_as_current_span("agent.request") as root_span:
15 root_span.set_attribute("app.query", query)
16
17 with tracer.start_as_current_span("retrieval") as retrieval_span:
18 retrieval_span.set_attribute("db.system", "chroma")
19 time.sleep(0.01)
20 docs_retrieved = 3
21 retrieval_span.set_attribute("retrieval.docs_count", docs_retrieved)
22
23 with tracer.start_as_current_span("gen_ai.call") as llm_span:
24 llm_span.set_attribute("gen_ai.operation.name", "chat")
25 llm_span.set_attribute("gen_ai.provider.name", "platform.internal")
26 llm_span.set_attribute("gen_ai.request.model", "deploy-assistant-prod")
27 llm_span.set_attribute("gen_ai.usage.input_tokens", 150)
28 llm_span.set_attribute("gen_ai.usage.output_tokens", 45)
29 time.sleep(0.02)
30
31execute_agent_loop("Check deploy safety for retry change")
32
33spans = exporter.get_finished_spans()
34span_names = {span.context.span_id: span.name for span in spans}
35parent_names = {
36 span.name: span_names.get(span.parent.span_id) if span.parent else None
37 for span in spans
38}
39print(f"Captured spans: {len(spans)}")
40for span in sorted(spans, key=lambda s: s.start_time):
41 print(f"Span: {span.name} | Parent: {parent_names[span.name]}")
42
43assert len(spans) == 3
44assert parent_names == {
45 "agent.request": None,
46 "retrieval": "agent.request",
47 "gen_ai.call": "agent.request",
48}1Captured spans: 3
2Span: agent.request | Parent: None
3Span: retrieval | Parent: agent.request
4Span: gen_ai.call | Parent: agent.requestIn semantic conventions 1.41.0, the inference-span attributes require both gen_ai.operation.name and gen_ai.provider.name; gen_ai.request.model is conditionally required when available. The synthetic service below uses a custom provider name because no real provider is involved. Use the convention's well-known provider value when one applies. Your application still needs custom attributes for its own invariant: which evidence version supported the answer, whether the answer route blocked a claim, where the first failure occurred, and which protection boundary leaked it.[4]
Prompt, output-message, and system-instruction attributes are opt-in in the OTel convention because they may contain sensitive content. Store stable identifiers and safe outcome attributes by default; make raw text capture a deliberate, governed exception.[4]
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.output_tokens": event.output_tokens,
8 "platform.answer.route": event.route,
9 "platform.answer.unsafe_claim_served": event.unsafe_claim_served,
10 "platform.failure.stage": event.first_failed_stage,
11 "platform.prompt.template_id": event.prompt_template_id,
12 }
13 if event.evidence_version is not None:
14 attributes["platform.evidence.version"] = event.evidence_version
15 if event.escape_stage is not None:
16 attributes["platform.failure.escape_stage"] = event.escape_stage
17 return attributes
18
19unsafe_attributes = trace_attributes(current_window[-1])
20print(f"operation={unsafe_attributes['gen_ai.operation.name']}")
21print(f"template={unsafe_attributes['platform.prompt.template_id']}")
22print(f"failed_stage={unsafe_attributes['platform.failure.stage']}")
23print(f"escape_stage={unsafe_attributes['platform.failure.escape_stage']}")
24print(f"raw_messages_logged={'gen_ai.input.messages' in unsafe_attributes}")
25
26assert unsafe_attributes["platform.answer.unsafe_claim_served"] is True
27assert unsafe_attributes["platform.failure.escape_stage"] == "serving_gate"
28assert unsafe_attributes["gen_ai.provider.name"] == "platform.internal"
29assert "gen_ai.input.messages" not in unsafe_attributes
30assert "gen_ai.output.messages" not in unsafe_attributes
31assert "gen_ai.system_instructions" not in unsafe_attributes1operation=chat
2template=deploy-answerer-v1.1-regression
3failed_stage=claim_generation
4escape_stage=serving_gate
5raw_messages_logged=Falsegen_ai.* attributes let tracing backends recognize the model call. platform.* attributes explain the business decision. Neither requires putting an operator's full message into a durable trace.
Cost is part of monitoring, but embedding current provider price claims in application logic or a tutorial makes both go stale quickly. Production systems should store usage counts on the trace and evaluate them with a versioned rate card. The rates below are synthetic fixture values, chosen only to exercise the calculation.
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_tokensIf a provider later changes cached-token pricing or adds a service-tier surcharge, old traces remain interpretable: usage was recorded once, while the rate-card version states how the estimate was computed. Reject impossible counters rather than clamping them into a plausible-looking cost.
A trace may contain an incident identifier, email address, or full operator request. That text can help an incident investigation, but it shouldn't be your 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 |
The record below keeps a redacted preview for an incident exemplar while leaving raw payload storage unset.
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_gateThe regex is intentionally narrow: it makes the fixture readable, but it isn't a production scrubber. In production, minimize collected attributes, review what instrumentation libraries emit, and apply centrally managed Collector processors or an equivalent scrub pipeline before durable storage.[5]
Not every metric deserves a page. A long answer or higher cost might warrant investigation. An unsupported deploy claim that escaped a hard gate violates the product invariant and should page immediately.
Thresholds must come from a service-level objective (SLO) or another owned service contract, not a generic article. This lab's synthetic policy says:
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 findings: list[str] = []
15 if quality.unsafe_serves > policy.max_unsafe_serves:
16 findings.append(
17 f"PAGE unsafe_claim_served={quality.unsafe_serves}/{quality.requests}"
18 )
19 if p95_ttft_ms > policy.max_p95_ttft_ms:
20 findings.append(f"WARN p95_ttft_ms={p95_ttft_ms}")
21 if cost_usd > policy.max_window_cost_usd:
22 findings.append(f"WARN window_cost_usd={cost_usd:.6f}")
23 return findings
24
25policy = MonitorPolicy(
26 policy_id="deploy-grounding-slo-v1",
27 max_unsafe_serves=0,
28 max_p95_ttft_ms=500,
29 max_window_cost_usd=0.002,
30)
31findings = evaluate_alerts(current_window, quality, total_cost, policy)
32
33for finding in findings:
34 print(finding)
35print(f"latency_warning={any('ttft' in item for item in findings)}")
36print(f"cost_warning={any('cost' in item for item in findings)}")
37
38assert findings == ["PAGE unsafe_claim_served=2/6"]1PAGE unsafe_claim_served=2/6
2latency_warning=False
3cost_warning=FalseThe page is actionable because it says which invariant broke. It doesn't page merely because a judge score drifted or because latency was a little noisy.
Six synthetic requests are enough to demonstrate alert logic, not to set a production latency or cost budget. In a real launch, define those warning thresholds from representative traffic and revisit them as workload shape changes. The zero-tolerance safety invariant is different: a known unsupported deploy claim must not be served.
An on-call engineer shouldn't begin by searching arbitrary logs. For an invariant page, attach one violating trace with its prompt template and source version, then state the immediate check.
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.The request still needs deeper review, but investigation is now narrow: generation produced an unsupported deploy approval, and the serving gate let it escape. Compare the template or gate configuration with the strict version that abstained on the same failure case.
The central example is a factual answer gate. If the product later allows an agent to create open rollback tickets or trigger CI jobs, the same discipline applies to tool actions. Log state transitions you control: tool name, redacted parameter hash, result status, progress marker, retry count, and stop reason. Don't depend on hidden reasoning text.
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_reviewAn 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_EVALUATIONThe monitoring system doesn't approve the proposed fix. It creates a precise starting point for the next controlled run: what broke, which candidate claims to fix it, and which metric must pass.
| Capability | Working proof |
|---|---|
| Separate safety from responsiveness | A six-event window pages on unsupported serves despite healthy p95 TTFT |
| Diagnose root cause and failed containment | Traces record claim_generation separately from the serving_gate escape |
| Keep durable telemetry useful and private | The incident record retains redacted evidence, stable identifiers, and no raw payload |
| Hand incident evidence to experiment tracking | The monitor creates a blocked candidate handoff with a required safety metric |
first_failed_stage and escape_stage separately, then attach both to the exemplar.Answer every question, then check your score. Score above 75% to mark this lesson complete.
10 questions remaining.
Semantic conventions for generative AI systems
OpenTelemetry Authors · 2026
OpenTelemetry GenAI metrics, v1.41.0
OpenTelemetry Authors · 2026
OpenTelemetry Specification.
OpenTelemetry Authors. · 2023
OpenTelemetry GenAI span definitions, v1.41.0
OpenTelemetry Authors · 2026
Handling sensitive data
OpenTelemetry Authors · 2026
Questions and insights from fellow learners.