agentleFS
radra23radra23/otel-as-code-plugin/skills/semconv-discipline/SKILL.md

semconv-discipline

Skill1 starsChanged 47 days agoApache-2.0View on GitHub

OpenTelemetry semantic-convention rules — resource vs span attributes, HTTP/DB/messaging attribute names, deprecations, custom-namespace prefixing, and cardinality limits. Use when writing, generating, or reviewing OTel instrumentation code.

The file

We split this skill into the rules it gives an agent

4 rules. About 0 are common, 1 is distinctive, and 3 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈1

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 3

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Use the NEW names (1.23+ stable).

    line 82
  2. Very specific:

    Use the NEW names (the four below are stable root exports of @opentelemetry/semantic-conventions.

    line 97
  3. Distinctive:

    Verify against the registry (https://opentelemetry.io/docs/specs/semconv/registry/), the same "check, don't assert from memory" rule this …

    line 160
  4. Very specific:

    Only when BOTH hold is it a collision: deployment.name — deployment.

    line 167

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

OTel Semantic Conventions Discipline

SEMCONV_VERSION: 1.45.0 — Use stable conventions from this version by default. Experimental conventions (GenAI, profiling, system) require the --experimental flag.

Single source of truth. This SEMCONV_VERSION constant is the one place the pinned semconv version is defined. Commands and subagents stamp it into generated file headers (via the <SEMCONV_VERSION> placeholder), and the semconv-lint hook reads it back out of this file at runtime. Bump it here only — the weekly CI drift job updates this line.

It is the specification version, not a package version. The SDK packages carry their own, independent numbering — @opentelemetry/semantic-conventions and opentelemetry-semantic-conventions each release on their own cadence and will not equal this number. Never treat the two as the same, and never derive one from the other.

Which constants a package exports, and from which entry point, is a property of the installed package, not of this version. Check it rather than asserting it:

node -e "const s=require('@opentelemetry/semantic-conventions'); console.log(s.ATTR_SERVICE_NAMESPACE)"
python -c "from opentelemetry.semconv.resource import ResourceAttributes as R; print(getattr(R,'DEPLOYMENT_ENVIRONMENT_NAME','<absent>'))"

A generated comment claiming an attribute "is incubating", "is not on the root export", or "was already stable at <version>" is a claim about a package release. It ages into a confident falsehood the moment the dependency moves, and it costs a verification pass to catch. If a workaround is genuinely needed, state what was observed and at what package version; if nothing needs explaining, write nothing.

Resource vs Span Attributes

Resource attributes describe the entity producing telemetry — they are set once on the Resource object and appear on every span/metric/log from that process. Span attributes describe a single operation.

Resource attributes (NEVER set on spans):

  • service.name — name of the service
  • service.version — version string
  • service.namespace — logical grouping
  • service.instance.id — unique instance identifier
  • deployment.environment.name — e.g. production, staging
  • host.name, host.id
  • container.id, k8s.pod.name

Common violation: span.setAttribute('service.name', ...) — this is WRONG. service.name must be on the Resource, not on spans.

service.name must not bake in the deployment environment (#209)

service.name identifies the logical service ACROSS environments; the environment belongs in deployment.environment.name, a separate Resource attribute that already exists for exactly this. A name like checkout-api-prod / checkout-api-dev conflates the two, and every downstream consumer pays for it: a dashboard/alert/SLO built from service.name (this plugin's own Grafana/ Datadog/New Relic/Dash0 queries all filter by it) now needs one copy per environment, or a regex, instead of one definition filtered by deployment.environment.name; cross-environment comparison breaks outright (there is no single checkout-api to compare -dev against -prod as).

WARNING, not a severe/strict-blocking rule (heuristic — a token match, not a structural fact like span.setAttribute('service.name', ...) above): a service.name value (observed in an existing bootstrap, or set via OTEL_SERVICE_NAME / OTEL_RESOURCE_ATTRIBUTES in deployment config) ending in a hyphen plus a known environment token — dev, int, qa, sbx, uat, perf, test, stage, staging, pre-prod, preprod, prod, production — is this violation. Fix: strip the suffix for service.name, and set deployment.environment.name to the stripped token (expand common abbreviations if the repo's own environment-name convention elsewhere is already the long form, e.g. prod → "production" — don't invent an expansion the repo doesn't already use itself). The clearest, strongest signal: the SAME base name appears across more than one deployment config (per environment), each time with a different one of these suffixes — that is direct proof the suffix is environment, not identity, and not a coincidental name ending in a token- like word (a service genuinely named data-prep is not this violation; it doesn't also show up as data-dev/data-qa elsewhere). A single observed name ending in a listed token, with no second data point, is still worth flagging, just with lower confidence — say so.

HTTP Conventions (semconv 1.23+)

Use the NEW names (1.23+ stable). Fix classifies whether /otel-instrument --fix <CV-id> may apply this rename mechanically (a straight key substitution, same value) or must leave it for a human (the value itself has to change shape — see agents/instrumentation-gen.md's fixList handling):

OLD (deprecated)NEW (use this)Fix
http.methodhttp.request.methodmechanical
http.urlurl.fullmechanical
http.status_codehttp.response.status_codemechanical
http.hostserver.addressmanual — http.host commonly carries host:port; server.address is host only, with port in a separate server.port attribute. A blind key rename ships a wrong value.
http.schemeurl.schememechanical
http.targeturl.path + url.querymanual — one OLD attribute splits into two NEW ones; requires parsing the path apart from the query string, not a rename.

Database Conventions

Use the NEW names (the four below are stable root exports of @opentelemetry/semantic-conventions; the OLD names are incubating/deprecated). All four are mechanical (straight key rename, same value) — see the HTTP table above for what a non-mechanical entry looks like:

OLD (deprecated)NEW (use this)Fix
db.systemdb.system.namemechanical
db.statementdb.query.textmechanical
db.operationdb.operation.namemechanical
db.namedb.namespacemechanical
  • db.system.name — REQUIRED (e.g. postgresql, redis, mongodb). NOT db.system, which is deprecated (replaced by db.system.name) — this was the one entry the list had left on the old name while the other three were already modernised.
  • db.namespace — database name
  • db.operation.name — e.g. SELECT, INSERT
  • db.query.text — sanitized query (no PII)

Messaging Conventions

  • messaging.system — REQUIRED (e.g. kafka, rabbitmq, aws_sqs)
  • messaging.destination.name — topic or queue name
  • messaging.operation.type — create, send, receive, process, settle (#223 — verified against the registry at the pinned SEMCONV_VERSION). Two older enum VALUES are deprecated — this is a deprecated value of this one attribute, not a deprecated attribute key, so it does not belong in the OLD→NEW attribute-rename table below (that table's checker in scripts/drift_check.py looks up each cell as an attribute name in the registry; publish and send are not attribute names, so a row there would silently fail to verify, or verify the wrong thing):
    • publish → use send instead
    • deliver → use process instead

⚠ Every messaging.* attribute is incubating — exported ONLY from the package's ./incubating entry point, not the stable root. A TypeScript/CommonJS project on moduleResolution: "node" (the legacy default) cannot resolve that subpath at all, so generated code that imports these from the root fails to compile. See the "Incubating semconv attributes and the /incubating subpath" section in agents/instrumentation-gen.md for how to handle it (bump moduleResolution, or use the verified string literal). This is a stability property of the package, so verify against the installed version rather than asserting.

General / connection attributes (deprecations)

OLD (deprecated)NEW (use this)Fix
peer.serviceserver.address (+ server.port) on the CLIENT span; identity comes from the callee's own service.name resource attributemanual — not a rename: peer.service was a caller-assigned label, server.address/server.port describe the connection, and the callee's own identity now comes from ITS service.name resource attribute, not a value set here.
deployment.environmentdeployment.environment.namemechanical — same-value rename, a RESOURCE attribute in both forms. The most common deprecated key in hand-written bootstraps: every resource block written before semconv 1.27 has it (#190).

Custom / Business Attribute Namespace Rule

Custom attributes MUST use a reverse-DNS namespace prefix:

  • CORRECT: com.myorg.checkout.cart_id
  • WRONG: cartId, cart_id, checkout_cart_id, biz.checkout.cart_id

Two distinct problems, two severities — do not collapse them into one "no prefix" finding:

  1. Reserved-namespace collision — ERROR, rename is not optional. This is a two-part test — check BOTH, on the full attribute name, never just the first segment:

    1. The attribute's first segment is a namespace OpenTelemetry reserves (non-exhaustive, but check the name's first segment against it): service., deployment., telemetry., otel., host., container., k8s., process., cloud., faas., http., url., server., client., network., dns., db., rpc., messaging., message., user., session., error., exception., code., gen_ai., feature_flag., peer..
    2. AND the full attribute name is NOT itself a registered attribute in that namespace. Verify against the registry (https://opentelemetry.io/docs/specs/semconv/registry/), the same "check, don't assert from memory" rule this file already applies to installed-package claims — a hardcoded exhaustive list here would rot exactly like the reserved-namespace list above is already flagged as "non-exhaustive." user.id, user.name, user.email, session.id, error.type, code.function.name are registered — using them for their registered meaning is CORRECT, not a collision, even though their first segment is reserved.

    Only when BOTH hold is it a collision: deployment.name — deployment. is reserved and deployment.name is not registered (deployment.environment.name and deployment.id are) — and message.notificationId — message. is the RPC registry and this name is not in it. Fix = rename out of the reserved namespace. The inverse mistake is worse than the finding this rule prevents: renaming a REGISTERED attribute (e.g. user.id → com.myorg.user.id) breaks every backend that special-cases the standard name — using a registered attribute for its registered meaning is required, not merely permitted, so never "fix" one into a house namespace.

  2. Merely unprefixed — WARNING, fix at the team's convenience. A single-segment or camelCase house attribute in a namespace OTel does not claim (app.name, vm.id, action.type). Nothing breaks; prefix it com.<org>.* when convenient.

This applies to business attributes too. biz.* appears in business-attr-ux only as a pre-namespace placeholder shape for inferred candidates; it is never a name that gets written. Candidates are presented already namespaced once the namespace is known, so the string the user approves is the conformant one — see "Business Attributes — Always Confirm" there.

Infer the namespace from:

  1. npm scope: @myorg/service → com.myorg
  2. Maven groupId: com.example.service → com.example
  3. Python package: myorg.inventory → com.myorg (ask if ambiguous)

Span Kind Rules

KindWhen to useRequired attributes
SERVERIncoming HTTP/RPCserver.address, url.path
CLIENTOutgoing HTTP/RPC/DB callserver.address, server.port
PRODUCERPublishing to a message queuemessaging.system, messaging.destination.name
CONSUMERReceiving from a message queuemessaging.system, messaging.operation.type
INTERNALInternal operations (default if unsure)none required

Cardinality Warnings

A high-cardinality (unbounded) identifier's harm depends on WHERE it lands — and the worst position is the one the old one-line rule never mentioned. Tier findings by position:

PositionSeverityWhy
Metric dimension (a counter/histogram tag/label)errorone permanently-retained time series per distinct value; sampling does not touch it; cost is unbounded and the series persist in the backend even after the code is fixed
Span attributewarningingest + index cost only; bounded by sampling; ages out with span retention
Span event attribute / log recordacceptablethe prescribed home for a per-request identifier

So the fix differs by position: for a metric dimension it is "remove the tag" (irreversible damage otherwise); for a span attribute it is "consider moving to a span event or log". Note the Collector transform delete_key guardrail covers spans and metric data points with the same drop-list (#172), so a listed identifier is stripped from a metric dimension before it reaches the backend, at the cost of merging the series that differed only by it. It is a backstop for the keys on its list, not a substitute for fixing the code: an identifier it does not list still creates a series per value, and series already created in the backend persist. Log records are deliberately not guarded, since they are the prescribed home for a per-request identifier.

The one-line form, kept because it is still true for the common case: high-cardinality identifiers do not belong as span attributes — use events or logs instead — but a metric dimension is worse, not exempt.

Canonical high-cardinality identifiers — the same set is enforced by the semconv-lint hook (at the source level across all six supported languages, matching both the dotted user.id form and the common camelCase/snake_case spellings — userId / user_id — as a span attribute, AND as a metric dimension, which the hook treats as error tier per the ranking above, not warning) and by the Collector transform drop-list in collector-topology (on span attributes and metric data points; not log records — see the cardinality-position table above). semconv-lint reads this exact list at runtime (hooks/semconv-lint.sh greps the line below) rather than hardcoding a second copy — keep it in sync with the Collector drop-list when adding an identifier, and do not reformat this line (the hook's extraction depends on its shape: backtick-wrapped dotted identifiers, comma-separated, ending in a period): user.id, session.id, request.id, order.id.

Also flag, and route to span events or structured logs:

  • Raw SQL query strings → must be sanitized before use as db.query.text
  • File paths with user data
  • Full URLs with query parameters containing PII

Signal: if an attribute value could have >10,000 distinct values in production, flag it.

Canonical Personal-Data Attribute Patterns (#227)

Distinct from cardinality above: this is about what a VALUE is (personal data), not how many distinct values it can take. An attribute can be high-cardinality without being personal data (a request ID) and can be personal data without being high-cardinality (a household's shared home.country — still PII, just low-cardinality). Report a PI finding by matching EITHER of these, independently — do not require both:

By attribute name — these commonly carry a personal-data value regardless of the specific value observed, matched as an exact key or as the attribute's final dotted segment (so a house namespace like com.acme.user.email still matches on email): user.email, user.name, user.full_name, user.phone, user.phone_number, user.address, user.ssn, user.date_of_birth, user.ip, client.address, enduser.id (when populated with an email or real name rather than an opaque internal ID — see the value-shape check below for telling those apart).

By value shape — read what the code actually sets, not just the key name, since a poorly named attribute (data, extra, metadata, a serialized object) can carry the same personal data under a name the list above cannot anticipate:

  • An email-shaped string (<local>@<domain>)
  • A phone-number-shaped string (a run of digits with optional +/-/(), typically 7+ digits)
  • A full name (two or more capitalized words where the attribute is not itself an enum/category)
  • A free-text user-input field (a comment body, a search query, a support-ticket description)
  • A serialized object/JSON blob that itself contains any of the above as a nested field

Neither list is exhaustive — personal data takes forms specific to each domain (a patient MRN, a national ID number, a vehicle plate). Treat both as a floor, not a ceiling: flag a value you recognize as personal data even when it matches neither list, and say what made you flag it.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.