DEV Community

Cover image for I contribute to OpenTelemetry and still shipped two retired attribute names, so I built Attrition
vigneshwar
vigneshwar Subscriber

Posted on AI-assisted

I contribute to OpenTelemetry and still shipped two retired attribute names, so I built Attrition

Sanity Challenge Path One Submission

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
Enter fullscreen mode Exit fullscreen mode

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

Attrition demo

Try it in two minutes

  1. Open the app and click Checkout service. One TypeScript file acts as both an HTTP server and an HTTP client. Notice that net.peer.name receives a different answer on each span, and that two HTTP metrics are flagged even though the registry no longer mentions them.
  2. 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.
  3. 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.
  4. Ask the chat agent: "We still record http.server.duration in milliseconds. What should we use now?"
  5. Open the Decisions page to see the conflicts the Knowledge Base raised and how each one was resolved.

Scanning a TypeScript checkout service

The chat agent answers from the same two sources and shows its evidence under every reply:

Chat answer with the Sanity Context calls behind it

Project page: https://vignesh2027.github.io/attrition

Code

GitHub logo 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.

Attrition scanning a TypeScript checkout service

Demo: two samples and the Knowledge Base decisions

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

How Attrition works

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.

A metric with a unit change in Sanity Studio

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)}}
  }
}
Enter fullscreen mode Exit fullscreen mode

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.duration maps to http.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 for db.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.

Knowledge Base decisions

5. Three agents, one source of truth

  • The scanner is fully deterministic. Extraction happens in code, verdicts come from groq_query, and guidance comes from knowledge_base_search and knowledge_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_search and knowledge_base_read directly 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

(https://dev.to/agent_sessions/new)

Top comments (2)

Collapse
 
respect17 profile image
Kudzai Murimi •

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.

Collapse
 
apples_one_cd174284bffb profile image
vigneshwar •

Exactly, you got it !