learn-ai-observability-with-phoebe / Builder session 9 of 10
Learn AI Observability with Phoebe · Builder track · Session 9 of 10

Drift and silent changes: the ground truth moved and nobody deployed

Every incident so far in this course had a cause inside your repository. This session is about the class of degradation with no deploy behind it: the traffic changed, the answers got worse, the provider swapped a model under a floating alias, or an embedding model changed and turned your retrieval into noise while every span stayed green. Four different drifts, four different signals, four different fixes, and one uncomfortable lesson about what "our evals are green" is worth.

🟠 Builder track AI engineers · platform engineers · technical PMs 45 min
0-3 · Welcome 3-20 · Four drifts, and the provider clock 20-40 · Build-along: the sampled scorer, then the version gate 40-45 · Q&A
Part 0

The only failure class with no commit to blame

Debugging assumes a change. You look at what shipped, you bisect, you revert. Drift breaks that reflex, because the system that got worse is byte-for-byte the system that was working, and the thing that moved is either outside your repository or outside your company. That is why drift is where teams lose the most time: they spend the first day looking for a deploy that does not exist.

Live - presented in session Self-study - read after class Official sources covered
★ What you walk out with today Four drift types with distinct signals and distinct fixes, the three concrete mechanisms by which a provider changes your system without your involvement, the difference between index staleness and embedding-space incompatibility (and why only one of them has a partial fix), a sampled online scorer wired to gen_ai.evaluation.result, and an embedding-version gate that refuses a query rather than answering it badly.
One sentence for this session Evals tell you whether it works. Observability tells you what actually happened. Drift is the case where both were true last month and only one of them is true today, so the useful artifact is not a score, it is a time series of a score, plotted next to a time series of your inputs.
Part 1 · the taxonomy

Four drifts, four signals, four fixes 7 min live

"Drift" gets used as one word for four unrelated problems, which is why drift conversations go nowhere. Separate them by the signal that reveals each one, because that is what decides what you have to build, and notice that only one of the four has a change you could point at in a changelog.

Four drifts, four different signals, four different fixes what moved where it shows up first what you actually do Input distribution the traffic changed nothing else did Embedding-centroid shift vs a rolling baseline. New-intent cluster share. Tokens per request moving with no code change. Retrieval null-rate. Cluster the nulls by query. That list is your content backlog, ranked for you. Output quality the answers changed the inputs did not Scorer on sampled traces. Refusal rate. Truncation rate. And downstream behaviour: retries, escalation to human, abandonment. Watch the downstream signal first. It is honest and it needs no scorer. Provider-side change somebody deployed and it was not you gen_ai.response.model not equal to the requested model. A step change in tokens per request at an unchanged posted price. Pin snapshots. Log both model fields. Diary the deprecation calendar. Embedding drift retrieval only, and it is two problems Index freshness as an SLI catches staleness. Version mismatch between the query embedding and the index embedding has no signal at all. Version the model in index metadata. Refuse mismatched queries. Only the third one has a deploy you can point at, and it is not in your repository.
🔍 Click to zoom - four unrelated problems that share one word
LiveThe case study: a vendor could not reproduce its own degradation5 min▶

The best available teaching artifact for output-quality drift is a primary-source postmortem in which a model provider documents how hard its own degradation was to detect. Three overlapping issues, reported by users, and the internal benchmarks stayed green.

  • Scale, from the postmortem itself: at peak, 16% of Sonnet 4 requests were affected in the worst hour, and approximately 30% of Claude Code users who made requests during that period had at least one message routed to the wrong server type.
  • Platform asymmetry: peak impact on Bedrock was 0.18% and on Vertex under 0.0004%. If you multi-home, one platform's telemetry does not represent the others, and the platform you happen to dashboard is the one whose numbers you will believe.
  • Why detection failed: the benchmarks "simply didn't capture the degradation users were reporting", partly because the model recovers from isolated errors, and because the bugs produced different symptoms on different platforms at different rates.

Take exactly two lessons. First: user reports are a signal. They are noisy, unstructured and frequently right. Second, and this is the hard one: "our evals are green" is not a rebuttal to a user report. It is a statement about a distribution you constructed, and drift is precisely the case where your constructed distribution has stopped resembling production traffic. When those two disagree, the honest next step is to sample real traces from the complaining segment, not to re-run the suite.

Self-studyDownstream behaviour: the signal that needs no scorer3 min read▶

Quality scorers are expensive to build, need calibration, and drift themselves. Meanwhile your users are already scoring every answer, in a currency you can measure without any judge at all:

  • Retry rate. The same conversation id asking a near-identical question within a minute. One person deciding the answer was useless, recorded as a fact rather than an opinion.
  • Escalation to human. The strongest signal available in any product with a support path, and usually already instrumented for a different reason.
  • Abandonment. A session that ends immediately after an answer, with no follow-up and no action taken on the result.

These are lagging by minutes rather than by a quarter, they cost nothing to compute, and they are honest in the way judge scores are not, because nobody had to define what "good" means. Their weakness is diagnosis: they tell you something got worse and nothing about what. So the sequence that works is: downstream behaviour alerts you, sampled traces explain it, and a scorer gives you the trend line afterwards. Teams that build in the opposite order spend a quarter on a judge before they have anything watching production.

Part 2 · the provider

Three ways a provider changes your system without you 7 min live

This is the drift type with the clearest mechanism and the widest blind spot, because most teams assume that not deploying means not changing. Three concrete mechanisms, all documented by the provider, all capable of moving your behaviour or your bill overnight.

1 · A floating alias is a promise about a name, not about a model claude-opus-5 floating alias: resolves to whatever that is today claude-sonnet-4-5-20250929 pinned snapshot: what you tested stays what you run 2 · Forced migration runs on a clock, whether or not you are ready Active Legacy Deprecated Retired The provider commits to at least 60 days' notice before a retirement date. Requests to models past the retirement date will fail. That is an outage on a calendar. 3 · Semantics change under a price that does not temperature, top_p and top_k are deprecated on Opus 4.7 and later, and return 400 if non-default. The newer tokenizer makes about 30% more tokens for the same text. Same price, bigger bill. Always log gen_ai.response.model next to gen_ai.request.model. One field, whole class caught.
🔍 Click to zoom - alias, calendar, and semantics: three changes with no commit of yours
The cheapest insurance in this course gen_ai.request.model is what you asked for. gen_ai.response.model is what answered. Both are Recommended attributes on the inference span, and the pair costs you one string per call. A non-zero mismatch rate is proof that a floating alias moved, that a gateway rewrote your request, or that a fallback path you forgot about is serving live traffic. Almost nobody emits both, and it is the single highest-value line of instrumentation on this page.
Self-studyWhat a pinned snapshot does not protect you from3 min read▶

Pinning is necessary and it is not sufficient, and it is worth being precise about the residue so you do not oversell it internally:

  • The clock still runs. A pinned snapshot has a lifecycle: Active, then Legacy, then Deprecated, then Retired. The commitment is at least 60 days' notice, and requests to a model past its retirement date fail. Pinning converts a silent quality change into a dated hard failure, which is a large improvement and not an exemption.
  • Serving-side variation persists. Identical kernels are run-to-run deterministic but not batch-invariant, and server batch size varies with other users' load. The other concurrent users are not an input to your system but they are a nondeterministic property of it, which is why 1000 identical prompts at temperature 0 produced 80 distinct completions.
  • Your reproducibility levers may be gone. On Opus 4.7 and later, temperature, top_p and top_k are deprecated and return 400 on non-default values, so "just set temperature to 0 and replay it" is not available as a strategy.

Which is the whole argument of this course restated: because you cannot reproduce a production trace by replaying it, you must capture enough of it at the time it happens.

Part 3 · retrieval

Embedding drift is two problems, and only one has a partial fix 6 min live

People say "our embeddings drifted" to mean two things that share nothing except the word. One is a freshness problem with an obvious SLI. The other is a compatibility problem with no signal whatsoever, and it is the purest coverage failure in this course: retrieval degrades towards random while every single span reports OK.

TWO PROBLEMS, ONE WORD Index staleness The document changed. Its embedding did not. Retrieval keeps returning last quarter's paragraph, confidently, with a high score. SIGNAL Index freshness as an SLI: age of the oldest embedding against the document mtime. Fix: re-embed on write. Partial is fine here. Embedding-space incompatibility You changed the embedding model. Vectors from two different models are not comparable, so the similarity scores are close to meaningless. SIGNAL None. Nothing errors. Retrieval returns k documents with plausible scores, as always. Fix: full corpus re-embedding. No partial exists. The operational rule: version the embedding model in the index metadata, and refuse any query whose query-embedding version does not match the index. Refusing is the feature: a mismatch that answers anyway is a green trace wrapped around noise.
🔍 Click to zoom - staleness has an SLI, incompatibility has no signal at all
Honest gap, stated plainly No primary vendor sentence says "you must re-embed your whole corpus when you change embedding model". That claim is mathematical necessity plus universal practice, not a quotable line from a specification, and you should present it that way in your own design reviews. What follows from the mathematics is not in dispute: a cosine similarity between vectors produced by two different models is not a measure of anything, so a mixed index is not a degraded index, it is a broken one that keeps working.
Self-studyEvals entering the telemetry spec: gen_ai.evaluation.result3 min read▶

Until recently, eval scores lived in one system and traces lived in another, and joining them was somebody's weekend project. That is changing: the GenAI semantic conventions now define an evaluation signal, which means a score can ride the same pipeline as the span it describes.

  • The event is gen_ai.evaluation.result, carrying gen_ai.evaluation.name, gen_ai.evaluation.score.value, gen_ai.evaluation.score.label and gen_ai.evaluation.explanation.
  • The practical consequence is the one that matters for this session: a drifting score is now a time series sitting next to the trace that produced it, so "quality dropped" and "here are forty traces from the drop" become the same query instead of two systems and a spreadsheet.
  • The usual caveat applies with full force. Like everything else in these conventions it is Development status with no tagged release, so check the current spelling in the repository before you build a dashboard on it.

OpenInference reaches the same place from the other direction, with EVALUATOR as one of its ten span kinds. Different dialect, same convergence: the evaluator is becoming part of the trace rather than a separate report.

Demo 1 of 2

Wire a sampled online scorer on Beacon traffic ★ 11 min · everyone builds

Beacon's Insight Bot now has coverage, cost attribution and alerts. What it does not have is any way to notice that answers got worse this month. Build the smallest thing that would: an asynchronous scorer on a sample of production traces, emitting a score back onto the trace.

Pick the sample rate and write down why. Prevailing vendor guidance is 1-10% of production traces, commonly 5-10%, scored asynchronously and filtered towards high-value traces. Label that in your design doc as [S] prevailing vendor guidance, not an empirical finding: there is no peer-reviewed or survey source establishing what teams actually use or what is sufficient. You are choosing a starting point, not applying a standard.

Make the sample representative or admit that it is not. A sample skewed towards cheap fast traces will miss exactly the twelve-step runs where quality dies. Stratify by the segments you already alert on in b8: model, cache-hit state, step bucket, and tenant tier. An unstratified 5% of Beacon traffic is roughly 5% of the easy questions.

Score off the request path, always. Read sampled traces from storage, score them, and emit gen_ai.evaluation.result with gen_ai.evaluation.name and gen_ai.evaluation.score.value. A judge in the hot path adds latency and a second failure mode to the thing you were trying to measure.

Calibrate the judge against humans before you alert on it. Have two people label 100 sampled traces, compare with the judge, and record the agreement. Skip this and you are not monitoring your system, you are monitoring the judge, and when the judge drifts you will go looking for a deploy in the wrong repository.

Now plot the score next to three things that need no judge at all: retry rate, escalation-to-human rate and tokens per request. When the honest signals and the scorer disagree, believe the honest signals and go read traces.

★ Sampled scorer, off the request path # 1-10% of traces (commonly 5-10%) - [S] prevailing vendor guidance, not evidence for trace in sample_traces(rate=0.05, stratify_by=["model", "cache_state", "step_bucket"]): verdict = judge(trace.question, trace.answer, trace.retrieved_doc_ids) with tracer.start_as_current_span("evaluate", links=[link_to(trace)]) as span: span.add_event("gen_ai.evaluation.result", { "gen_ai.evaluation.name": "answer_grounded_in_retrieval", "gen_ai.evaluation.score.value": verdict.score, # 0.0 - 1.0 "gen_ai.evaluation.score.label": verdict.label, # pass / fail "gen_ai.evaluation.explanation": verdict.reason, }) # ticket on drift, never a page: the sample is small and the judge drifts too
Two caveats to put in the design doc, not in the retro A sample only works if it is representative, and a judge score only means something after human calibration. Both failures produce the same symptom: a confident quality time series that has stopped describing your users. Write both caveats next to the number in the doc, so the next person to read the dashboard knows what it is and is not.
Demo 2 of 2

Two guards: the model pin, and the embedding version gate ★ 11 min · everyone builds

Beacon's retrieval index was built eighteen months ago and nobody remembers which embedding model produced it. That sentence is a production incident waiting for a migration ticket. Build the two guards that turn both silent-change classes into loud, dated failures.

Replace every floating alias in configuration with a pinned snapshot, and put the pin in the same file as the eval results that justified it. A pin without a recorded reason gets removed by the next person who wants a newer model.

Emit both model fields on every inference span and add the b8 rule: alert when gen_ai.response.model differs from gen_ai.request.model on more than zero requests. Then check the deprecation calendar for every pinned snapshot you own and put the retirement dates in a shared calendar, because at least 60 days' notice is only useful to someone who is reading.

Write the embedding model id and version into the index metadata, not into a README. Index metadata travels with the index through backups, restores and migrations. A README does not.

At query time, compare the query-embedding version with the index version and refuse on mismatch. Return an error and set span status, do not degrade quietly. This is the one place in the whole course where refusing to answer is unambiguously the right behaviour, because an answer built on a mismatched index is a confident answer built on noise.

Add index freshness as an SLI: the age of the oldest embedding relative to its document's modified time. Staleness is the drift that has a partial fix, so give it the metric that lets you fix it incrementally.

★ The gate that refuses rather than degrades INDEX_EMBED = index.metadata["embedding_model"] # e.g. "<model-id>@2025-01" def retrieve(question: str, k: int = 8): qv, qv_model = embed(question) if qv_model != INDEX_EMBED: span = trace.get_current_span() span.set_status(Status(StatusCode.ERROR)) span.set_attribute("retrieval.embedding_version_mismatch", True) raise EmbeddingVersionMismatch(f"query={qv_model} index={INDEX_EMBED}") return index.search(qv, k=k) # doc ids AND scores go on the span
Why the loud failure is the cheap option A mismatched index does not error, does not slow down and does not empty out. It returns k documents with plausible-looking scores, the model summarises them fluently, and every span in the trace is green. There is no percentile, no error rate and no eval score that reliably catches it, because the failure lives in a comparison that was never valid in the first place. Six lines of version check convert an unbounded, unobservable quality problem into a stack trace with a date on it.
Homework

Try it yourself - this week ◐ 50-60 min total

Source material

Official sources covered

Primary sources are marked [P] and secondary [S]. Two claims on this page are deliberately not presented as sourced facts: the sampling cadence, and the re-embedding requirement. Both are flagged below and in the body.

✓[P] Anthropic postmortem of three issues: 16% of Sonnet 4 requests at peak, about 30% of Claude Code users affected, Bedrock 0.18%, Vertex under 0.0004%Part 1 · benchmarks did not capture what users reported
✓[P] Floating aliases versus pinned snapshots, and the Active to Legacy to Deprecated to Retired lifecycle with at least 60 days' noticePart 2 · requests past the retirement date will fail
✓[P] temperature, top_p and top_k deprecated on Opus 4.7 and later, returning 400 on non-default valuesPart 2 · reproducibility is not available as a lever
✓[P] The tokenizer change: approximately 30% more tokens for the same textPart 2 · semantics moving under an unchanged price
✓[P] Non-determinism: 1000 completions at temperature 0 gave 80 unique outputs; kernels are deterministic but not batch-invariantPart 2 self-study · why capture beats replay
✓[P] gen_ai.request.model and gen_ai.response.model as Recommended attributes on the inference spanPart 2 · the mismatch rule, and Demo 2
✓[P] gen_ai.evaluation.result with .name, .score.value, .score.label and .explanation; OpenInference's EVALUATOR span kindPart 3 · evals entering the telemetry spec. All Development status
◐[S] Sampling cadence of 1-10% of traces, commonly 5-10%, scored asynchronouslyDemo 1 · prevailing vendor guidance, explicitly not an empirical finding. No peer-reviewed or survey source establishes what teams use or what suffices
◐Full corpus re-embedding on an embedding-model changePart 3 · honest gap. No primary vendor sentence states it. Mathematical necessity plus universal practice
◐Refusal rate, truncation signals and the burn-rate rules that carry these driftsReferenced from b6 and b8 rather than re-taught
·Beacon's eighteen-month-old index and its missing embedding metadataConstructed case, consistent with the course's running incident. Not a real system
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · Users in one segment report that answers got worse this week. Your eval suite is green and nothing shipped. What is the correct next move?

A model provider documented this failure about itself: benchmarks did not capture the degradation users were reporting, partly because the model recovers from isolated errors, and because symptoms differed by platform and rate. User reports are a signal. "Our evals are green" is not a rebuttal to one, it is a statement about a set of examples you chose before the drift happened. Go and read the traces from the complaining segment.

2 · Someone swaps the embedding model for new document ingestion but does not re-embed the existing corpus. What does your telemetry show?

This is the purest coverage failure in the course. Similarity between vectors from two different models is not a measure of anything, but the arithmetic still completes and still returns a ranked list, so there is no error, no latency change and no empty result to alert on. That is why the fix is structural rather than observational: version the embedding model in the index metadata and refuse any query whose embedding version does not match. Refusing loudly is the feature.

3 · You are asked what sampling rate to score production traces at. What is the honest answer?

The range is real and it is worth using as a starting point, but its provenance is vendor documentation, not evidence. Saying so costs you nothing and buys you the right to change the number when your own data justifies it. Two conditions matter far more than the percentage: the sample has to be representative, so stratify it by the segments you already alert on, and the judge has to be calibrated against humans before you put a threshold on its output, or you are monitoring the judge's drift rather than your system's.

Builder session 9 cheat sheet · pin this

Four drifts, not oneInput distribution, output quality, provider-side change, embedding drift. Different signals, different fixes.
Retrieval null-rate by clusterWhere a new uncovered topic appears first. Cluster the nulls and you have a ranked content backlog.
Downstream behaviour firstRetry rate, escalation to human, abandonment. The earliest honest quality signal, and it needs no scorer.
"Evals are green" is not a rebuttalA vendor's own postmortem: users reported degradation, standard evals did not reproduce it.
Pin snapshots, log both modelsAn alias means "whatever that is today". Always emit gen_ai.response.model next to the requested model.
The clock is a dependencyActive, Legacy, Deprecated, Retired. At least 60 days' notice, and past retirement the request fails.
Staleness vs incompatibilityStaleness has an SLI and a partial fix. Incompatibility has no signal and needs full re-embedding.
Sample 1-10%, and say whyPrevailing vendor guidance, not evidence. Stratify it, and calibrate the judge before you alert on it.