This is a submission for the Sanity Challenge, Path One: Ship an Agent That Queries Real Content
What I Built
Last month my entry won the DEV Summer Bug Smash. It was a Go demo that traced Redis commands into Sentry. This week I ran a new tool against that same file, and it flagged two names that OpenTelemetry had already retired:
attribute.String("db.system", "redis"), // renamed to db.system.name in v1.30.0
attribute.String("db.operation", cmd.Name()), // renamed to db.operation.name in v1.26.0
I contribute to OpenTelemetry, and I still shipped them. That is the problem Attrition solves.
The problem
OpenTelemetry's semantic conventions define the names that telemetry uses: http.request.method, db.system.name, http.server.request.duration. These names change between releases. Old tutorials, SDK constants and copied snippets keep the retired ones alive, and nothing breaks loudly when you use them. Instead, dashboards and alerts quietly stop matching, and teams find out weeks later.
Finding these names sounds like a search problem. It is not. The correct answer often depends on context that a keyword search cannot see:
| Name in your code | Why a search gets it wrong | What Attrition answers |
|---|---|---|
net.peer.name |
There are two correct replacements |
server.address on a client span, client.address on a server span, decided from your code |
http.server.duration |
It vanished from the registry with no deprecation entry |
http.server.request.duration, with the unit changing from ms to s |
db.client.connections.wait_time |
It looks like a simple rename | The unit also changed from ms to s, so a rename alone records wrong values |
cloud.platform = "azure_vm" |
The key itself is current | The value is retired; use "azure.vm"
|
db.name |
The registry and the migration guide disagree | Both views are shown, and the change is left for a person to decide |
What Attrition does
Attrition reads instrumentation code and checks every attribute key, metric name, event name and enum value against 1,563 names from 26 releases of the specification. For each retired name it reports:
- a verdict read from structured data, never guessed by a model
- the replacement that fits the span kind your code uses
- the release that retired it, and whether your pinned version already had it retired
- any unit change, when a renamed metric also changed its unit
- what the official migration guide adds, read from a Sanity Knowledge Base
- a link to the exact line of the specification that says so
- a patch, limited to the renames that are safe to automate
Demo
Live app, no login required: https://attrition-otel.vercel.app
Try it in two minutes
- Open the app and click Checkout service. One TypeScript file acts as both an HTTP server and an HTTP client. Notice that
net.peer.namereceives a different answer on each span, and that two HTTP metrics are flagged even though the registry no longer mentions them. - Click Orders and an LLM call. This Python sample shows a metric whose unit changed, a retired enum value, a chat event that became a span attribute, and a case where the registry and the migration guide disagree.
- Scroll to How this answer was built under any result. It lists every Sanity Context call, the query it sent, the time it took and the number of documents returned.
- Ask the chat agent: "We still record http.server.duration in milliseconds. What should we use now?"
- Open the Decisions page to see the conflicts the Knowledge Base raised and how each one was resolved.
The chat agent answers from the same two sources and shows its evidence under every reply:
Project page: https://vignesh2027.github.io/attrition
Code
vignesh2027
/
attrition
Find the OpenTelemetry attribute names the spec already retired. An agent on Sanity Context over all 940 semantic convention attributes.
Attrition
Find the OpenTelemetry names the spec already retired.
Paste instrumentation code. Attrition finds every attribute key, metric name, event name, enum value and SDK constant in it, checks each one against 1,563 names from 26 releases of the OpenTelemetry semantic conventions, and tells you what to use instead: for your span kind, with the release that retired it, the exact spec line that says so, and what the official migration guide adds. It writes a patch only for the renames that are safe to automate and leaves the rest for a person.
Live app
https://attrition-otel.vercel.app (no login)
Knowledge Base decisions
https://attrition-otel.vercel.app/decisions
Project page
https://vignesh2027.github.io/attrition
Sanity project ID
y9raau23, dataset production (public)
Public dataset query
every retired name as JSON
Sanity Studio
https://attrition.sanity.studio
Context MCP, dataset
https://api.sanity.io/v1/context/organizations/oc2g3x7ee/mcp/attrition
Context MCP, Knowledge Base
https://api.sanity.io/v1/context/organizations/oc2g3x7ee/mcp/attrition-kb
Why this needs structured content
The semantic conventions rename things between releases, and old blog posts, SDK…
One dataset serves four clients, each built for a different job:
| Client | Language | Best for |
|---|---|---|
| Web app | TypeScript, Next.js | Pasting a file, reading the evidence, asking questions |
cli/ |
Go | Scanning whole repositories and gating CI; -fix applies only safe renames |
rust/ |
Rust | Offline scanning for pre-commit hooks and air-gapped CI |
python/ |
Python | A terminal agent built on the official Python MCP SDK |
action.yml |
GitHub Action | Failing a pull request that introduces a retired name |
The Rust scanner is a port of the web extractor, and a parity script confirms that both produce identical counts on five real repositories. CI builds and tests all three languages and runs the Action against the repository's own samples.
How I Used Sanity
The design rests on one rule: structured data decides the verdict, the Knowledge Base explains the change, and the model never decides what replaced what.
1. Modelling the specification as structured content
The specification is published as YAML spread across many files and releases. A deterministic importer reads every tagged release from v1.21.0 to v1.44.0 and writes 1,654 documents to Sanity: 940 attribute, 571 metric and 36 event documents, plus namespaces and release statistics.
The newest release defines each name. The older releases add history that no single release contains: introducedIn, deprecatedIn, and 50 names that disappeared from the registry without any deprecation entry. Those are recorded with the verdict DROPPED and the last release that defined them.
Every retired name receives a verdict computed in code from the specification's own reason, renamed_to and note fields:
| Verdict | Attributes | Metrics | Events | Example |
|---|---|---|---|---|
RENAMED |
97 | 71 | 1 |
http.method becomes http.request.method
|
REPLACED |
15 | 5 | 5 |
rpc.client.duration becomes rpc.client.call.duration, ms to s |
MOVED_OUT |
58 | 11 | 3 |
gen_ai.* moved to a separate repository in v1.42.0 |
DROPPED |
16 | 30 | 4 |
http.server.duration, last defined in v1.21.0 |
REMOVED |
21 | 10 | 1 | No replacement; delete it |
SPLIT |
4 |
http.target becomes url.path plus url.query
|
||
MERGED_INTO |
4 |
code.function folds into code.function.name
|
||
SPAN_KIND_DEPENDENT |
3 |
net.peer.name, net.peer.port
|
||
CONDITIONAL |
2 |
db.sql.table, http.host
|
||
USE_SIGNAL_FIELD |
2 |
event.name moves to the log record's EventName field |
The structure carries the details that make answers correct. Each replacement records the condition it applies under (always, client spans, server spans, together). Twenty-four enum values carry their own replacementValue. When a renamed metric's replacement uses a different unit, the importer records a unitChange by comparing the two metric documents.
That comparison also uncovered a genuine error in the upstream specification: two deprecated system.linux.memory.* entries carry the description and unit of a network packet counter. Rather than report a false unit change, Attrition stores the finding as a specErratum.
I reviewed every verdict by hand, and no name remains in the NEEDS_REVIEW state.
2. Querying the dataset through Sanity Context
The dataset is served by an organization-level Context MCP endpoint named attrition. Its GROQ filter limits access to the convention types, and its instructions explain what each verdict means and forbid inventing a replacement.
A scan sends a single groq_query covering every name found in the code:
{
"release": *[_type == "specRelease"][0].tag,
"attrs": *[_type in ["attribute", "metric", "event"] && key in ["http.method", "net.peer.name", "http.server.duration"]]{
_id, _type, key, status, lastSeenIn, unit, specErratum, "source": source{url, release},
"deprecatedMembers": members[defined(deprecated)]{value, deprecated, replacementValue},
deprecation{verdict, note, deprecatedIn, valueChanges, unitChange{from, to}, movedTo{label, url},
"replacements": replacements[]{key, when, "stability": coalesce(attribute->stability, metric->stability)}}
}
}
The code determines the span kind and the data determines the answer. Because no model sits in this path, a scan cannot invent a name.
3. A Knowledge Base for what the registry leaves out
The registry records what a name became. It does not record what else changed, and for dropped names it records nothing at all. That knowledge lives in the official migration guides.
I built the Knowledge Base entirely with the Sanity CLI (sanity context create, context imports create and context build) from 144 sources, within the 150 document budget:
- 137 documents bound from the dataset: the retired attributes and the HTTP, database, RPC and messaging metrics that most instrumentation uses
- 5 official migration guides: HTTP, database, RPC, code attributes and version selection
- 2 older specification pages that still use the retired names
A second organization endpoint, attrition-kb, serves the Knowledge Base. For each retired name, a scan calls knowledge_base_search and then knowledge_base_read on the results, keeping the guide entry that discusses that name. Entry paths are never hard-coded, because each rebuild can rename them.
This is where several of the most valuable answers come from:
-
http.server.durationmaps tohttp.server.request.duration, with the unit changing from ms to s. The registry has no record of this at all. -
db.query.text, the replacement fordb.statement, "SHOULD be collected by default only if sanitization excludes sensitive information." A rename alone would never surface that privacy requirement. - Each area's dual-emit setting, such as
OTEL_SEMCONV_STABILITY_OPT_IN=database/dup, which lets teams emit old and new names side by side during a migration.
4. Resolving conflicts between sources
The Knowledge Base build raised four genuine conflicts. My dataset stated that http.server.duration and http.resend_count left the registry with no replacement, while the official guides stated that they were renamed.
Both statements are accurate at different levels. The registry stops listing a name once it is gone, while the guide records where it went. I resolved each conflict in favour of the guide using client.context.issues.resolve(), applied the resulting page updates with issues.apply(), and Sanity converted each decision into a standing instruction that every future build follows.
The effect was immediately visible. Before the decisions, asking about http.resend_count returned "no replacement". Afterwards, the rewritten entry correctly returned http.request.resend_count. Every conflict, both claims and the chosen side are published on the Decisions page.
5. Three agents, one source of truth
-
The scanner is fully deterministic. Extraction happens in code, verdicts come from
groq_query, and guidance comes fromknowledge_base_searchandknowledge_base_read. - The chat agent retrieves its evidence first through the same Context calls, then answers in a single model call restricted to that evidence. It cites the dataset or the exact Knowledge Base entry and lists the calls beneath each answer. This design also keeps it within the limits of a free model tier.
-
The Python agent takes the conventional route: the model calls
groq_query,knowledge_base_searchandknowledge_base_readdirectly through the official Python MCP SDK. It demonstrates that the same endpoints work from any agent framework.
6. Results on real code
I ran the Go CLI against five open-source repositories at pinned commits, and the Rust offline scanner reproduced every count:
| Repository | Retired names in code | Files to update |
|---|---|---|
| open-telemetry/opentelemetry-demo | 6 | 4 |
| jaegertracing/jaeger | 67 | 13 |
| redis/go-redis | 16 | 4 |
| go-gorm/opentelemetry | 0 | 0 |
| uptrace/opentelemetry-go-extra | 18 | 7 |
The most significant finding: go-redis's official redisotel package still registers the old db.client.connections.* pool metrics and records create_time and use_time in milliseconds. Renaming them to the new names without converting the values would make every reading a thousand times too large. This is exactly why Attrition never auto-patches a name whose unit changed.
The clean result for go-gorm/opentelemetry matters as much: a reliable tool must say nothing when the code is current. Every finding links to its source line in bench/RESULTS.md.
Design decisions and limitations
- Patches are conservative by design. Only unconditional renames of string keys are patched. Span-kind choices, unit changes, merges and disagreements between sources are always left for a person.
- A finding is not always a bug. Test fixtures that read historical data, or libraries that intentionally emit both old and new names during a migration, may keep a retired name on purpose.
- Next steps: mapping pinned SDK versions to the exact specification release they ship, and adding the remaining migration guides to the Knowledge Base as the 150 document limit allows.
Sanity Project Details
-
Project ID:
y9raau23 -
Dataset:
production(public) - Public dataset query: every retired name as JSON
- Sanity Studio: https://attrition.sanity.studio
-
Knowledge Base:
kbI5ncVyqDpc, served by the organization Context MCP endpointattrition-kb -
Dataset Context MCP endpoint:
attrition - Knowledge Base decisions: https://attrition-otel.vercel.app/decisions








Top comments (2)
The title alone tells the whole story, even people who write the spec ship against the old names sometimes. 1,563 retired names is way more than I expected.
Exactly, you got it !