Observability: Putting a Flight Recorder on Every Run
Offline evaluation only covers the tasks you thought of; production is where the real users are. Today you instrument the target against the open generative AI semantic conventions, turn one run into a queryable span tree, aggregate cost and latency, and report the evaluation scores themselves as telemetry.
Today's Goals
- Instrument a model call and a tool call against the open semantic conventions, and explain why the convention version has to be pinned in your own code
- Aggregate cost, latency and failure rate per task from span data, and say which field time-to-first-chunk belongs in
- Report evaluation scores as telemetry attributes, and explain how that stitches offline evaluation together with production monitoring
Plain-Language Walkthrough
Timestamps on every leg of a flight
You fly from Shanghai to Chengdu with a connection in Xi'an. The itinerary does not say "Shanghai to Chengdu, eight hours." It says it as timestamped legs: check-in, first flight out and in, the wait at the connection, second flight out and in, bags on the belt.
Those legs have three properties, and they map onto everything in today's material.
First, each leg has its own start and end. When the trip runs eight hours long, you can point at the leg that ate the time — a delay on the first flight, or three hours in the connection queue.
Second, the legs nest rather than sit side by side. Boarding the second flight belongs inside that flight's leg. Nesting is what lets you say how long "the connection" took as a whole, instead of stitching a flat pile of events back together by timestamp.
Third, each leg carries attributes. Flight number, aircraft type, gate. Attributes exist for aggregation: the average taxi time for that aircraft type at that airport is answerable only because of them.
After four days you already have a transcript — the complete record of one trial. Today's job is to turn a run into a timestamped, nested, attributed itinerary, written with field names other people already recognize.
Three words: trace, span, attribute
Three terms. Pin them down now.
| Word | What it is | Itinerary equivalent |
|---|---|---|
| trace | One complete run | The whole itinerary |
| span | One segment of the run, with a start and an end | One leg |
| attribute | A key-value pair attached to a span | Flight number, aircraft type |
What does a span actually look like? Strip the packaging and it is a struct:
{
"traceId": "t0000000000000000000000000000001",
"spanId": "s000000000000003",
"parentSpanId": "s000000000000002",
"name": "chat mock-model",
"startUnixNano": 1767000000000000000,
"endUnixNano": 1767000000252000000,
"attributes": {
"gen_ai.operation.name": "chat",
"gen_ai.provider.name": "openai-compatible-gateway",
"gen_ai.request.model": "mock-model",
"gen_ai.usage.input_tokens": 339,
"gen_ai.usage.output_tokens": 126
},
"status": "ok"
}That is all of it. The recorder in today's lab is a few dozen lines: build structs of this shape, wire parentSpanId correctly, append one per line to a file.
Production uses the official SDK, which handles context propagation, batched export, retries and sampling. But once you have seen what a single span really is, the SDK's configuration options start meaning something — which is why today installs no instrumentation SDK at all.
Why use an open convention instead of naming your own fields
Nothing stops you from recording the input token count as prompt_tokens, or in_tok. It is your system. So why type out something as long as gen_ai.usage.input_tokens? Three reasons, increasing in importance.
First, changing backends does not mean re-instrumenting. Backends get swapped often. With an open convention a migration changes an export endpoint; with homegrown names it changes every instrumentation site plus every dashboard query.
Second, off-the-shelf dashboards just work. Backends ship built-in views against this convention — spend by model, error rate by tool, the trace for one conversation — and matching field names is all it takes to get them.
Third, the one people overlook: it is a checklist somebody already thought through for you. Left to yourself you would record model, duration and token count, and stop. The convention also has cached input tokens, reasoning tokens, finish reasons, tool call ids. Skipping them raises no error, but the day someone asks how much caching saved last month, data you never recorded is data you cannot go back for. Telemetry gaps are never backfillable.
The real status of this convention: it moved, it is pre-1.0, and the pages are covered in deprecation notices
Read this section word by word: much of what is written online gets these points backwards, and copying it leads to the opposite conclusion.
One: the repository moved. As of v1.42.0 in June 2026, everything beginning with gen_ai. moved out of the main semantic-conventions repository into a standalone one, open-telemetry/semantic-conventions-genai, Apache-2.0, which has not cut a single release tag to date.
Two, the part most often misread: on the main repository's attribute registry page, every gen_ai. attribute now carries a red "deprecated, moved to the GenAI repository" marker.
Three: it is still not stable. As of July 2026 no generative AI span, attribute or metric is marked Stable; everything sits at Development. There is no 1.0, and names can still change.
So do you use it? Yes — with the version pinned.
The constants file looks like this:
// The pinned convention version. 1.42.0 is the last main-repo release that
// carried gen_ai; after it the source of truth is the standalone repository,
// which has no tag yet, so all we can pin to is a date.
export const SEMCONV_VERSION = '1.42.0'
export const GENAI_REPO_REF = 'semantic-conventions-genai@2026-07'
export const SEMCONV_STABILITY = 'development'
// Attribute names copied verbatim from the convention. Do not invent your own.
// The old name gen_ai.system is deprecated; it is gen_ai.provider.name now.
export const GEN_AI = {
OPERATION_NAME: 'gen_ai.operation.name',
PROVIDER_NAME: 'gen_ai.provider.name',
REQUEST_MODEL: 'gen_ai.request.model',
USAGE_INPUT_TOKENS: 'gen_ai.usage.input_tokens',
USAGE_OUTPUT_TOKENS: 'gen_ai.usage.output_tokens',
TOOL_NAME: 'gen_ai.tool.name',
}
// All nine legal operation names. Inventing a tenth is the same as recording
// nothing: backends facet on the enum, and unknown values fall into an "other"
// bucket that never makes it onto a chart.
export const OPERATIONS = [
'chat',
'create_agent',
'embeddings',
'execute_tool',
'generate_content',
'invoke_agent',
'invoke_workflow',
'retrieval',
'text_completion',
]# The pinned convention version. 1.42.0 is the last main-repo release that
# carried gen_ai; after it the source of truth is the standalone repository,
# which has no tag yet, so all we can pin to is a date.
SEMCONV_VERSION = "1.42.0"
GENAI_REPO_REF = "semantic-conventions-genai@2026-07"
SEMCONV_STABILITY = "development"
# Attribute names copied verbatim from the convention. Do not invent your own.
# The old name gen_ai.system is deprecated; it is gen_ai.provider.name now.
GEN_AI = {
"OPERATION_NAME": "gen_ai.operation.name",
"PROVIDER_NAME": "gen_ai.provider.name",
"REQUEST_MODEL": "gen_ai.request.model",
"USAGE_INPUT_TOKENS": "gen_ai.usage.input_tokens",
"USAGE_OUTPUT_TOKENS": "gen_ai.usage.output_tokens",
"TOOL_NAME": "gen_ai.tool.name",
}
# All nine legal operation names. Inventing a tenth is the same as recording
# nothing: backends facet on the enum, and unknown values fall into an "other"
# bucket that never makes it onto a chart.
OPERATIONS = [
"chat",
"create_agent",
"embeddings",
"execute_tool",
"generate_content",
"invoke_agent",
"invoke_workflow",
"retrieval",
"text_completion",
]Which attributes to record
The convention has many attributes. Today's lab uses this set, grouped by purpose:
| Purpose | Attribute |
|---|---|
| What this segment is doing | gen_ai.operation.name (one of the nine values only) |
| Who is serving it | gen_ai.provider.name (the old gen_ai.system is deprecated) |
| Request parameters | gen_ai.request.model, gen_ai.request.temperature, gen_ai.request.max_tokens |
| Response information | gen_ai.response.model, gen_ai.response.id, gen_ai.response.finish_reasons |
| What it cost | gen_ai.usage.input_tokens, gen_ai.usage.output_tokens |
| Caching and reasoning | gen_ai.usage.cache_read.input_tokens, gen_ai.usage.cache_creation.input_tokens, gen_ai.usage.reasoning.output_tokens |
| Tying one conversation together | gen_ai.conversation.id |
| Tool calls | gen_ai.tool.name, gen_ai.tool.type, gen_ai.tool.call.id, gen_ai.tool.call.arguments, gen_ai.tool.call.result |
| Message content | gen_ai.input.messages, gen_ai.output.messages |
| Streaming time to first chunk | gen_ai.response.time_to_first_chunk (the unit is seconds) |
The last one deserves its own sentence. Time to first chunk cannot be derived from total duration. It is stamped at the instant the first streamed chunk arrives, and for a streaming interface it is the number the user perceives as speed: ten seconds total with text appearing after half a second beats five seconds withheld until the end.
Today's offline target does not stream, so the field is unavailable. The dashboard says "not collected" rather than filling in a zero: a fabricated number is far more dangerous than an empty cell. An empty cell sends someone to add instrumentation; a fake number sends someone to optimize a problem that does not exist.
The trace for one run: three levels
One instrumented trial produces this tree:
· invoke_agent refund-agent (offline) 253ms
· chat mock-model 252ms
· execute_tool lookup_order 20ms
· execute_tool check_policy 22ms
· execute_tool issue_refund 61msThree levels: the agent invocation, the model turn inside it, and inside that the tools this turn decided to call.
Why are the tool spans children of the chat span rather than siblings at the top level? Because the tools are what this model turn decided to call, and only that nesting attributes tool time to a turn. A multi-turn agent has several chat spans, and once everything is flat you can no longer say which turn issued the third tool call.
One more rule: instrument in a wrapper, do not modify the thing being instrumented.
The target and the runner in today's lab are frozen files. That is not an artificial constraint but a reconstruction of the real situation — the agent in production probably was not written by you, or is already running, and "refactor it first, then instrument" is not one of your options.
// Wrap a target and return a new one that behaves identically but emits spans.
export function wrap(inner, tracer, options) {
return {
name: inner.name,
async run(task, world) {
const root = tracer.start('invoke_agent', 'invoke_agent', undefined, {
'gen_ai.operation.name': 'invoke_agent',
'gen_ai.provider.name': options.providerName,
'gen_ai.request.model': options.model,
// Your own attributes go in your own namespace. Never smuggle them into gen_ai.
'eval.task.id': task.id,
})
const transcript = await inner.run(task, world)
emitModelTurn(tracer, root, transcript, options)
tracer.end(root, transcript.durationMs)
// The return value is unchanged: instrumentation must not alter behavior.
return transcript
},
}
}# Wrap a target and return a new one that behaves identically but emits spans.
def wrap(inner, tracer, options):
class Wrapped:
name = inner.name
async def run(self, task, world):
root = tracer.start(
"invoke_agent",
"invoke_agent",
None,
{
"gen_ai.operation.name": "invoke_agent",
"gen_ai.provider.name": options["provider_name"],
"gen_ai.request.model": options["model"],
# Your own attributes go in your own namespace, never inside gen_ai.
"eval.task.id": task["id"],
},
)
transcript = await inner.run(task, world)
emit_model_turn(tracer, root, transcript, options)
tracer.end(root, transcript["duration_ms"])
# The return value is unchanged: instrumentation must not alter behavior.
return transcript
return Wrapped()That last comment is a hard constraint: instrumentation must not change the behavior of what it instruments, or what you measured is not what is running in production. One self-test assertion exists purely for this: with and without the wrapper, the same seed must produce a byte-identical transcript.
From spans to a dashboard
Once you have spans, a dashboard is a group-by and a sum. One rule comes first: every number on the dashboard may come only from spans.
That sounds like a truism and is remarkably easy to violate. Today's runner already tracks total cost; printing that directly is faster and more accurate, so why not? Because production has no runner, only a pile of spans. If the dashboard quietly reads a variable in memory, a missing instrumentation field is undetectable locally — you find out after release, looking at a blank column.
// Cost of one chat span: read tokens from attributes only, convert at unit price.
// Keep two decimal places of a cent so totals can be reconciled.
export function spanCostCents(span, price) {
if (span.attributes['gen_ai.operation.name'] !== 'chat') return 0
const input = span.attributes['gen_ai.usage.input_tokens'] ?? 0
const output = span.attributes['gen_ai.usage.output_tokens'] ?? 0
const cents = (input / 1000) * price.input + (output / 1000) * price.output
return Math.round(cents * 100) / 100
}
// Failure rate comes from the evaluation label, not from an HTTP status code.
// A trial that returns 200 OK while refunding the wrong order looks perfect
// from the status code.
export function failureRate(rootSpans) {
if (rootSpans.length === 0) return 0
const failed = rootSpans.filter(
(s) => s.attributes['gen_ai.evaluation.score.label'] === 'fail'
)
return failed.length / rootSpans.length
}# Cost of one chat span: read tokens from attributes only, convert at unit price.
# Keep two decimal places of a cent so totals can be reconciled.
def span_cost_cents(span, price):
if span["attributes"].get("gen_ai.operation.name") != "chat":
return 0
input_tokens = span["attributes"].get("gen_ai.usage.input_tokens", 0)
output_tokens = span["attributes"].get("gen_ai.usage.output_tokens", 0)
cents = input_tokens / 1000 * price["input"] + output_tokens / 1000 * price["output"]
return round(cents, 2)
# Failure rate comes from the evaluation label, not from an HTTP status code.
# A trial that returns 200 OK while refunding the wrong order looks perfect
# from the status code.
def failure_rate(root_spans):
if not root_spans:
return 0
failed = [
s for s in root_spans
if s["attributes"].get("gen_ai.evaluation.score.label") == "fail"
]
return len(failed) / len(root_spans)That price table hides a debt worth admitting out loud: unit prices are hardcoded, so the day a vendor changes them your historical cost dashboard is rewritten retroactively. The solid approach is to compute cost at the price in force at the time and persist it alongside the span. Today simplifies to avoid a second configuration file, but this is a real problem, not a detail to pretend away.
Evaluation results are telemetry too
So far today has only been "put monitoring on an agent," separate from the four days of evaluation. Now stitch them together. The convention already defines four attributes for this:
| Attribute | What it records |
|---|---|
gen_ai.evaluation.name | Which grader |
gen_ai.evaluation.score.value | The score |
gen_ai.evaluation.score.label | Pass or fail |
gen_ai.evaluation.explanation | Why |
Which means an evaluation result is itself first-class telemetry, not "data from another system." The scores computed over the past four days attach to the trace as attributes directly.
One boundary: not every production request is worth a model judge, that is far too expensive. Tier it — anything code can decide (does the outcome match, is the format valid) runs on everything, the model judge runs on a sample.
Sampling: never drop a failure
Recording every trace in production is expensive, in storage and in backend write volume. So you sample.
The laziest approach is head sampling: roll the dice when the request arrives. The price is fatal — a failure on one percent of traffic has a 99 percent chance of being thrown away before it goes wrong. The samples you most need are precisely the ones head sampling discards.
The right approach is tail sampling: wait until the trace finishes and the outcome is known, then decide:
- Failures, keep every single one
- Slow ones (past p99, say), keep all of them
- Successful and not slow, keep a small fixed fraction as a baseline
With the evaluation attributes above, the "failed" predicate is trivial — read gen_ai.evaluation.score.label. Another place where offline and production are sewn together.
One implementation detail: base the keep-or-drop decision on a hash of the traceId, not a random number. A hash is stable, so every node reaches the same conclusion for the same trace. A random number keeps some of a trace's spans and drops the rest, and what you reassemble is a mutilated tree — worse than nothing.
Source Reading
Read today's three sources in this order.
The OpenTelemetry tracing concepts documentation goes first, and it has nothing to do with generative AI: what a trace is, what a span is, how context propagates, where sampling happens. This machinery has run in backend systems for years; generative AI only adds field conventions on top. If you have never worked with distributed tracing, this is the one required reading.
The generative AI semantic conventions pages are today's source of truth for field names. Notice two things. First, every attribute is marked Development and none is Stable; that is the current state, not a typo. Second, the "deprecated, moved to the GenAI repository" markers mean the page moved, not that the attributes were retired.
The standalone repository comes last, for two things: it still has no release tag, and its issue tracker shows what is still being argued about. The second has direct value — fields under discussion are the ones to be ready to rename.
Hands-On Lab
Three modules — the attribute-name constants table, a minimal trace recorder, the aggregation dashboard — plus the layer that wraps instrumentation around the target. Four exercises. When it runs, the dashboard looks like this:
-- cost and latency (aggregated from spans only, never from runner memory) --
task trials fail% cost(c) p50(ms) p95(ms) tool calls
ev-002-expired-order 5 20.0% 1.22 278 323 11
ev-001-fresh-order 5 0.0% 1.28 256 361 13
ev-003-already-refunded 5 0.0% 1.27 302 333 12
ev-004-nagging-user 5 0.0% 2.18 439 473 29
traces: 20 total cost: 5.95 cents
time-to-first-chunk coverage: 0/20 mean: not collectedStare at the ev-004-nagging-user row.
Its failure rate is zero, as good as the first row. But its tool call count is 29 where everyone else is 11 to 13, its cost is 2.18 cents against roughly 1.2, and its latency is sixty percent higher.
This task triggers defect three in the target: when the user says "check again," it looks the order up over and over. The outcome is entirely correct, so four days of evaluation report nothing. Only the dashboard sees it, because what it wastes is money and time.
That is the value of today's machinery: it catches "right but expensive" and "right but slow," the two failures an evaluation score can never see.
The lab also ships a toy collector written with node:http on port 3145, which queries a trace back out by traceId. It is far from a real collector — no batching, retries, redaction or multi-tenancy — but it preserves the essential property: spans come back as a tree.
Interview Questions
Today's four questions circle three things: why an open convention, how to depend on a standard that is still changing, and how to design sampling so it keeps the failures.
The second is worth preparing carefully. "A standard still in development, whose names may change — would you adopt it now?" That is not about the standard, it is about your engineering habits around uncertain dependencies. "Wait until it stabilizes" gives up three benefits already available; "use it, fix it later" reveals you never costed the fixing. The answer is in between: use it, confine the blast radius to one file, and pin the version into the data itself.
Checklist and Tomorrow
By the end of today you should be able to:
- Define trace, span and attribute, and draw the three-level tree of one agent run by hand
- Give two of the three reasons for an open convention over homegrown field names
- Explain what the deprecation markers on the main repository page mean, and why they are not a reason to delete instrumentation
- Name two benefits of pinning the convention version into a constants file
- Say which field time to first chunk belongs in, its unit, and why it cannot be derived from total duration
- Explain how offline and production connect once evaluation scores go out as telemetry attributes
- State the fatal problem with head sampling, and why tail sampling hashes instead of rolling dice
- Get all ten assertions green with
MOCK=1 pnpm selftest - Find the "all correct but twice the cost" task on the dashboard, and explain why the evaluation score cannot see it
Tomorrow is D6, Regression Gates: Catching Degradation Before the Merge. Everything built today and on the previous four days — scores, transcripts, traces, dashboards — is still only there for a human to look at. Tomorrow turns it into a gate that blocks people: how to store a baseline snapshot, how much of a drop counts as a regression, how to separate noise from real degradation, and the unavoidable question of what criterion a gate can use on a system that jitters by nature.
Interview questions
Why instrument an agent with a public semantic convention instead of a field-name scheme your team invents?为什么给 Agent 埋点要用公开的语义约定,而不是团队自己定一套字段名?
Common in ChinaCommon overseasBasic#observability#opentelemetry#semantic-conventionsHow to reason about it · think before answering
- This tests whether you have ever actually migrated an observability backend. 'For standardization' is an empty answer; give three reasons that map to concrete cost.
- First, migration cost. Backends get swapped often - self-hosted to commercial, one vendor to another, or two running side by side for comparison. With a public convention you change one export endpoint; with private field names you change every instrumentation site plus every dashboard query. At scale that difference is an order of magnitude.
- Second, ready-made views. Backends ship built-in panels keyed on these fields: spend by model, error rate by tool, traces by conversation. Match the names and the charts exist for free; miss them and you rebuild each one, and new teammates cannot read your private schema.
- Third, and most overlooked: the convention is a checklist someone already thought through for you. Left to yourself you record model, latency and token counts. The convention also has cache-read tokens, cache-creation tokens, reasoning tokens, finish reasons, tool call ids. Omitting them raises no error, but when someone asks how much caching saved last month, the history simply is not there - telemetry gaps cannot be backfilled.
- State the boundary too: not everything belongs in the public namespace. Your own dimensions - task id, tenant, experiment arm - go under your own prefix. Do not smuggle private fields under gen_ai, or you will not be able to tell yours from theirs when the convention moves.
- Expected follow-up: what if the field you need is not in the convention? Check carefully that it really is absent, put it in your own namespace, and watch upstream discussions - renaming once after it lands is cheaper than inventing your own forever.
分析过程 · 先想清楚再作答
- 这题考的是「有没有真的换过可观测后端」。只答「为了标准化」是一句空话,要给出三条能落到具体成本的理由。
- 第一条是换后端的成本。可观测后端是换起来很频繁的东西——自建换商业的、商业的换一家、或者双跑做对比。用公开约定时,换后端改的是导出地址一处;用自定字段时,改的是每一处埋点,外加每一张面板的查询语句。这个差别在系统大起来之后是数量级的。
- 第二条是现成视图。各家后端都按这套约定做了开箱即用的面板:按模型看花费、按工具看错误率、按会话串链路。字段名对上了这些图不用配就有;对不上就得一张张自己拼,而且新同事看不懂你那套私有字段。
- 第三条最容易被忽略,也是最值钱的:**约定本身是一份别人替你想好的清单**。自己定字段多半只记模型、耗时、token 数三样;约定里还有缓存命中的 token 数、推理 token 数、完成原因、工具调用 id。这些不记不会报错,但等到要回答「上个月缓存省了多少钱」的时候,历史数据里没有就是永远没有了——**遥测的坑是补不回来的**。
- 反过来也要说清边界:不是所有字段都塞进公开命名空间。业务自己的维度(任务 id、租户、实验分组)应该放在自己的命名空间里,不要往 gen_ai 前缀底下塞私货,否则升级约定时你分不清哪些是自己的、哪些是人家的。
- 可预期的追问是「那约定里没有你要的字段怎么办」。答案是先查一遍确认真的没有,然后放进自己的命名空间,并留意上游有没有在讨论同名字段——真加进约定之后做一次改名,比一直自造要划算。
Key points
- Swapping backends changes one export endpoint instead of every instrumentation site and dashboard query.
- Vendor-provided default views work out of the box instead of being rebuilt chart by chart.
- The convention is a ready-made field checklist covering cache tokens, reasoning tokens and finish reasons you would not think of.
- Telemetry gaps are unrecoverable: a field you did not record cannot be reconstructed later.
- Keep business dimensions under your own namespace rather than inside the public prefix.
答题要点
- 换后端时只改导出地址,不用改每一处埋点和每一张面板查询。
- 各家后端的开箱即用视图直接可用,不必逐张自己拼图。
- 约定是一份现成的字段清单,缓存 token、推理 token、完成原因这些自己多半想不到。
- 遥测的坑补不回来:当时没记的字段,事后无法从历史数据里恢复。
- 业务自有维度放进自己的命名空间,不要塞进公开前缀底下。
Would you adopt a standard that is still in development and whose attribute names may change? How do you control the upgrade risk?一个仍在开发中、属性名还会改的标准,你会现在就用吗?怎么控制升级风险?
Common in ChinaCommon overseasDeep dive#observability#opentelemetry#dependency-riskHow to reason about it · think before answering
- On the surface this is about a standard; really it is about how you handle an unstable dependency. Both extremes score poorly: 'wait for stable' forfeits benefits available today, while 'just adopt and fix later' shows you never priced the fix.
- Get the facts right first, which alone separates candidates: nothing in the GenAI semantic conventions is marked Stable - everything is Development, there is no 1.0 - and the conventions have moved out of the main semantic-conventions repository into a dedicated one that has not cut a single release tag yet.
- Add the most misread detail: the registry pages in the main repository now mark every one of those attributes as deprecated and moved. That is a page relocation, not a deprecation of the attributes. Deleting instrumentation because of that red text has been a common mistake this year; pointing it out shows you read the primary source.
- Then give the plan, whose core is shrinking the blast radius: every attribute name appears exactly once, in one constants file, and everywhere else imports it. Add a guard asserting that every emitted name comes from that table, so nobody hand-writes a string - a hand-written typo raises no error, it just silently drops a column from the dashboard.
- Second, pin the version into the data itself: freeze the convention version in the constants file and emit it as an attribute on every trace. A year later you can tell which revision a batch of data was recorded under instead of guessing.
- Finally, the upgrade move: on a rename, dual-write both names for a transition window, cut dashboards and alerts over to the new one, then drop the old. Expected follow-up - should the rename live in the data pipeline instead? It can, but that moves the debt into the pipeline; dual-writing with a stated removal date is cleaner.
分析过程 · 先想清楚再作答
- 这题问的表面是标准,实际是**你处理不确定依赖的工程习惯**。两个极端答案都拿不到分:「等它稳定了再说」会白白丢掉现在就能拿到的好处,「用,有问题再改」则暴露你没算过改的成本。
- 先把事实说准,这一步就能拉开差距:生成式 AI 的那套语义约定至今没有任何条目标记为 Stable,全部处于 Development,没有 1.0;而且它已经从主语义约定仓库搬进了一个独立仓库,那个仓库到现在一个发布 tag 都没打过。
- 还要补一句最容易被误读的现状:主仓库的属性登记页上,每一条相关属性现在都带着「已弃用,已移至新仓库」的标记。**那是页面搬家,不是属性被废弃。** 看到红字就把埋点删掉是这一年最常见的误操作,能主动指出这一点,说明你看的是一手页面而不是二手文章。
- 然后给方案,核心是**把改动面收敛**:全部属性名只在一个常量文件里出现一次,别处一律引用;再写一条护栏断言所有用到的名字都来自这张常量表,防止有人图省事手写字符串——手写的那个拼错了不会报错,只会在面板上少一列。
- 第二件事是把版本钉进数据本身:常量文件里写死约定版本,并把这个版本号作为属性写进每一条链路。一年后翻历史数据时,你能立刻知道那批数据是按哪一版记的,而不是靠猜。
- 最后给升级动作:属性改名时做双写过渡(一段时间内新旧名都写),等面板和告警都切到新名再撤掉旧的。可预期的追问是「那要不要在数据管道里做改名映射」——可以,但那是把债转移到了管道上,双写加一个明确的下线日期更干净。
Key points
- State the facts: nothing is Stable, everything is Development, and it has moved to a repository with no release tag yet.
- The deprecation banners in the main repository mean the pages moved, not that the attributes died - do not delete instrumentation over them.
- Adopt it, but confine changes to a single constants file that everything else imports.
- Add a guard asserting every emitted attribute name comes from that table, since a hand-typed typo silently drops a column.
- Pin the convention version in code and emit it as an attribute; on renames dual-write, migrate dashboards, then retire the old name.
答题要点
- 先把事实说准:至今无任何条目为 Stable,全部 Development,且已搬进一个尚无发布 tag 的独立仓库。
- 主仓库页面上的「已弃用」是页面搬家,不是属性被废弃,不能照着它删埋点。
- 用,但把改动面收敛到一个常量文件,别处一律引用。
- 加一条护栏断言所有用到的属性名都来自常量表,防止手写字符串拼错后静默少一列。
- 把约定版本钉进常量文件并作为属性写进链路;改名时双写过渡,切完面板再下线旧名。
How would you layer the trace tree for one agent run, and what attributes go on each layer?一次 Agent 运行的链路树你会怎么分层?每层各记哪些属性?
Common in ChinaCommon overseasIntermediate#observability#tracing#span-designHow to reason about it · think before answering
- This tests whether you have actually drawn one. 'Record the model call' earns nothing; give the layers, the operation name per layer, and the rule that decides nesting.
- Three layers is the common skeleton. Outermost is the agent invocation, operation invoke_agent, carrying provider, request model, conversation id, plus your own business dimensions in your own namespace. The middle layer is each model turn, operation chat, carrying request parameters, response model and id, finish reasons, and input/output token usage. Innermost is each tool call, operation execute_tool, carrying tool name, tool type, call id, arguments and result.
- Operation names cannot be invented. The convention defines an enum of exactly nine values: chat, create_agent, embeddings, execute_tool, generate_content, invoke_agent, invoke_workflow, retrieval, text_completion. An off-enum value is equivalent to not instrumenting at all: backends facet on the enum, and unknown values land in an 'other' bucket that never surfaces in a chart.
- Explain the nesting rule: tool spans hang under the model turn that requested them, not as siblings of it, because that is the only way to attribute tool latency to a specific turn. Flatten them in a multi-turn agent and you can no longer say which turn issued the third tool call. Both layouts exist in the wild; what matters is that one company picks one, or hierarchy-based aggregation stops agreeing across services.
- Mention two commonly missed fields. Time to first chunk must be stamped when the first streamed chunk arrives and cannot be derived from total duration - for a streaming UI it is the latency the user actually feels. And the evaluation quartet - grader name, score, pass label, explanation - belongs on the root span so the dashboard's failure rate has a correct source.
- Finally, payload control: truncate and redact messages, tool arguments and results. Dumping whole conversations into spans is the classic way to blow up storage in one afternoon and the most common path for leaking personal data. Expected follow-up - how do you debug without the full text? Keep it in your own logs and put only a correlating id on the span.
分析过程 · 先想清楚再作答
- 这题考的是「有没有真的画过一棵树」。只说「记一下模型调用」的拿不到分,要给出层级、给出每层的操作名、并解释分层的判据。
- 三层是最常用的骨架:最外层是一次 Agent 调用,操作名 invoke_agent,记提供方、请求模型、会话 id,再加上你自己命名空间里的业务维度;中间层是每一轮模型调用,操作名 chat,记请求参数、响应模型与 id、完成原因、输入输出 token 用量;最里层是每一次工具调用,操作名 execute_tool,记工具名、工具类型、调用 id、参数与结果。
- 操作名不能自己造。约定里它是一个枚举,一共九个合法取值(chat、create_agent、embeddings、execute_tool、generate_content、invoke_agent、invoke_workflow、retrieval、text_completion)。写一个枚举外的值等于没埋:后端按枚举分面,不认识的值会掉进 other 桶,永远出不了图。
- 分层判据要说清楚:工具跨度挂在它所属的那一轮模型调用下面,而不是与模型调用平级。因为工具是那一轮决定要调的,挂进去才能把工具耗时归因到具体某一轮;多轮 Agent 一旦平铺,你就说不清第三次工具调用是第几轮发起的。**两种画法真实世界里都有,重点是全公司统一**,否则按层级做的聚合查询在两个服务之间对不上。
- 还要提两个容易漏的字段。一是流式首块延迟,它必须在读到第一个分片时打点,**不能从总耗时推算**,对流式界面来说它才是用户感知的快慢。二是评估结果四件套,把评分器名字、分数、通过标签与理由挂在根跨度上,这样面板上的失败率才有正确来源。
- 最后是负载控制:输入输出消息和工具参数结果都要截断并考虑脱敏,整轮对话原样进链路是把存储一次性写爆的经典方式,也是泄漏个人信息最常见的路径。可预期的追问是「那出了问题要看全文怎么办」——把全文留在你自己的日志里,链路只留一个能关联回去的 id。
Key points
- Three layers: invoke_agent wraps chat, chat wraps execute_tool, with tools nested under the turn that requested them.
- Operation names must come from the nine-value enum; anything else lands in the backend's 'other' bucket.
- Per layer: root carries provider, model and conversation id; chat carries request parameters, response id and token usage; tool spans carry name, type, call id, arguments and result.
- Time to first chunk must be stamped on arrival of the first chunk, never derived; the evaluation quartet goes on the root span.
- Truncate and redact messages and tool payloads; keep full text in logs and put only a correlating id on the span.
答题要点
- 三层:invoke_agent 包 chat,chat 包 execute_tool;工具挂在发起它的那一轮模型调用下面。
- 操作名只能取九个合法值之一,自造值会掉进后端的 other 桶,等于没埋。
- 各层属性:根层记提供方、模型与会话 id;chat 层记请求参数、响应 id 与 token 用量;工具层记工具名、类型、调用 id、参数与结果。
- 首块延迟必须在第一个分片到达时打点,不能从总耗时推算;评估四件套挂在根跨度上。
- 消息与工具参数结果要截断并脱敏,全文留在日志里、链路只放关联 id。
Recording every trace in production is too expensive. How do you design sampling so that you do not throw away the failures you actually need?线上全量记录太贵,你怎么设计采样策略才不会把真正的故障样本丢掉?
Common in ChinaCommon overseasDeep dive#observability#sampling#productionHow to reason about it · think before answering
- The trap is that the word 'sampling' makes people reach for a random percentage drop. The real question is when the decision is made.
- Head-based sampling rolls the dice as the request arrives. It is cheap and simple, but the cost is fatal: the decision happens before you know whether this trace will fail. A failure mode that hits 1% of requests is discarded 99% of the time before it goes wrong - the samples you most need are precisely the ones most likely to be dropped.
- Tail-based sampling is the right shape: wait until the trace finishes and the outcome is known, then decide. The rules can be blunt - keep every failure, keep everything slower than p99, keep a small proportion of fast successes as a baseline. The baseline matters: keep only failures and you cannot compute a failure rate or see what healthy looks like.
- For agents, say explicitly what 'failure' means: not the HTTP status code. A request that refunded the wrong amount still returns 200. The signal should come from the evaluation attributes - put the grader's pass label on the root span and let the sampling rule read it. That is a direct payoff of stitching offline evaluation into online telemetry.
- One implementation detail separates people who have done this: use a stable hash of the trace id, not a random number. Randomness keeps some spans of a trace and drops others, producing a truncated tree that is worse than nothing - it makes a stage look as if it never happened.
- Name the cost too: tail sampling must buffer all spans of a trace until the verdict is known, so the collector has to survive memory pressure and out-of-order arrival, and long traces need a timeout that forces a flush. Expected follow-up - how do you keep spend bounded? Budget the post-sampling write volume and, when over budget, lower the retention of successful traces first; the retention of failures and slow traces never moves.
分析过程 · 先想清楚再作答
- 这题的陷阱在于「采样」这个词会让人下意识想到按比例随机丢。真正的考点是**在什么时刻做这个决定**。
- 头部采样是请求一进来就掷骰子决定记不记。它便宜、实现简单,但代价是致命的:这个决定是在你还不知道这条链路会不会出问题的时候做的。于是一条命中率 1% 的故障链路,有 99% 的概率在它出问题之前就已经被丢掉了——**线上最需要的那批样本,恰好是最容易被采样掉的那批**。
- 正确的做法是尾部采样:等链路跑完、结果已知,再决定留不留。规则可以写得很直白——失败的一条不落,超过 p99 的慢链路全留,成功且不慢的按比例留一小部分当基线。留基线很重要,全丢掉的话你手里只剩故障样本,没法算失败率,也看不出正常态是什么样。
- 对 Agent 来说「失败」的判据要特意说清楚:不是 HTTP 状态码。一个退错了钱的请求照样是 200。判据应该来自评估结果属性——把评分器的通过标签挂在根跨度上,采样规则直接读它。这也是离线评估与线上监控缝在一起之后的直接收益。
- 还有一个实现细节能看出有没有真做过:决定留不留要用 traceId 的稳定哈希,不要用随机数。随机数会让一条链路的一部分跨度被留下、另一部分被丢掉,拼出来是一棵残树,比完全没有更糟——它会让人以为某一段根本没发生。
- 代价也要主动说:尾部采样必须先把一条链路的全部跨度缓存到能判定结果为止,所以收集端要扛住内存与乱序到达,长链路还要设超时强制出清。可预期的追问是「那怎么保证成本可控」——给采样后的写入量设预算,超预算时先降成功样本的留存比例,**失败与慢链路的留存率永远不动**。
Key points
- The question is not how much to drop but when to decide: head-based sampling rolls the dice before the outcome is known.
- Its fatal flaw: a low-frequency failure mode is almost always discarded before it goes wrong.
- Tail-based rules: keep all failures, keep everything above p99 latency, keep a small sampled baseline of fast successes.
- Failure for an agent comes from evaluation attributes, not HTTP status - a wrong refund still returns 200.
- Use a stable hash of the trace id, never a random number, or you get truncated trees; under budget pressure lower only the retention of successful traces.
答题要点
- 关键不是丢多少,而是在什么时刻决定:头部采样在结果未知时就掷骰子。
- 头部采样的致命问题:低命中率的故障链路极大概率在出问题之前已被丢掉。
- 尾部采样规则:失败全留、超 p99 的慢链路全留、成功且不慢的按比例留一小部分当基线。
- Agent 的失败判据来自评估属性而不是 HTTP 状态码,退错钱的请求同样返回 200。
- 用 traceId 的稳定哈希而不是随机数,否则会产出残缺的链路树;预算紧张时只降成功样本留存率。