LeetLLM
My PlanLearnGlossaryTracksPracticeBlog
LeetLLM

Your go-to resource for mastering AI & LLM systems.

Product

  • Learn
  • Glossary
  • Tracks
  • Practice
  • Blog
  • RSS

Legal

  • Terms of Service
  • Privacy Policy

© 2026 LeetLLM. All rights reserved.

All Topics
Your Progress
0%

0 of 196 articles completed

🛠️Computing Foundations0/9
Git, Shell, Linux for AIDocker for Reproducible AIPython for AI EngineeringNumPy and Tensor ShapesCUDA for ML TrainingMPS & Metal for ML on MacData Structures for AISQL and Data ModelingAlgorithms for ML Engineers
📊Math & Statistics0/9
Gradients and BackpropVectors, Matrices & TensorsLinear Algebra for MLAdam, Momentum, SchedulersProbability for Machine LearningBayesian Inference from EvidenceStatistics and UncertaintyDistributions and SamplingHypothesis Tests, Intervals, and pass@k
📚Preparation & Prerequisites0/13
Neural Networks from ScratchCNNs from ScratchTraining & BackpropagationSoftmax, Cross-Entropy & OptimizationRNNs, LSTMs, GRUs, and Sequence ModelingAutoencoders and VAEsThe Transformer Architecture End-to-EndLanguage Modeling & Next TokensFrom GPT to Modern LLMsPrompt Engineering FundamentalsCalling LLM APIs in ProductionFirst AI App End-to-EndThe LLM Lifecycle
🧮ML Algorithms & Evaluation0/15
Linear Regression from ScratchLogistic Regression and MetricsDecision Trees, Forests, and BoostingReinforcement Learning BasicsValidation and LeakageStatistical Learning and GeneralizationConformal Prediction and UncertaintyClustering and PCAProbabilistic Generative ModelsCore Retrieval AlgorithmsDecoding AlgorithmsExperiment Design and A/B TestingCausal Inference and CounterfactualsPyTorch Training LoopsDataset Pipelines and Data Quality
📦Production ML Systems0/6
Feature Engineering for Production MLBatch and Streaming Feature PipelinesGradient Boosted Trees in ProductionRanking and Recommendation SystemsForecasting and Anomaly DetectionMonitoring Predictive Models
🧪Core LLM Foundations0/8
The Bitter Lesson & ComputeBPE, WordPiece, and SentencePieceStatic to Contextual EmbeddingsPerplexity & Model EvaluationFile Ingestion for AIChunking StrategiesLLM Benchmarks & LimitationsInstruction Tuning & Chat Templates
🧰Applied LLM Engineering0/25
Dimensionality Reduction for EmbeddingsCoT, ToT & Self-Consistency PromptingFunction Calling & Tool UseMCP & Tool Protocol StandardsContext EngineeringPrompt Injection DefenseResponsible AI GovernancePrivacy-Preserving Machine LearningData Labeling and Human FeedbackEvaluating AI AgentsProduction RAG PipelinesHybrid Search: Dense + SparseReranking and Cross-Encoders for RAGRAG Evaluation for Reliable AnswersLLM-as-a-Judge EvaluationBias & Fairness in LLMsHallucination Detection & MitigationLLM Observability & MonitoringExperiment Tracking with MLflow and W&BPrompt Optimization with DSPyModel Versioning & DeploymentSemantic Caching & Cost OptimizationLLM Cost Engineering & Token EconomicsModel Gateways, Routing, and FallbacksDesign an Automated Support Agent
🎓Portfolio Capstones0/9
Capstone: Delivery ETA PredictionCapstone: Product RankingCapstone: Demand ForecastingCapstone: Image Damage ClassifierCapstone: Production ML PipelineCapstone: Document QACapstone: Eval DashboardCapstone: Fine-Tuned ClassifierCapstone: Reproducible ML Study
🧠Transformer Deep Dives0/8
Sentence Embeddings & Contrastive LossEmbedding Similarity & QuantizationScaled Dot-Product AttentionVision Transformers and Image EncodersPositional Encoding: RoPE & ALiBiLayer Normalization: Pre-LN vs Post-LNMechanistic InterpretabilityDecoding Strategies: Greedy to Nucleus
🧬Advanced Training & Adaptation0/17
Scaling Laws & Compute-Optimal TrainingPre-training Data at ScaleBuild GPT from Scratch LabJAX for PyTorch ResearchersContinued Pretraining for Domain ShiftSynthetic Data PipelinesSupervised Fine-Tuning PipelineMixed Precision TrainingDistributed Training: FSDP & ZeROLoRA & Parameter-Efficient TuningTraining Run OperationsReward Modeling from Preference DataRLHF & DPO AlignmentConstitutional AI & Red TeamingRLVR & Verifiable RewardsKnowledge Distillation for LLMsModel Merging and Weight Interpolation
🤖Advanced Agents & Retrieval0/16
Vector DB Internals: HNSW & IVFAdvanced RAG: HyDE & Self-RAGGraphRAG & Knowledge GraphsRAG Security & Access ControlStructured Output GenerationReAct & Plan-and-ExecuteGuardrails & Safety FiltersCode Generation & SandboxingComputer-Use / GUI / Browser AgentsHuman-in-the-Loop Agent ArchitectureAI Coding Workflow with AgentsAgent Memory & PersistenceAgent Failure & RecoveryRecursive Language Models (RLM)Multi-Agent OrchestrationCapstone: Production Agent
⚡Inference & Production Scale0/31
Inference: TTFT, TPS & KV CacheMulti-Query & Grouped-Query AttentionKV Cache & PagedAttentionPrefix Caching and Prompt CachingFlashAttention & Memory EfficiencyContinuous Batching & SchedulingScaling LLM InferenceModel Parallelism for LLM InferenceAccelerator Architecture Field GuideCorrect GPU Profiling and BenchmarkingGPU Kernel Performance Engineering LabGPU Parallel Primitives LabGPU Compilers and Kernel LanguagesGEMM From Naive CUDA to Tensor CoresLow-Precision GPU ProgrammingGPU Runtime Execution LabAI-Generated Kernel EngineeringModel Quantization: GPTQ, AWQ & GGUFLocal LLM DeploymentSLM Specialization & Edge DeploymentSpeculative DecodingLong Context Window ManagementMixture of Experts ArchitectureMamba & State Space ModelsReasoning & Test-Time ComputeAdvanced MLOps & DevOps for AIGPU Serving & AutoscalingGPU Failure OperationsDistributed Inference Data PlaneServing Benchmark EngineeringA/B Testing for LLMs
🏗️System Design Capstones0/9
Content Moderation SystemCode Completion SystemMulti-Tenant LLM PlatformLLM-Powered Search EngineVision-Language Models & CLIPMultimodal LLM ArchitectureDiffusion Models: Images & TextReal-Time Voice AI AgentReasoning Agent System Design
🎤AI Lab Interviewing0/4
AI Lab Coding Interview: Python SystemsAI Lab System Design InterviewAI Lab Behavioral InterviewAI Lab Technical Presentation
🔬Project Deep Dives0/17
Deep Dive - vLLMDeep Dive - SkyRLDeep Dive - FlashAttentionDeep Dive - FlashInferDeep Dive - DeepGEMMDeep Dive - NCCLDeep Dive - MegatronDeep Dive - DeepSpeedDeep Dive - RayDeep Dive - MLflowDeep Dive - PyTorchDeep Dive - TransformersDeep Dive - SGLangDeep Dive - slimeDeep Dive - DeepEPDeep Dive - TinkerDeep Dive - Light-PEFT
Back to Topics
LearnApplied LLM EngineeringLLM Observability & Monitoring
⚙️MediumMLOps & Deployment

LLM Observability & Monitoring

Turn claim-level answer traces into production metrics, actionable alerts, privacy-safe debugging records, and reproducible incident evidence.

20 min read
Learning path
Step 78 of 196 in the full curriculum
Hallucination Detection & MitigationExperiment Tracking with MLflow and W&B

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.

TTFT bar chart for six deploy-answer requests on a 0 to 500 millisecond axis. Requests 201 through 204 stay safe at 172, 181, 188, and 205 ms. Requests 205 and 206 are unsafe serves at 218 and 240 ms. Every bar sits under the 500 ms warning, which is the chart maximum.
Every first-token delay sits under the 500 ms warning. Color, not height, marks the two unsafe serves. A latency dashboard would miss the rollback.

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 questionEvent fieldWhy it matters
What answer route was chosen?routeShows serve, shorten, review, or abstain behavior
Did an unsupported claim reach the operator?unsafe_claim_servedDirect safety invariant
Where did a request first go wrong?first_failed_stageSeparates evidence, generation, and serving faults
Which protection boundary leaked a known failure?escape_stageDistinguishes root cause from failed containment
Which record and prompt were used?evidence_version, prompt_template_idMakes a failure reproducible
Was the request usable and affordable?token and timing fieldsMeasures 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.

trace-events.py
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}")
Output
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 SERVE

An 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.

Diagram showing One request, Retrieve policy, Generate claims, and Serving gate.
One request, Retrieve policy, Generate claims, and Serving gate.

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:

MetricNumerator / denominatorInterpretation
Unsafe-serve rateRequests with any unsupported served claim / requestsOperator-facing factual safety failure
Safe-route rateRequests without unsafe served claims / requestsGate effectiveness, including justified abstention
Abstention rateAbstained requests / requestsProduct usefulness signal, not a safety failure by itself
Failure-stage countRequests by first failed stageWhere engineers should investigate first
Escape-stage countUnsafe requests by leaked protection boundaryWhere 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.

quality-window.py
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"] == 2
Output
1unsafe_serve_rate=33.3% 2safe_route_rate=66.7% 3abstentions=3 4claim_generation_failures=4 5serving_gate_escapes=2

The 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.

Split of four claim-generation defects in the six-request window. Left: req_202 and req_203 stay contained under deploy-answerer-v1, route abstain, no escape stage. Right: req_205 and req_206 escape through serving_gate on v1.1-regression, route serve, and page.
Four unsupported drafts share the same first failed stage. The serving gate is what turns two of them into a page and leaves the other two as safe abstentions.

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.

streaming-latency.py
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 > 0
Output
1p95_ttft_ms=240 2req_206_post_first_tps=134.9 3quality_safe=False 4invalid_timing=total_ms must exceed ttft_ms after the first token

This 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]Reference 1OpenTelemetry GenAI Semantic Conventionshttps://github.com/open-telemetry/semantic-conventions-genai

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]Reference 2OpenTelemetry GenAI metrics, v1.41.0https://github.com/open-telemetry/semantic-conventions/blob/v1.41.0/docs/gen-ai/gen-ai-metrics.md[3]Reference 3OpenTelemetry GenAI span definitions, v1.41.0https://github.com/open-telemetry/semantic-conventions/blob/v1.41.0/model/gen-ai/spans.yaml

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]Reference 4OpenTelemetry Specification.https://opentelemetry.io/docs/specs/otel 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]Reference 3OpenTelemetry GenAI span definitions, v1.41.0https://github.com/open-telemetry/semantic-conventions/blob/v1.41.0/model/gen-ai/spans.yaml

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.

request-span-tree.py
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 True
Output
1root=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.request

Chat 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]Reference 3OpenTelemetry GenAI span definitions, v1.41.0https://github.com/open-telemetry/semantic-conventions/blob/v1.41.0/model/gen-ai/spans.yaml

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]Reference 3OpenTelemetry GenAI span definitions, v1.41.0https://github.com/open-telemetry/semantic-conventions/blob/v1.41.0/model/gen-ai/spans.yaml

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.

trace-attributes.py
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_attributes
Output
1operation=chat 2template=deploy-answerer-v1.1-regression 3failed_stage=claim_generation 4escape_stage=serving_gate 5cached_input_tokens=80 6raw_messages_logged=False

gen_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.

versioned-cost.py
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 > 0
Output
1rate_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_tokens

Versioning 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 defaultAvoid by default
Request ID, evidence version, prompt template IDFull operator message
Route, first failure stage, escape stage, claim-verdict countsFull generated answer
Token counts, latency, rate-card versionUnredacted incident or contact details
Redacted preview when neededRaw 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.

durable-log-record.py
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_preview
Output
1Email me deploy approval for incident [INCIDENT_ID] at [EMAIL] 2raw_payload_stored=False 3scrub_policy=platform-pii-v1 4escape_stage=serving_gate

Regex 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]Reference 5Handling sensitive datahttps://opentelemetry.io/docs/security/handling-sensitive-data/

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.

Three-step privacy path for req_206. Raw operator text includes incident INC10234 and bruno@example.com and is not stored. platform-pii-v1 replaces those values with INCIDENT_ID and EMAIL markers. The durable record keeps template, evidence version, route, claim-generation failure, and serving-gate escape, with raw_payload_ref set to None.
The durable req_206 record keeps template, evidence, route, and both failure stages. The incident id, email, and raw payload never become the stored record.

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.

actionable-alerts.py
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"]
Output
1PAGE unsafe_claim_served=2/6 2latency_warning=False 3cost_warning=False

Page 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.

runbook-card.py
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"
Output
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]Reference 3OpenTelemetry GenAI span definitions, v1.41.0https://github.com/open-telemetry/semantic-conventions/blob/v1.41.0/model/gen-ai/spans.yaml

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.

Two consecutive lookup_deploy_status steps compared on tool name, redacted parameter hash deploy-redacted:v1, and progress marker. When the marker stays scan:v17, stalled_repeat is true and the route is stop_and_review. When the marker becomes scan:v18, the same tool and hash continue.
The same tool and parameter hash are not a loop by themselves. The progress marker has to stay frozen before the monitor stops the agent for review.
tool-loop-monitor.py
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 True
Output
1tool=lookup_deploy_status 2stalled_repeat=True 3route=stop_and_review

Preserve 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.

experiment-handoff.py
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"
Output
1candidate=deploy-answerer-v1.2-fix 2required_metric=unsafe_claim_served == 0 on regression and holdout windows 3promotion=BLOCKED_PENDING_EVALUATION

Monitoring 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 classSampling habitWhy
Hard safety invariants (unsafe serve, policy violation)Always-on counters and exemplarsOne miss must page; keep the page path unsampled
Expensive judges / LLM-as-judge quality scoresHead sample a fixed rate (for example 1 to 10%), or tail-sample errors and high latency firstCost 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 flagProtects privacy and trace storage
Agent tool loopsAlways log controlled state deltas (tool name, param hash, progress); sample full nested spans at a lower rate if neededLoop 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.

Complete the lesson

Mastery Check

Answer every question, then check your score. Score 75% or higher to mark this lesson complete.

1.In a six-request deploy-answer window, three requests abstained because the gate could not support the draft factual claim, and none of those abstentions served an unsupported claim. Why is the 50% abstention rate not automatically an incident?

Correct answer: Because safe withholding measures usefulness, while serving a known unsupported claim violates the safety invariant.

Abstention can reveal poor usefulness or weak evidence coverage, but it contains an unsupported claim. Safety fails when that claim reaches an operator, so abstention and unsafe-serve rates need separate decisions.

2.A deploy-answer window has two unsupported deploy approvals served to operators. Its p95 TTFT is 240 ms, below the 500 ms warning threshold, and the endpoint returns 200. Which condition should page?

Correct answer: The unsafe-serve invariant, because a known unsupported deploy claim reached an operator response.

Transport success and low latency don't establish factual safety. Any known unsupported deploy approval reaching an operator breaches the zero-tolerance invariant; 240 ms only shows latency stayed healthy.

3.A trace shows first_failed_stage='claim_generation' and escape_stage='serving_gate' for an unsafe served deploy approval. What distinction do these fields preserve?

Correct answer: claim_generation locates the first defect; serving_gate locates the later containment failure.

Root cause and containment are different questions. The first failed stage points to where the bad claim appeared, while the escape stage identifies the boundary that should have stopped it before serving.

4.An incident exemplar needs enough context to explain a claim-gate decision in a standard tracing backend, but durable storage should minimize operator data. Which design meets those constraints?

Correct answer: Keep model and usage fields, route and stages, version IDs, and a redacted preview.

Standard model fields identify the inference operation; product fields explain the gate decision and make it reproducible. A redacted preview adds context without retaining full prompts, answers, or retrieved records by default.

5.Event A has 30 output tokens, TTFT of 240 ms, and total latency of 455 ms. Event B is a multi-token response with TTFT and total latency both equal to 240 ms. How should post-first-token throughput be handled?

Correct answer: Compute 29 / 0.215 = 134.9 tokens/s for A, and reject B because its post-TTFT duration isn't positive.

The first token arrives at TTFT, leaving 29 tokens over 215 ms. A multi-token event with no positive post-TTFT interval is invalid instrumentation; returning zero or dropping it silently would hide the defect.

6.A request reports 201 input tokens, including 80 cached tokens, and 30 output tokens. A versioned card charges 1.00, 0.10, and 5.00 USD per million for uncached input, cached input, and output. Another request reports 202 cached tokens out of 201 input tokens. What should the cost system do?

Correct answer: Record 0.000279 USD for the valid request, and reject the second request as impossible usage.

The request has 121 uncached input tokens, so cost is (121 x 1.00 + 80 x 0.10 + 30 x 5.00) / 1,000,000 = 0.000279. Cached input can't exceed total input; reject that event instead of manufacturing a plausible estimate.

7.An unsafe-serve aggregate triggers a page. Which attachment most directly lets the on-call engineer move from detection to a reproducible first investigation?

Correct answer: A violating request ID, template and evidence versions, both failure stages, and a concrete first check.

An aggregate shows that the invariant changed; a violating exemplar makes the failure reproducible. Version IDs identify inputs, stage fields separate root defect from containment leak, and the first check gives on-call an immediate action without defaulting to raw text.

8.An agent's last two steps used the same tool, the same redacted parameter hash, and the same progress marker. What should the monitor infer?

Correct answer: The workflow is stalled and should stop for review because the repeated state transition made no progress.

The same tool call can be legitimate when state advances. Here tool, parameters, and progress marker all repeat, which is enough to identify a stall without collecting hidden reasoning.

9.After the unsafe window, monitoring creates a handoff for deploy-answerer-v1.2-fix with promotion status BLOCKED_PENDING_EVALUATION. What must happen before promotion?

Correct answer: Pass controlled regression and representative holdout checks on the same unsafe-serve invariant.

Monitoring proves the deployed path failed, not that a candidate fixes it or generalizes. The incident handoff should preserve the failing invariant, and promotion remains blocked until regression and representative holdout evidence pass it.

10.The traced implementation opens deploy-answer.request for req_205, then starts retrieval deploy-policy, chat deploy-assistant-prod, and serving_gate while that root is current. Which exported relationship preserves the request structure?

Correct answer: All three operation spans are children of deploy-answer.request, so one trace links retrieval, generation, and the gate leak to the user request.

Starting each operation while the request span is current gives all three their parent. Separate child spans retain step-specific attributes, while one trace preserves causality from retrieval through generation and containment.

10 questions remaining.

Next Step
Continue to Experiment Tracking with MLflow and W&B

You can now page on an unsafe serve and hand off the failing template, exemplar, and required metric. Next you'll freeze those identifiers into comparable runs so `deploy-answerer-v1.2-fix` earns promotion instead of inheriting it from the incident.

PreviousHallucination Detection & Mitigation
Share this article
XFacebookLinkedInBlueskyRedditHacker NewsEmail
References

OpenTelemetry GenAI Semantic Conventions

OpenTelemetry Authors · 2026

https://github.com/open-telemetry/semantic-conventions-genai

OpenTelemetry GenAI metrics, v1.41.0

OpenTelemetry Authors · 2026

https://github.com/open-telemetry/semantic-conventions/blob/v1.41.0/docs/gen-ai/gen-ai-metrics.md

OpenTelemetry GenAI span definitions, v1.41.0

OpenTelemetry Authors · 2026

https://github.com/open-telemetry/semantic-conventions/blob/v1.41.0/model/gen-ai/spans.yaml

OpenTelemetry Specification.

OpenTelemetry Authors. · 2023

https://opentelemetry.io/docs/specs/otel

Handling sensitive data

OpenTelemetry Authors · 2026

https://opentelemetry.io/docs/security/handling-sensitive-data/

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.