Skip to content
Skip to lesson
Back to Topics
LearnApplied LLM EngineeringLLM Observability & Monitoring
⚙️MediumMLOps & Deployment

LLM Observability & Monitoring

Connect answer-quality checks to latency, cost, traces, and actionable alerts without confusing missing evidence with a healthy service.

31 min read
Learning path
Step 78 of 197 in the full curriculum

Personalize this lesson

Adapt explanations and teaching visuals to your background and preferred voice.

At 09:12 in a fictional deploy-policy window, the dashboard for a large language model (LLM) service looks spotless: all six responses return 200 OK, first-token delays stay below the fixture's 500 ms warning threshold, and estimated cost stays under budget. Two answers still contain unsupported deploy approvals. The window, outcomes, and prices below were supplied by hand; no production service or human annotation study ran.

Application Performance Monitoring (APM) can carry custom quality metrics and traces. The gap is an incomplete measurement contract: HTTP success alone doesn't establish correct content, for ordinary software or an LLM. Google's SRE guidance already includes wrong content returned with HTTP 200 as an implicit error. For this answerer, extend those operational signals with evidence support, containment outcomes, token usage, and request execution structure.[1]

In the previous lesson, we built incident-answerer-v1: a claim gate that refused to invent a recovery time missing from the versioned incident record. Each request wrote a recording evidence version, route, and first failed stage. We maintain that contract on the parallel deploy-answerer-v1 path, then observe a window. One trace answers what happened to one request; window metrics answer whether failure rate, token burn, or latency shifted across the service.

joins those views. Monitoring compresses many events into rates, percentiles, and alerts. Trace evidence lets an on-call engineer start from an alert and inspect the operations and reviewed outcomes for one failed call. You need both because a fast, inexpensive 200 can still be a severe safety incident.

Six synthetic requests all return HTTP 200 with first-token delays of 172 to 240 ms. Supplied outcome labels identify two unsafe served answers: 2 of 6 reviewed requests and 2 of 3 substantive answers served.
Every first-token delay is below the fixture's 500 ms warning. HTTP status alone misses the two unsafe answers; the supplied quality labels expose them. First-token delay is not total response time.

Emit containment and outcome fields at the serving gate

Start with the serving decision. A claim gate compares draft claims with admitted evidence, then chooses whether to show or withhold an answer. Its route and a separate check of the served answer 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 a substantive answer actually reach the operator?substantive_output_servedObserved payload outcome, not just the route label
Did an unsupported claim reach the operator?unsafe_claim_servedReviewed outcome, separate from the gate's own verdict
Where did a request first go wrong?first_failed_stageSeparates evidence, generation, and serving faults
Which protection boundary leaked a known failure?escape_stageSeparates the observed defect from failed containment
Which record and prompt were used?evidence_version, prompt_template_idLocates artifacts needed for reproduction
Was the request usable and affordable? and timing fieldsMeasures experience and cost beside quality

These six synthetic requests have hand-authored outcome labels. The strict path serves one supported answer and withholds three others, including one request with no admitted policy. The regressed path serves two invented approvals. Four drafts contain unsupported claims, but only two reach an operator. The fixture models a complete final event set; a live collector must reconcile received events with an independent ingress count for the same window.

A live serving gate can't certify its own success. Join independent rule checks, reviewed outcomes, or versioned evaluator results to the request ID. Here False means reviewed with no unsupported served claim; None means not checked yet. Neither a missing label nor an evaluator timeout means safe. Stage labels are investigation annotations, not automatic proof of a root cause.

Read the six rows before running the code. Which requests should trigger the unsafe-serve policy, and which should merely contribute to the abstention count?

trace-events.py
from dataclasses import dataclass, replace
from types import MappingProxyType
from collections.abc import Mapping

ROUTES = frozenset({"serve", "shorten", "review", "abstain"})
FAILURE_STAGES = frozenset({
    "passed", "evidence_admission", "claim_generation", "generation_stability",
    "stability_check_incomplete", "serving_gate", "unknown",
})
ESCAPE_STAGES = frozenset({"serving_gate", "privacy_filter", "safety_filter"})

def require_text(value: object, name: str) -> None:
    if type(value) is not str or not value.strip():
        raise ValueError(f"{name} must be a nonempty string")

@dataclass(frozen=True)
class TraceEvent:
    request_id: str
    case_id: str
    evidence_version: str | None
    prompt_template_id: str
    route: str
    first_failed_stage: str
    escape_stage: str | None
    unsafe_claim_served: bool | None
    substantive_output_served: bool
    input_tokens: int
    cached_input_tokens: int
    output_tokens: int
    ttft_ms: int
    total_ms: int

    def __post_init__(self) -> None:
        for name in ("request_id", "case_id", "prompt_template_id"):
            require_text(getattr(self, name), name)
        if self.evidence_version is not None:
            require_text(self.evidence_version, "evidence_version")
        if (type(self.route) is not str or self.route not in ROUTES
                or type(self.first_failed_stage) is not str
                or self.first_failed_stage not in FAILURE_STAGES):
            raise ValueError("unknown route or failure stage")
        if self.escape_stage is not None and (
            type(self.escape_stage) is not str or self.escape_stage not in ESCAPE_STAGES
        ):
            raise ValueError("unknown containment boundary")
        if self.unsafe_claim_served is not None and type(self.unsafe_claim_served) is not bool:
            raise ValueError("outcome must be a Boolean or None for pending review")
        if type(self.substantive_output_served) is not bool:
            raise ValueError("payload outcome must be a Boolean")
        if self.unsafe_claim_served is True and not self.substantive_output_served:
            raise ValueError("an unsafe served claim requires an actual served payload")

current_window = [
    TraceEvent("req_201", "clean_policy", "deploy-policy@v17", "deploy-answerer-v1",
               "serve", "passed", None, False, True, 188, 80, 27, 172, 410),
    TraceEvent("req_202", "invented_approval", "deploy-policy@v17", "deploy-answerer-v1",
               "abstain", "claim_generation", None, False, False, 196, 80, 20, 181, 395),
    TraceEvent("req_203", "wrong_freeze_status", "deploy-policy@v17", "deploy-answerer-v1",
               "abstain", "claim_generation", None, False, False, 192, 80, 21, 188, 402),
    TraceEvent("req_204", "missing_policy", None, "deploy-answerer-v1",
               "abstain", "evidence_admission", None, False, False, 160, 64, 15, 205, 382),
    TraceEvent("req_205", "invented_approval", "deploy-policy@v17", "deploy-answerer-v1.1-regression",
               "serve", "claim_generation", "serving_gate", True, True, 198, 80, 29, 218, 430),
    TraceEvent("req_206", "invented_approval", "deploy-policy@v17", "deploy-answerer-v1.1-regression",
               "serve", "claim_generation", "serving_gate", True, True, 201, 80, 30, 240, 455),
]
ingress_request_count = 6  # Separate fixture counter for this exact window.

print(f"events={len(current_window)}")
for event in current_window:
    verdict = {True: "UNSAFE SERVE", False: "reviewed safe", None: "unchecked"}[event.unsafe_claim_served]
    print(f"{event.request_id}: {event.route:7} {verdict}")
Output
events=6
req_201: serve   reviewed safe
req_202: abstain reviewed safe
req_203: abstain reviewed safe
req_204: abstain reviewed safe
req_205: serve   UNSAFE SERVE
req_206: serve   UNSAFE SERVE

An abstention isn't automatically a failure. When evidence doesn't establish a deploy approval, withholding is safe product behavior. Under this service's policy, the 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.

Traces connect steps within a request. Metrics summarize a population over time. Logs record discrete events and can share trace IDs. These signals overlap; their retention and privacy controls are design choices, not properties implied by their names.

Turn request evidence into quality metrics

A metric is useful only when its numerator matches the invariant it claims to protect. A generic "answer quality score" could hide two unsafe deploy claims inside many pleasant responses. Start with counts whose denominators you can explain:

MetricNumerator / denominatorInterpretation
Reviewed unsafe-serve rateReviewed unsafe requests / reviewed requestsFailure rate among checked requests, not necessarily all traffic
Conditional unsafe-answer riskReviewed unsafe substantive answers / reviewed substantive answersSame denominator as the previous lesson's unsafe-serve rate
Reviewed safe-route rateReviewed requests without an unsafe served claim / reviewed requestsComplement within the same reviewed set
Review coverageReviewed requests / all requestsMakes missing quality evidence visible
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

Two requests are unsafe serves, three abstain, and four drafts have a supplied generation-defect label. The summary keeps those counts separate. It rejects duplicate request IDs and shows how many ingress requests have a received final event. An absent event is not a safe request. Production delayed-review updates need an upsert keyed by request ID, not a second row in the denominator.

quality-window.py
from collections import Counter
from dataclasses import dataclass

@dataclass(frozen=True)
class QualityWindow:
    requests: int
    observed: int
    reviewed: int
    reviewed_served: int
    unsafe_serves: int
    abstentions: int
    failure_stages: Mapping[str, int]
    escape_stages: Mapping[str, int]

    @property
    def unsafe_serve_rate(self) -> float | None:
        return self.unsafe_serves / self.reviewed if self.reviewed else None

    @property
    def safe_route_rate(self) -> float | None:
        return (self.reviewed - self.unsafe_serves) / self.reviewed if self.reviewed else None

    @property
    def conditional_unsafe_answer_risk(self) -> float | None:
        return self.unsafe_serves / self.reviewed_served if self.reviewed_served else None

def quality_window(events: list[TraceEvent], ingress_request_count: int) -> QualityWindow:
    if type(events) not in (list, tuple) or any(type(event) is not TraceEvent for event in events):
        raise ValueError("need validated final events")
    if type(ingress_request_count) is not int or ingress_request_count < len(events):
        raise ValueError("ingress count must cover every observed request")
    if len({event.request_id for event in events}) != len(events):
        raise ValueError("one final outcome per request ID is required")
    return QualityWindow(
        requests=ingress_request_count,
        observed=len(events),
        reviewed=sum(event.unsafe_claim_served is not None for event in events),
        reviewed_served=sum(event.unsafe_claim_served is not None and event.substantive_output_served
                            for event in events),
        unsafe_serves=sum(event.unsafe_claim_served is True for event in events),
        abstentions=sum(event.route == "abstain" for event in events),
        failure_stages=MappingProxyType(dict(Counter(event.first_failed_stage for event in events))),
        escape_stages=MappingProxyType(dict(Counter(
            event.escape_stage for event in events
            if event.unsafe_claim_served is True and event.escape_stage is not None
        ))),
    )

quality = quality_window(current_window, ingress_request_count)
print(f"unsafe_serve_rate={quality.unsafe_serve_rate:.1%}")
print(f"conditional_unsafe_answer_risk={quality.conditional_unsafe_answer_risk:.1%}")
print(f"safe_route_rate={quality.safe_route_rate:.1%}")
print(f"review_coverage={quality.reviewed}/{quality.requests}")
print(f"abstentions={quality.abstentions}")
print(f"claim_generation_failures={quality.failure_stages['claim_generation']}")
print(f"serving_gate_escapes={quality.escape_stages['serving_gate']}")

assert quality.unsafe_serves == 2
assert quality.failure_stages["claim_generation"] == 4
assert quality.escape_stages["serving_gate"] == 2
Output
unsafe_serve_rate=33.3%
conditional_unsafe_answer_risk=66.7%
safe_route_rate=66.7%
review_coverage=6/6
abstentions=3
claim_generation_failures=4
serving_gate_escapes=2

All six requests were reviewed, so 2/6 measures unsafe requests in this complete window. Among the three substantive answers actually served, 2/3 were unsafe. Withholding more answers changes the conditional denominator, which is why the previous lesson also tracked usefulness and coverage. The complemented 4/6 rate means no unsafe served claim was found; it doesn't establish correct, complete answers for four requests.

If only the two unsafe examples were selected for review, 2/2 would describe that selected set, not a 100% production failure rate. An empty reviewed set has an undefined rate, not zero. The payload flag must come from inspecting actual output: relabeling a leaked answer abstain doesn't make it withheld.

The strict template withheld two unsupported drafts; the regressed template let two escape. That association makes the template change worth investigating. It doesn't establish whether the cause was a prompt edit, gate configuration, or another correlated change. Mitigate the unsafe path, then compare the versioned artifacts and reproduce the failure.

Four supplied claim-generation failure labels. Requests 202 and 203 are withheld under v1; requests 205 and 206 leak unsupported approvals under v1.1-regression, producing a PAGE policy finding.
These supplied labels distinguish defective drafts from failed containment. The version association guides comparison; it does not establish which change caused the leak.

Keep the golden signals and explain LLM latency

Quality is the first invariant here, but operators also notice sluggish answers. Keep Google's four golden signals: latency, traffic, errors, and saturation. An LLM endpoint still needs request arrival rates, active streams, failed requests, and evidence of exhausted capacity. Add measurements that explain its workload rather than replacing traffic and saturation with quality scores.[1]

MeasurementBoundary to document
Request dispatch to first visible token. Depending on instrumentation, this can include retrieval, network transit, queueing, prefill, and output buffering or gating. A server timer has a different boundary.
Inter-token latency and throughputIndividual model tokens, visible tokens, or client chunks. Chunk delivery is not a direct measurement of every decoding step.
Traffic and saturationRequests/s and active streams; for a self-hosted model, queue depth, batch size, memory and KV-cache occupancy, and accelerator utilization help locate bottlenecks.
Usage and estimated costTotal input, cached-input subsets, and output against a named rate card. Reasoning tokens may be included within output usage rather than an additional billable category.
Cached-input fractioncached_input_tokens / input_tokens, with no fraction when input is zero. This is a token fraction, not the fraction of requests that hit a cache. Latency and savings depend on the cache implementation and pricing.
Containment and reviewed qualityGate interventions, independently detected escapes, review coverage, label delay, and the evaluator version. An intervention count alone does not count defects that escaped detection.

Prefill can process multiple prompt positions together. Autoregressive decoding often becomes memory-bandwidth limited at small batch sizes, but the bottleneck depends on model, hardware, batch size, context length, and implementation. Don't infer it from slow client delivery alone. The inference study cited here analyzes those tradeoffs for particular models and TPU configurations; it does not establish a universal GPU bottleneck.[2]

For a streamed response in this lab:

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

The fixture assumes visible tokens arrive individually: ttft_ms ends at the first token and total_ms ends at the last token, measured from the same monotonic start. For req_206, 30 output tokens leave 29 after the first, over 455 - 240 = 215 ms. Its post-first-token throughput is 29 / 0.215 = 134.9 tokens/s.

An API chunk can contain zero, one, or several tokens. Usage totals may also include hidden reasoning tokens. Don't apply this formula to chunk timestamps and billed token totals without reconciling those boundaries. Measure first visible content, final visible content, and visible output tokens consistently; separately retain full request duration through stream closure.

The fixture also reports : 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
import math

def nearest_rank_percentile(values: list[int], percentile: float) -> int:
    if (type(values) not in (list, tuple) or not values
            or type(percentile) not in (int, float) or not 0 < percentile <= 1):
        raise ValueError("need observations and a percentile in (0, 1]")
    if any(type(value) is not int or value < 0 for value in values):
        raise ValueError("timings must be non-negative integer milliseconds")
    ordered = sorted(values)
    rank = max(1, math.ceil(percentile * len(ordered)))
    return ordered[rank - 1]

def post_first_token_tps(event: TraceEvent) -> float | None:
    if type(event.output_tokens) is not int or event.output_tokens < 1:
        raise ValueError("TTFT requires at least one visible output token")
    if any(type(value) is not int or value < 0 for value in (event.ttft_ms, event.total_ms)):
        raise ValueError("timings must be non-negative integer milliseconds")
    if event.total_ms < event.ttft_ms:
        raise ValueError("total_ms cannot precede ttft_ms")
    remaining_tokens = event.output_tokens - 1
    if remaining_tokens == 0:
        return None  # No post-first-token interval to measure.
    remaining_ms = event.total_ms - event.ttft_ms
    if remaining_ms <= 0:
        raise ValueError("total_ms must exceed ttft_ms after the first token")
    return remaining_tokens / (remaining_ms / 1000)

p95_ttft_ms = nearest_rank_percentile([event.ttft_ms for event in current_window], 0.95)
example_tps = post_first_token_tps(current_window[-1])
malformed_timing = replace(current_window[-1], total_ms=240)

print(f"p95_ttft_ms={p95_ttft_ms}")
print(f"req_206_post_first_tps={example_tps:.1f}")
print(f"quality_safe={quality.unsafe_serves == 0}")
try:
    post_first_token_tps(malformed_timing)
except ValueError as error:
    print(f"invalid_timing={error}")
else:
    raise AssertionError("malformed timing event should fail")

assert p95_ttft_ms == 240
assert quality.unsafe_serves > 0
Output
p95_ttft_ms=240
req_206_post_first_tps=134.9
quality_safe=False
invalid_timing=total_ms must exceed ttft_ms after the first token

This window is fast and unsafe. A latency-only dashboard wouldn't catch the unsafe release.

Under these timing assumptions, a multi-token response with total_ms <= ttft_ms has no usable post-first-token interval. Repair the instrumentation or increase timer resolution; don't invent a denominator. A one-token response returns None because there are no later tokens to measure.

OpenTelemetry (OTel) GenAI conventions are Development and live in a dedicated repository. Pin the convention revision your instrumentation emits.[3]

The conventions checked on September 21, 2026, at revision cc07f722069974139dab497d80d145144b19daca, distinguish client chunk timing from server token timing. gen_ai.client.operation.time_to_first_chunk and gen_ai.client.operation.time_per_output_chunk measure client delivery. gen_ai.server.time_to_first_token and gen_ai.server.time_per_output_token measure model-server output for successful responses. Their unit is seconds; so is the span attribute gen_ai.response.time_to_first_chunk. First chunk and first server token aren't interchangeable measurements.[4][5]

This lab's ttft_ms is an application-owned, operator-visible field. Write down where the timer starts and stops before comparing it with a server metric. Also count errors and cancellations separately: a latency distribution over completed responses can look healthy while requests fail before producing output.

Follow request execution with distinct spans

A flat event row can trigger a page, but it doesn't reveal how nested operations executed. In a modern AI pipeline, a single user request branches across embedding lookup, vector search, reranking, model inference, and safety validation. If services emit isolated log lines, on-call engineers must reconstruct execution by guessing timestamp correlations across distributed services.

OpenTelemetry records parent-child spans. W3C Trace Context uses a 128-bit trace ID and distinct 64-bit span IDs; its HTTP traceparent format is version-trace-id-parent-id-trace-flags. Instrumentation must inject and extract context across HTTP calls, queue message metadata, and async boundaries. Installing an SDK alone doesn't establish every link.[6]

Our tiny tracer illustrates nesting with local integer IDs. It is not an OTel SDK: it has no exporter, timestamps, concurrent-task context, or distributed propagation. A repeated span name must still identify a new operation.

Under the GenAI semantic conventions:

The request is the root; retrieval, chat, and serving_gate are siblings. The chat span carries the reviewed generation defect and the gate span carries the containment escape.

request-span-tree.py
from contextlib import contextmanager
from dataclasses import dataclass

@dataclass(frozen=True)
class Span:
    span_id: int
    name: str
    parent_id: int | None
    attributes: Mapping[str, str | int | bool]

class Tracer:
    def __init__(self) -> None:
        self.spans: list[Span] = []
        self._stack: list[int] = []

    @contextmanager
    def start(self, name: str, **attributes: str | int | bool):
        require_text(name, "span name")
        parent_id = self._stack[-1] if self._stack else None
        span = Span(len(self.spans), name, parent_id, MappingProxyType(dict(attributes)))
        self.spans.append(span)
        self._stack.append(span.span_id)
        try:
            yield span
        finally:
            self._stack.pop()

tracer = Tracer()
unsafe = next(event for event in current_window if event.unsafe_claim_served)
with tracer.start("deploy-answer.request", **{"platform.request.id": unsafe.request_id}):
    with tracer.start(
        "retrieval deploy-policy",
        **{
            "gen_ai.operation.name": "retrieval",
            "gen_ai.data_source.id": "deploy-policy",
            "platform.evidence.version": unsafe.evidence_version or "",
        },
    ):
        pass
    with tracer.start(
        "chat deploy-assistant-prod",
        **{
            "gen_ai.operation.name": "chat",
            "gen_ai.provider.name": "platform.internal",
            "gen_ai.request.model": "deploy-assistant-prod",
            "gen_ai.usage.input_tokens": unsafe.input_tokens,
            "gen_ai.usage.output_tokens": unsafe.output_tokens,
            "platform.failure.stage": unsafe.first_failed_stage,
        },
    ):
        pass
    with tracer.start(
        "serving_gate",
        **{
            "platform.answer.route": unsafe.route,
            "platform.answer.unsafe_claim_served": unsafe.unsafe_claim_served,
            "platform.failure.escape_stage": unsafe.escape_stage or "",
        },
    ):
        pass

by_id = {span.span_id: span for span in tracer.spans}
print(f"root={unsafe.request_id}")
for span in tracer.spans:
    parent_name = None if span.parent_id is None else by_id[span.parent_id].name
    print(f"{span.name} parent={parent_name}")

assert unsafe.request_id == "req_205"
assert [span.parent_id for span in tracer.spans] == [None, 0, 0, 0]
assert tracer.spans[-1].attributes["platform.answer.unsafe_claim_served"] is True

repeated = Tracer()
with repeated.start("agent.request"):
    for _ in range(2):
        with repeated.start("execute_tool lookup_deploy_status"):
            pass
assert [span.span_id for span in repeated.spans] == [0, 1, 2]
assert [span.parent_id for span in repeated.spans] == [None, 0, 0]
print(f"repeated_tool_spans={len(repeated.spans) - 1}")
Output
root=req_205
deploy-answer.request parent=None
retrieval deploy-policy parent=deploy-answer.request
chat deploy-assistant-prod parent=deploy-answer.request
serving_gate parent=deploy-answer.request
repeated_tool_spans=2

The chat span carries the reviewed failure stage; the serving-gate span carries the escape. These attributes locate the observations without including the operator message. Parentage records execution structure, not proof that a parent caused a defect.

Keep standard telemetry and product decisions together

Standard fields identify the model operation. On an inference span, gen_ai.operation.name and gen_ai.provider.name are required. gen_ai.request.model is required when the model ID is available. This synthetic service uses a custom provider because no real vendor is involved. Use a well-known provider value when one applies.[5]

Product fields answer different questions: which evidence version supported the answer, whether the route blocked a claim, where the first failure was observed, and which protection boundary leaked it. Keep those custom attributes beside standard telemetry.

Prompt, output-message, and system-instruction attributes are opt-in in because they may contain sensitive content. Store stable identifiers and safe outcomes by default; raw text capture needs deliberate governance.[5]

The current revision also defines gen_ai.prompt.name and gen_ai.prompt.version when a named, versioned prompt is used.[5] Here a host-owned registry supplies those values. The builder omits input messages, output messages, and system instructions, and leaves the unsafe-serve verdict unset when review is pending. Stable-looking IDs still need an approved source; an arbitrary string field can carry sensitive text.

trace-attributes.py
PROMPTS = MappingProxyType({
    "deploy-answerer-v1": ("deploy-answerer", "v1"),
    "deploy-answerer-v1.1-regression": ("deploy-answerer", "v1.1-regression"),
})

def trace_attributes(event: TraceEvent) -> dict[str, str | int | bool]:
    if event.prompt_template_id not in PROMPTS:
        raise ValueError("prompt ID is absent from the host registry")
    prompt_name, prompt_version = PROMPTS[event.prompt_template_id]
    attributes: dict[str, str | int | bool] = {
        "gen_ai.operation.name": "chat",
        "gen_ai.provider.name": "platform.internal",
        "gen_ai.request.model": "deploy-assistant-prod",
        "gen_ai.request.stream": True,
        "gen_ai.prompt.name": prompt_name,
        "gen_ai.prompt.version": prompt_version,
        "gen_ai.usage.input_tokens": event.input_tokens,
        "gen_ai.usage.cache_read.input_tokens": event.cached_input_tokens,
        "gen_ai.usage.output_tokens": event.output_tokens,
        "platform.answer.route": event.route,
        "platform.answer.reviewed": event.unsafe_claim_served is not None,
        "platform.answer.substantive_output_served": event.substantive_output_served,
        "platform.failure.stage": event.first_failed_stage,
        "platform.prompt.template_id": event.prompt_template_id,
    }
    if event.unsafe_claim_served is not None:
        attributes["platform.answer.unsafe_claim_served"] = event.unsafe_claim_served
    if event.evidence_version is not None:
        attributes["platform.evidence.version"] = event.evidence_version
    if event.escape_stage is not None:
        attributes["platform.failure.escape_stage"] = event.escape_stage
    return attributes

unsafe_attributes = trace_attributes(current_window[-1])
print(f"operation={unsafe_attributes['gen_ai.operation.name']}")
print(f"template={unsafe_attributes['platform.prompt.template_id']}")
print(f"failed_stage={unsafe_attributes['platform.failure.stage']}")
print(f"escape_stage={unsafe_attributes['platform.failure.escape_stage']}")
print(f"cached_input_tokens={unsafe_attributes['gen_ai.usage.cache_read.input_tokens']}")
print(f"raw_messages_logged={'gen_ai.input.messages' in unsafe_attributes}")

assert unsafe_attributes["platform.answer.unsafe_claim_served"] is True
assert unsafe_attributes["platform.failure.escape_stage"] == "serving_gate"
assert unsafe_attributes["gen_ai.provider.name"] == "platform.internal"
assert unsafe_attributes["gen_ai.usage.cache_read.input_tokens"] == 80
assert unsafe_attributes["gen_ai.prompt.version"] == "v1.1-regression"
assert "gen_ai.input.messages" not in unsafe_attributes
assert "gen_ai.output.messages" not in unsafe_attributes
assert "gen_ai.system_instructions" not in unsafe_attributes
Output
operation=chat
template=deploy-answerer-v1.1-regression
failed_stage=claim_generation
escape_stage=serving_gate
cached_input_tokens=80
raw_messages_logged=False

gen_ai.* attributes identify the model call and named prompt. platform.* attributes describe the product decision. Neither requires copying the operator's message into the trace. Total input includes cached input; breakdowns are subsets, not additional tokens. Keep billed and visible-token counts separate when providers report different boundaries.[7]

Attribute cost with a versioned rate card

Store usage counts on the trace, then evaluate them with a versioned rate card. These synthetic USD rates price total input as uncached plus cache-read input. Normalize provider usage to that convention first: OTel's total input count includes cached input.[7] Cache writes, reasoning tokens, tools, batch discounts, and service tiers may require other categories. This estimate isn't an invoice.

For req_206, 201 total input tokens minus 80 cached leaves 121 uncached. At these fixture rates, the estimate is $0.000279. A deliberately malformed copy reports more cached tokens than total input and must be rejected.

versioned-cost.py
def require_nonnegative_number(value: object, name: str) -> None:
    if type(value) not in (int, float):
        raise ValueError(f"{name} must be a finite non-negative number")
    try:
        valid = math.isfinite(value) and value >= 0
    except OverflowError:
        valid = False
    if not valid:
        raise ValueError(f"{name} must be a finite non-negative number")

@dataclass(frozen=True)
class RateCard:
    version: str
    input_per_million: float
    cached_input_per_million: float
    output_per_million: float

    def __post_init__(self) -> None:
        require_text(self.version, "rate-card version")
        for name in ("input_per_million", "cached_input_per_million", "output_per_million"):
            require_nonnegative_number(getattr(self, name), name)

def estimate_cost(event: TraceEvent, card: RateCard) -> float:
    if any(type(value) is not int or value < 0 for value in (
        event.input_tokens, event.cached_input_tokens, event.output_tokens
    )):
        raise ValueError("token counts must be non-negative integers")
    if not 0 <= event.cached_input_tokens <= event.input_tokens:
        raise ValueError("cached_input_tokens must be between 0 and input_tokens")
    uncached_input = event.input_tokens - event.cached_input_tokens
    try:
        estimate = (
            uncached_input * card.input_per_million
            + event.cached_input_tokens * card.cached_input_per_million
            + event.output_tokens * card.output_per_million
        ) / 1_000_000
    except OverflowError as error:
        raise ValueError("usage exceeds the monitoring estimate's numeric range") from error
    require_nonnegative_number(estimate, "estimated cost")
    return estimate

card = RateCard(
    version="internal-fixture-2026-05-27",
    input_per_million=1.00,
    cached_input_per_million=0.10,
    output_per_million=5.00,
)
cost_by_request = {
    event.request_id: estimate_cost(event, card)
    for event in current_window
}
total_cost = sum(cost_by_request.values())
malformed_usage = replace(current_window[-1], cached_input_tokens=202)

print(f"rate_card={card.version}")
print(f"window_cost_usd={total_cost:.6f}")
print(f"req_206_cost_usd={cost_by_request['req_206']:.6f}")
try:
    estimate_cost(malformed_usage, card)
except ValueError as error:
    print(f"invalid_usage={error}")
else:
    raise AssertionError("malformed usage event should fail")

assert card.version.startswith("internal-fixture")
assert total_cost > 0
Output
rate_card=internal-fixture-2026-05-27
window_cost_usd=0.001427
req_206_cost_usd=0.000279
invalid_usage=cached_input_tokens must be between 0 and input_tokens

Versioning separates observation from repricing. Old traces retain usage while the rate-card version explains the estimate. Reject impossible counters rather than clamping them. Float arithmetic is sufficient for this monitoring example; billing needs an explicit decimal precision and rounding policy.

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 the 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
Approved operational identifiersRaw retrieved documents and free-text previews

The record below accepts only a structured event and excludes dedicated payload fields. That reduces collection, but it does not sanitize arbitrary strings placed in an ID. An approved metadata source must supply those fields. IDs can identify people or sensitive incidents, so they still need access controls and retention limits. The record retains artifact identifiers, not artifact contents.

durable-log-record.py
@dataclass(frozen=True)
class DurableRecord:
    request_id: str
    prompt_template_id: str
    evidence_version: str | None
    route: str
    failed_stage: str
    escape_stage: str | None
    raw_payload_ref: str | None
    collection_policy: str

def durable_record(event: TraceEvent) -> DurableRecord:
    return DurableRecord(
        request_id=event.request_id,
        prompt_template_id=event.prompt_template_id,
        evidence_version=event.evidence_version,
        route=event.route,
        failed_stage=event.first_failed_stage,
        escape_stage=event.escape_stage,
        raw_payload_ref=None,
        collection_policy="structured-only-v1",
    )

record = durable_record(current_window[-1])
print(f"request_id={record.request_id}")
print(f"raw_payload_stored={record.raw_payload_ref is not None}")
print(f"collection_policy={record.collection_policy}")
print(f"escape_stage={record.escape_stage}")

assert record.raw_payload_ref is None
Output
request_id=req_206
raw_payload_stored=False
collection_policy=structured-only-v1
escape_stage=serving_gate

Redacting emails and incident numbers doesn't remove passwords, customer details, or sensitive meaning from arbitrary text. Prefer not collecting that text. Review what instrumentation libraries emit and apply Collector processors or an equivalent allowlist before export. Scrubbing downstream doesn't undo earlier copies in buffers or debug logs.[8]

If an investigation genuinely needs payload capture, define authorization, scope, expiry, storage isolation, and deletion before enabling it. A sample rate isn't permission to collect sensitive data. Versions help find the original artifacts only while authorized access and retention still permit it; they don't recreate missing source content.

Alert on decisions engineers can act on

Not every metric deserves a page. A long answer or higher cost might warrant investigation. Under this service's policy, a detected unsupported deploy claim that escaped the gate requires a page.

Thresholds belong to a 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.

The evaluator computes quality, latency, and cost from the same events and named rate card. It also checks the independent ingress count. Two unsafe serves produce one policy finding; p95 TTFT is below 500 ms and estimated window cost is below $0.002. This code prints findings; it doesn't contact a paging service.

actionable-alerts.py
@dataclass(frozen=True)
class MonitorPolicy:
    policy_id: str
    max_unsafe_serves: int
    max_p95_ttft_ms: int
    max_window_cost_usd: float

    def __post_init__(self) -> None:
        require_text(self.policy_id, "policy ID")
        for name in ("max_unsafe_serves", "max_p95_ttft_ms"):
            value = getattr(self, name)
            if type(value) is not int or value < 0:
                raise ValueError(f"{name} must be a non-negative integer")
        require_nonnegative_number(self.max_window_cost_usd, "cost budget")

def evaluate_alerts(
    events: list[TraceEvent],
    rate_card: RateCard,
    policy: MonitorPolicy,
    ingress_request_count: int,
) -> list[str]:
    quality = quality_window(events, ingress_request_count)
    if not events:
        return ["UNKNOWN no request observations"]
    findings: list[str] = []
    if quality.unsafe_serves > policy.max_unsafe_serves:
        findings.append(
            f"PAGE unsafe_claim_served={quality.unsafe_serves}/{quality.requests}"
        )
    try:
        p95 = nearest_rank_percentile([event.ttft_ms for event in events], 0.95)
    except ValueError:
        findings.append("UNKNOWN invalid_latency_telemetry")
    else:
        if p95 > policy.max_p95_ttft_ms:
            findings.append(f"WARN p95_ttft_ms={p95}")
    try:
        cost_usd = sum(estimate_cost(event, rate_card) for event in events)
        require_nonnegative_number(cost_usd, "observed window cost")
    except ValueError:
        findings.append("UNKNOWN invalid_usage_telemetry")
    else:
        if cost_usd > policy.max_window_cost_usd:
            findings.append(f"WARN window_cost_usd={cost_usd:.6f}")
    if quality.observed < quality.requests:
        findings.append(f"UNKNOWN missing_request_events={quality.requests - quality.observed}")
    if quality.reviewed < quality.observed:
        findings.append(f"UNKNOWN unreviewed_requests={quality.observed - quality.reviewed}")
    return findings

policy = MonitorPolicy(
    policy_id="deploy-grounding-slo-v1",
    max_unsafe_serves=0,
    max_p95_ttft_ms=500,
    max_window_cost_usd=0.002,
)
findings = evaluate_alerts(current_window, card, policy, ingress_request_count)

for finding in findings:
    print(finding)
print(f"latency_warning={any('ttft' in item for item in findings)}")
print(f"cost_warning={any('cost' in item for item in findings)}")

assert findings == ["PAGE unsafe_claim_served=2/6"]
Output
PAGE unsafe_claim_served=2/6
latency_warning=False
cost_warning=False

The page finding names a broken invariant. UNKNOWN is a separate coverage signal. If one of six final events is absent, report the missing event even when all five received events are reviewed safe. Missing events also make latency and cost summaries incomplete; low observed cost doesn't establish that the full window stayed under budget. Reconcile window boundaries, ingress counters, and collector health. Production alerting systems can group repeated findings into an incident and route it to an owner and runbook.

Malformed usage or timing mustn't erase a known safety finding. The quality record can be valid even when another telemetry channel is broken. This copy of req_206 has impossible cached usage and a missing first-token delay: the evaluator still pages on the two reviewed unsafe answers, and reports both operational measurements as unknown.

broken-measurements-still-page.py
broken_window = current_window[:-1] + [replace(current_window[-1],
    cached_input_tokens=202, ttft_ms=None)]
broken_findings = evaluate_alerts(broken_window, card, policy, ingress_request_count)
for finding in broken_findings:
    print(finding)
assert broken_findings == [
    "PAGE unsafe_claim_served=2/6",
    "UNKNOWN invalid_latency_telemetry",
    "UNKNOWN invalid_usage_telemetry",
]
Output
PAGE unsafe_claim_served=2/6
UNKNOWN invalid_latency_telemetry
UNKNOWN invalid_usage_telemetry

Six synthetic requests demonstrate alert logic, not a production latency or cost budget. Set warning thresholds from representative traffic and revisit them as workload shape changes. Zero-tolerance safety invariants are different: a known unsupported deploy claim must not be served.

Root-cause triage: from metric anomalies to span exemplars

An unsafe-serve finding narrows the investigation. It doesn't determine an exact cause. Walk through this fixture:

  1. Read the denominator: unsafe_claim_served=2/6 means two of six reviewed requests leaked a claim. Among the three substantive answers actually served, two were unsafe.
  2. Slice by version: All withholding in these rows occurs under deploy-answerer-v1; both escapes occur under deploy-answerer-v1.1-regression. This is an association in six supplied cases, not proof that a prompt edit caused the escapes.
  3. Inspect a violating exemplar: For req_205, the chat span carries first_failed_stage='claim_generation' and the gate span carries escape_stage='serving_gate'. Those annotations separate a defective draft from failed containment. They don't explain the gate's internal mechanism.
  4. Compare retained artifacts: Inspect authorized prompt, source, and gate artifacts. A production reproduction also needs gate configuration, service and model versions, evaluator version, and relevant inputs. This minimal fixture doesn't include them all.
  5. Contain and verify: Disable the affected path or revert to a previously validated configuration according to the runbook. Check that containment works after the change; a rollback label alone doesn't establish safety. Then evaluate a candidate fix.

For an invariant page, attach one violating trace with its template and source versions, then state an immediate check. The exemplar is selected to investigate the violation; it isn't a representative traffic sample.

runbook-card.py
@dataclass(frozen=True)
class IncidentCard:
    policy_id: str
    summary: str
    exemplar_request_id: str
    template_id: str
    evidence_version: str | None
    first_failed_stage: str
    escape_stage: str | None
    first_check: str

def incident_card(events: list[TraceEvent], policy: MonitorPolicy) -> IncidentCard | None:
    exemplar = next((event for event in events if event.unsafe_claim_served is True), None)
    if exemplar is None:
        return None
    return IncidentCard(
        policy_id=policy.policy_id,
        summary="Unsupported deploy claim reached operator response.",
        exemplar_request_id=exemplar.request_id,
        template_id=exemplar.prompt_template_id,
        evidence_version=exemplar.evidence_version,
        first_failed_stage=exemplar.first_failed_stage,
        escape_stage=exemplar.escape_stage,
        first_check="Compare serving-gate template with deploy-answerer-v1.",
    )

card_view = incident_card(current_window, policy)
assert card_view is not None
print(f"exemplar={card_view.exemplar_request_id}")
print(f"template={card_view.template_id}")
print(f"first_stage={card_view.first_failed_stage}")
print(f"escape_stage={card_view.escape_stage}")
print(f"first_check={card_view.first_check}")

assert card_view.first_failed_stage == "claim_generation"
assert card_view.escape_stage == "serving_gate"
Output
exemplar=req_205
template=deploy-answerer-v1.1-regression
first_stage=claim_generation
escape_stage=serving_gate
first_check=Compare serving-gate template with deploy-answerer-v1.

The first investigation is specific: compare the template and gate configuration with the strict version that abstained on the same case. A validated can be a containment step while the team investigates the cause.

Distributed tracing in multi-agent trajectories and tool loops

Tool-using workflows can take several model and tool steps. One useful runtime model has four phases; agent frameworks may organize them differently:

  1. Select an action: The application records the chosen action without needing hidden reasoning text.
  2. Tool execution (execute_tool): The agent dispatches a structured call to an external tool or sub-agent.[5]
  3. Observation: The tool executes and injects its result back into the agent's context window.
  4. State evaluation and reflection: The agent inspects the observation to decide whether the task is complete or if another tool call is needed.

Instrument model and tool operations and propagate their context. The GenAI conventions recommend execute_tool {gen_ai.tool.name} as the tool span name, with a tool call ID when available and an error type for failures. A missing tool ID is not permission to merge distinct calls.[5]

Tool arguments and results are opt-in because they can carry . Here an opaque operation key distinguishes the deployment being polled without copying its arguments. A shared redaction placeholder wouldn't distinguish two deployments; an ordinary hash of a guessable value wouldn't make it private. Keep approved tool names, result status, progress markers, repeat counts, and stop reasons. Don't depend on hidden reasoning text.

An unbounded loop can keep spending without completing work. Input-token totals grow quadratically only under particular assumptions: each of N model calls resends the full history, starting at L₀ tokens and adding a fixed g tokens after each call. Summing L₀, L₀ + g, …, L₀ + (N - 1)g gives NL₀ + gN(N - 1)/2. For L₀=100, g=20, four calls send 520 input tokens; eight send 1,360. Capping history at 120 instead gives 460 and 940. These are logical input counts, not dollar estimates: prefix caching can change recomputation and prices, while truncation or compaction changes the history. A repeated tool call without another model call doesn't incur that model-input series.

Polling can legitimately return the same state twice. Define a bound instead of pretending to diagnose intent: this fixture stops after three consecutive observations of the same tool, operation key, and progress marker. A changed marker resets the count. Real pollers also need backoff and an elapsed-time deadline; a fast loop mustn't exhaust a budget intended for a slow deployment.

Example action, tool, observation, and runtime-budget phases. Three equal progress markers reach the repeat limit; a changed marker resets that counter. Neither sequence alone establishes whether the workflow is healthy.
The stop is a budget decision, not proof of a bug. Progress resets this repeat counter; an independent deadline can still end the operation.
tool-loop-monitor.py
@dataclass(frozen=True)
class ToolStep:
    tool_name: str
    operation_key: str
    progress_marker: str

    def __post_init__(self) -> None:
        for name in ("tool_name", "operation_key", "progress_marker"):
            require_text(getattr(self, name), name)

def repeat_budget_exhausted(steps: list[ToolStep], max_same_state: int = 3) -> bool:
    if type(steps) not in (list, tuple) or any(type(step) is not ToolStep for step in steps):
        raise ValueError("steps must be a sequence of validated ToolStep records")
    if type(max_same_state) is not int or max_same_state < 2:
        raise ValueError("max_same_state must be an integer of at least 2")
    if not steps:
        return False
    same_state = 0
    for step in reversed(steps):
        if step != steps[-1]:
            break
        same_state += 1
    return same_state >= max_same_state

steps = [
    ToolStep("lookup_deploy_status", "operation-42", "scan:v17"),
    ToolStep("lookup_deploy_status", "operation-42", "scan:v17"),
    ToolStep("lookup_deploy_status", "operation-42", "scan:v17"),
]

print(f"tool={steps[-1].tool_name}")
print(f"repeat_budget_exhausted={repeat_budget_exhausted(steps)}")
print("route=stop_and_review" if repeat_budget_exhausted(steps) else "route=continue")

assert repeat_budget_exhausted(steps[:2]) is False
assert repeat_budget_exhausted(steps) is True
assert repeat_budget_exhausted(steps + [replace(steps[-1], progress_marker="scan:v18")]) is False
Output
tool=lookup_deploy_status
repeat_budget_exhausted=True
route=stop_and_review

The helper above returns a budget decision; the example prints a route. An actual runtime must enforce that decision before another tool call, independently of trace sampling, and enforce its deadline and other resource limits too.

Preserve the evidence for experiment tracking

An alert reports an observed policy violation or incomplete evidence. Here the outcomes are supplied fixture labels. Neither logs nor span nesting establish answer truth by themselves. Evaluating a candidate fix requires a controlled experiment linking the failing window, candidate, policy, evaluator, and rerun results.

experiment-handoff.py
@dataclass(frozen=True)
class ExperimentHandoff:
    incident_policy_id: str
    failing_template_id: str
    exemplar_request_id: str
    evidence_version: str | None
    candidate_template_id: str
    required_metric: str
    promotion_status: str

handoff = ExperimentHandoff(
    incident_policy_id=card_view.policy_id,
    failing_template_id=card_view.template_id,
    exemplar_request_id=card_view.exemplar_request_id,
    evidence_version=card_view.evidence_version,
    candidate_template_id="deploy-answerer-v1.2-fix",
    required_metric="zero unsafe claims; complete review; retained clean-case usefulness",
    promotion_status="BLOCKED_PENDING_EVALUATION",
)

print(f"candidate={handoff.candidate_template_id}")
print(f"required_metric={handoff.required_metric}")
print(f"promotion={handoff.promotion_status}")

assert handoff.promotion_status == "BLOCKED_PENDING_EVALUATION"
Output
candidate=deploy-answerer-v1.2-fix
required_metric=zero unsafe claims; complete review; retained clean-case usefulness
promotion=BLOCKED_PENDING_EVALUATION

Monitoring supplies a starting point for a controlled run. Specify usefulness thresholds before tuning: abstaining on every clean request would avoid unsafe claims while breaking the service's purpose. Require complete review and safety checks on the regression cases, then evaluate a separate representative holdout. Zero observed failures in either finite set isn't a guarantee of future safety.

Keep the 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 a new failure label.

Count before sampling traces

Trace volume, retention, and operational needs determine whether sampling is useful. Two common strategies decide at different points:[9]

Suppose a quality label arrives 60 seconds after completion, but the collector decides after 10 seconds. A rule to keep unsafe traces cannot use the verdict it hasn't received. The current tail-sampling processor also offers different decision strategies; configure and measure the one deployed. Tail sampling doesn't require or guarantee a complete trace, and can't recover spans already discarded upstream.[10]

Neither technique turns an unobserved quality defect into a detected one.

Signal classSampling habitWhy
Request counts, latency, detected violationsCompute before trace samplingSampled spans aren't the complete request population
Expensive quality evaluatorsKeep a known-probability sample for rate estimates; separately oversample suspected failures for diagnosisA failure-enriched sample has a different denominator
Sensitive payloadsDefault off, even on retained tracesSampling doesn't provide authorization or anonymization
Agent repeat budgetsEnforce in the runtime; export bounded state and stop countersStopping mustn't depend on whether a trace is retained

For example, retaining both unsafe traces plus one of the four safe traces gives 2/3 unsafe in storage, while the complete window is 2/6. Don't put the first fraction on a dashboard labeled "all requests." Publish the sample policy, review coverage, evaluator version, and label delay beside any estimated quality rate.

The gate runs on every request in this example, but independent outcome review may lag or miss defects. An unsampled counter counts all detected violations only if its own collection path works. Monitor dropped telemetry, duplicate events, late labels, and missing outcomes. Keep high-cardinality request IDs in traces or exemplars rather than metric dimensions, and use bounded dimensions such as route and deployment version.

Complete the lesson

Mastery Check

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

1.All six requests are reviewed. Three substantive answers reach operators; two of those contain unsupported approvals. What pair of rates preserves both denominators?
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.Visible tokens arrive individually, and total latency ends at the last token. A has 30 tokens, TTFT 240 ms, and total latency 455 ms. B has multiple tokens but TTFT and total latency both 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, 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?
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.A poller stops after three consecutive identical observations. Its latest two steps share a tool, operation key, and progress marker. No deadline has expired. What should happen?
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 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?

10 questions remaining.

References

Monitoring Distributed Systems

Google · 2016 · Site Reliability Engineering

https://sre.google/sre-book/monitoring-distributed-systems/

Efficiently Scaling Transformer Inference

Pope, R., et al. · 2023 · MLSys 2023

https://proceedings.mlsys.org/paper_files/paper/2023/hash/c4be71ab8d24cdfb45e3d06dbfca2780-Abstract-mlsys2023.html

OpenTelemetry GenAI Semantic Conventions

OpenTelemetry Authors · 2026

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

OpenTelemetry GenAI metrics, September 21, 2026 revision

OpenTelemetry Authors · 2026

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

OpenTelemetry GenAI span definitions, September 21, 2026 revision

OpenTelemetry Authors · 2026

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

Trace Context

W3C Distributed Tracing Working Group · 2021

https://www.w3.org/TR/trace-context/

OpenTelemetry GenAI attribute registry, September 21, 2026 revision

OpenTelemetry Authors · 2026

https://github.com/open-telemetry/semantic-conventions-genai/blob/cc07f722069974139dab497d80d145144b19daca/model/gen-ai/registry.yaml

Handling sensitive data

OpenTelemetry Authors · 2026

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

Sampling

OpenTelemetry Authors · 2026

https://opentelemetry.io/docs/concepts/sampling/

OpenTelemetry Collector tail-sampling processor

OpenTelemetry Authors · 2026

https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/tailsamplingprocessor

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.