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 158 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/8
Gradients and BackpropVectors, Matrices & TensorsLinear Algebra for MLAdam, Momentum, SchedulersProbability for Machine LearningStatistics 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/11
Linear Regression from ScratchLogistic Regression and MetricsDecision Trees, Forests, and BoostingReinforcement Learning BasicsValidation and LeakageClustering and PCACore Retrieval AlgorithmsDecoding AlgorithmsExperiment Design and A/B TestingPyTorch 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/24
Dimensionality Reduction for EmbeddingsCoT, ToT & Self-Consistency PromptingFunction Calling & Tool UseMCP & Tool Protocol StandardsContext EngineeringPrompt Injection DefenseResponsible AI GovernanceData 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/8
Capstone: Delivery ETA PredictionCapstone: Product RankingCapstone: Demand ForecastingCapstone: Image Damage ClassifierCapstone: Production ML PipelineCapstone: Document QACapstone: Eval DashboardCapstone: Fine-Tuned Classifier
🧠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/15
Scaling Laws & Compute-Optimal TrainingPre-training Data at ScaleBuild GPT from Scratch LabContinued Pretraining for Domain ShiftSynthetic Data PipelinesSupervised Fine-Tuning PipelineMixed Precision TrainingDistributed Training: FSDP & ZeROLoRA & Parameter-Efficient TuningReward 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/19
Inference: TTFT, TPS & KV CacheMulti-Query & Grouped-Query AttentionKV Cache & PagedAttentionPrefix Caching and Prompt CachingFlashAttention & Memory EfficiencyContinuous Batching & SchedulingScaling LLM InferenceModel Parallelism for LLM InferenceModel 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 & AutoscalingA/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 & Test-Time Compute
🎤AI Lab Interviewing0/4
AI Lab Coding Interview: Python SystemsAI Lab System Design InterviewAI Lab Behavioral InterviewAI Lab Technical Presentation
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.

19 min read
Learning path
Step 72 of 158 in the full curriculum
Hallucination Detection & MitigationExperiment Tracking with MLflow and W&B

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.

Six-request production window where four requests route safely, requests 205 and 206 serve unsupported deploy approvals, TTFT stays under the 500 millisecond warning line, and the alert panel links exemplar request 205 to a claim-generation regression that escaped the serving gate. Six-request production window where four requests route safely, requests 205 and 206 serve unsupported deploy approvals, TTFT stays under the 500 millisecond warning line, and the alert panel links exemplar request 205 to a claim-generation regression that escaped the serving gate.
Latency stays healthy while claim safety regresses, so the alert has to point straight to request 205 and its trace.

Start with the event your gate already produced

Avoid beginning with a dashboard wish list. Begin with the decision boundary from the serving path:

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

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.

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

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

Turn request evidence into quality metrics

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:

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

The next cell aggregates the synthetic production window.

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.

Observability triage view where safety fails with 2 of 6 unsafe serves while latency and cost stay within limits, and the same claim-generation defect splits into two contained requests under the strict gate and two escaped requests under version 1.1. Observability triage view where safety fails with 2 of 6 unsafe serves while latency and cost stay within limits, and the same claim-generation defect splits into two contained requests under the strict gate and two escaped requests under version 1.1.
Safety, latency, and cost answer different policy questions. The containment split shows why the same generation defect stayed safe under the strict gate but escaped under version 1.1.

Measure responsiveness without hiding correctness

Quality is the 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 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.

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 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]Reference 1Semantic conventions for generative AI systemshttps://opentelemetry.io/docs/specs/semconv/gen-ai/[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

Parent-child span hierarchy for agent tracing

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]Reference 3OpenTelemetry Specification.https://opentelemetry.io/docs/specs/otel

Here is how you initialize and link these spans programmatically using the OpenTelemetry API:

otel-spans.py
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}
Output
1Captured spans: 3 2Span: agent.request | Parent: None 3Span: retrieval | Parent: agent.request 4Span: gen_ai.call | Parent: agent.request

Keep standard telemetry and product decisions together

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

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

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

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

Attribute cost with a versioned rate card

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.

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

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

Store durable debugging evidence without operator text

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

The record below keeps a redacted preview for an incident exemplar while leaving raw payload storage unset.

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

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

Privacy-preserving trace transformation where the raw request for incident INC-10234 and bruno@example.com is not stored, the scrub policy platform-pii-v1 replaces those values with INCIDENT_ID and EMAIL markers, and the durable request 206 record keeps template, evidence, route, claim-generation failure, and serving-gate escape fields while raw_payload_ref remains None. Privacy-preserving trace transformation where the raw request for incident INC-10234 and bruno@example.com is not stored, the scrub policy platform-pii-v1 replaces those values with INCIDENT_ID and EMAIL markers, and the durable request 206 record keeps template, evidence, route, claim-generation failure, and serving-gate escape fields while raw_payload_ref remains None.
The durable request 206 record keeps enough structured evidence to reproduce the gate escape, while the raw incident ID, email address, and payload reference remain absent.

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

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

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

Attach an exemplar and a first investigation step

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.

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.

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.

Extend the contract when answers call tools

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.

Agent state-delta comparison where two consecutive lookup_deploy_status steps have the same redacted parameter hash deploy-redacted:v1 and the same progress marker scan:v17, so all three equality checks produce stalled_repeat true and route to stop_and_review, while a counterfactual scan:v18 progress marker would continue. Agent state-delta comparison where two consecutive lookup_deploy_status steps have the same redacted parameter hash deploy-redacted:v1 and the same progress marker scan:v17, so all three equality checks produce stalled_repeat true and route to stop_and_review, while a counterfactual scan:v18 progress marker would continue.
An identical action becomes a stalled loop only when the controlled progress marker is also unchanged. A new deploy state takes the continue branch without inspecting hidden reasoning text.
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

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

Mastery check

Mastery outcomes

CapabilityWorking proof
Separate safety from responsivenessA six-event window pages on unsupported serves despite healthy p95 TTFT
Diagnose root cause and failed containmentTraces record claim_generation separately from the serving_gate escape
Keep durable telemetry useful and privateThe incident record retains redacted evidence, stable identifiers, and no raw payload
Hand incident evidence to experiment trackingThe monitor creates a blocked candidate handoff with a required safety metric

Evaluation rubric

  • Starts from a factual-serving invariant instead of generic dashboard metrics
  • Treats justified abstention as safe behavior rather than a blanket failure
  • Detects unsafe served claims even when latency is healthy
  • Keeps the earliest defect separate from the protection boundary that leaked it
  • Computes post-first-token throughput without counting the first token twice
  • Keeps sensitive prompt and answer text out of default durable attributes
  • Uses a versioned cost card rather than hard-coded current-provider assertions
  • Pages with an exemplar trace and a concrete first check
  • Records tool progress without relying on chain-of-thought logs
  • Blocks a candidate fix until a measured experiment validates it

Follow-up questions

Common pitfalls

Response success is mistaken for answer safety

  • Symptom: Dashboards stay green while operators receive invented deploy approvals.
  • Cause: Monitoring captures HTTP status and latency but not claim-gate outcomes.
  • Fix: Emit and aggregate the unsafe-served-claim invariant beside performance metrics.

Abstention pages the on-call engineer

  • Symptom: A strict gate produces noisy incidents whenever evidence is missing.
  • Cause: Safe withholding and unsafe serving were collapsed into one "bad answer" metric.
  • Fix: Separate unsafe-serve, abstention, and supported-serve rates.

Streaming speed is calculated incorrectly

  • Symptom: Generation throughput looks slightly better than it was.
  • Cause: The first token is counted in an interval that begins after first-token arrival.
  • Fix: Divide remaining output tokens by post-TTFT duration, or define a different measured interval explicitly. Reject malformed timing events instead of masking them with a fallback denominator.

Root cause and containment escape are collapsed

  • Symptom: An incident starts at the serving gate but loses where the unsupported draft originated.
  • Cause: One field is used for both the earliest defect and the boundary that leaked it.
  • Fix: Record first_failed_stage and escape_stage separately, then attach both to the exemplar.

Durable logs collect raw operator text

  • Symptom: Debugging records retain incident identifiers and email addresses unnecessarily.
  • Cause: Raw prompts were treated as ordinary metadata.
  • Fix: Store redacted previews and stable identifiers by default; govern any raw-payload access separately.

The alert can't start an investigation

  • Symptom: A page says "quality decreased" and on-call searches broad logs manually.
  • Cause: The alert has no exemplar trace, template version, evidence version, first failed stage, or escape stage.
  • Fix: Attach a violating trace and the first runbook check to every invariant alert.
Complete the lesson

Mastery Check

Answer every question, then check your score. Score above 75% 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?
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?
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?
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?
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?
6.A request reports 201 input tokens, including 80 cached tokens, and 30 output tokens. A versioned card charges 1.00,1.00, 1.00,0.10, and $5.00 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?
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?
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?
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?
10.The traced implementation opens agent.request, then starts retrieval and gen_ai.call while the root span is current. Which exported relationship preserves the request structure?

10 questions remaining.

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

You can now detect a live failure and capture the exact evidence behind it. Next you'll record controlled candidate runs so a proposed fix earns promotion through reproducible results.

PreviousHallucination Detection & Mitigation
Share this article
XFacebookLinkedInBlueskyRedditHacker NewsEmail
References

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

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.