Skip to content

Telemetry Attribute Catalog ​

The canonical list of attributes MCTL puts on OpenTelemetry spans, and how they map onto upstream OpenTelemetry semantic conventions.

This page exists so that producers do not each invent their own names. A trace is only useful if a DevLoop span, an agent diagnosis span and an MCP tool span can be joined on the same keys, and that only happens if the keys are agreed before the producers are written.

Status describes the catalog contract, not a live deployment check. shipped identifies the named implementation; reserved retains a name without claiming production export. The mctl-agents tracing implementation can export when its OTLP configuration and rollout gates enable it. The Telegram audit helper attaches attributes to an existing span. Verify the deployed producer and collector before assuming an attribute is queryable.

Source audit: 2026-10-15, against mctl-agents 7189793, mctl-telegram c23f388 and mctl-gitops 0274faa02. This audit does not promote reserved attributes or assert production trace availability.

Ownership ​

ConcernOwner
Attribute names, types, shapes — this pagemctl-docs
Collector enrichment and redactionmctl-gitops, platform-gitops/bootstrap/templates/observability/otel-collector.yaml
What the control plane asserts about an execution, and the trust modelmctlhq/mctl-agents#196
Version identity fixed per runmctl-agents/docs/adr/007-agent-definition-execution-profile-contract.md
Backend selectionmctlhq/mctl-gitops#1280

This catalog fixes names. It does not decide which fields the control plane is entitled to assert, nor how a workload is prevented from spoofing them — that is mctlhq/mctl-agents#196, and the two are deliberately separate. A name can be agreed now; the trust model is a design question that is still open.

Naming rules ​

  1. Reuse an upstream convention when one exists. If OpenTelemetry defines an attribute for the concept, use its name and its value shape. Do not mint an mctl.* alias for something that already has a standard name.
  2. Mint mctl.* only for concepts upstream does not model. DevLoop executions, work items, agent release tuples and the Cloudflare edge route have no upstream equivalent. mctl. is the only prefix MCTL owns.
  3. Namespace by concept, not by producer. mctl.execution.id, not mctl.agents.execution_id. Two services emitting the same concept must emit the same key.
  4. Dots separate levels, underscores separate words within a level.mctl.work_item.id, not mctl.workItem.id or mctl.work-item.id.
  5. Singular subject. mctl.repository.name, never mctl.repositories.name.
  6. Identifiers end in id; human-readable names end in name. If both exist, emit both rather than overloading one. A leaf may be _id when the identifier qualifies a sub-entity of the level above it — mctl.workflow.run_id is the id of a run of that workflow, not of a separate run entity, and mctl.edge.request_id likewise. Upstream has the same shape (k8s.pod.uid). Promote it to its own level only when the sub-entity gains attributes of its own. An identifier assigned by an external system may keep that system's own term for it: mctl.issue.number and mctl.pr.number carry the GitHub issue and pull-request number, which GitHub calls a number and keeps distinct from its own internal id, so renaming them .id would obscure which identifier is meant.
  7. Units in the key for anything measured, following upstream practice (.duration in seconds as a float, .count as an integer). Do not encode a unit in the value.
  8. Booleans read as an assertion, e.g. mctl.approval.required, so that true needs no further explanation.

Upstream namespaces deliberately not adopted ​

Rule 1 says reuse an upstream convention when one exists. Three near-misses are worth naming explicitly, so the next producer does not read the tables below as a counter-example to the rule:

MCTL attributeNearest upstreamWhy MCTL keeps its own
mctl.repository.namevcs.repository.name (release candidate)Not the same value. Upstream's is the bare repository name and its own note warns it "can clash with forks of the same repository if collecting telemetry across multiple orgs". MCTL carries the qualified owner/repo, which is a different field.
mctl.pr.numbervcs.change.id (release candidate)The same concept, and the one real candidate for adoption. MCTL keeps its own for now because an integer is the natural type for a PR number and upstream's is a string, and because vcs.* is not stable — adopting a release-candidate name into a catalog whose whole purpose is to stop renames would be self-defeating. This is the row most likely to change at the re-review below.
mctl.user.iduser.idMCTL distinguishes two people that user.id collapses: mctl.user.id is the upstream principal a call ran as, mctl.actor.id is the human who triggered the execution. One upstream key cannot carry both.

Re-review this table when vcs.* reaches stable — that is the trigger, and nothing checks it automatically.

Resource attributes ​

Resource attributes describe the producer, not the operation, and are set once per process. Several are added by the Collector, so a producer that omits them still gets them.

AttributeSet byStatusNotes
service.nameproducershipped (required)Upstream convention. The one resource attribute a producer must set itself; nothing can infer it reliably.
k8s.namespace.nameCollector, k8sattributesshipped
k8s.pod.nameCollector, k8sattributesshipped
k8s.node.nameCollector, k8sattributesshipped
k8s.deployment.nameCollector, k8sattributesshipped
mctl.teamCollector, from pod label mctl.ai/teamshippedThe same pair promtail relabels for logs, so a trace and a log line for one pod carry matching tenant identity with no mapping table.
mctl.componentCollector, from pod label mctl.ai/componentshippedAs above, from mctl.ai/component.
k8s.cluster.nameCollector, resource processorshippedaction: insert, so a producer that sets it wins.
deployment.environment.nameCollector, resource processorshippedaction: insert, same rule. The stable upstream key; the Collector moved to it from the deprecated deployment.environment in mctlhq/mctl-gitops#1724 (mctlhq/mctl-gitops#1332) and does not emit the old key. A producer setting the environment itself should use this name too.

The insert semantics matter: the Collector never overwrites a producer's value for these two. A producer that knows better — a workload running for one tenant environment inside a shared cluster, say — may set them and will not be rewritten.

Span attributes — correlation identity ​

These are the join keys. They are what makes one DevLoop visible as a single trace, and they are the attributes every producer in the epic must carry.

AttributeTypeStatusMeaning
mctl.execution.idstringreservedThe canonical execution identifier. Semantics owned by mctlhq/mctl-agents#196; today the nearest durable value is attempt.id in a DevLoop .status.yaml.
mctl.workflow.idstringreservedTemporal workflow id, e.g. dev-loop-mctlhq-mctl-telegram-617.
mctl.workflow.run_idstringreservedTemporal run id. Distinguishes retries of one workflow id.
mctl.workflow.typestringreservedinvestigate, implement, and so on. Low cardinality — safe to group by.
mctl.argo.workflow.namestringreservedThe Argo workflow that executed one activity, as stored on the ExecutionRecord.
mctl.work_item.idstringreservedRoadmap work item id, e.g. attribute-catalog. Joins a trace to the roadmap graph.
mctl.epic.namestringreservedEpic manifest name, e.g. observability.
mctl.agent.namestringreservedinvestigator, implementer, shepherd, …
mctl.repository.namestringreservedFull name, mctlhq/mctl-telegram. Never a URL.
mctl.issue.numberintreservedIssue the execution is acting on.
mctl.pr.numberintreservedPull request the execution produced or is acting on.
mctl.actor.typestringreservedgithub_user, service, schedule. Bounded set.
mctl.actor.idstringreservedWho triggered it. Subject to the privacy rules below.
mctl.trigger.typestringreservedgithub_issue, github_comment, schedule, manual.

Trace and span identifiers are not in this table on purpose: trace_id and span_id are part of the OTLP envelope, not attributes. Copying them into an attribute creates a second correlation scheme, which is the thing mctlhq/.github#55 exists to prevent.

Span attributes — version identity ​

Fixed per run by ADR-007. These make a trace answer "what exactly ran", not just "which service ran".

AttributeTypeStatusMeaning
mctl.agent.definition_versionstringreservedFrom the resolved release tuple.
mctl.agent.profile_versionstringreservedFrom the resolved release tuple.
mctl.agent.release_revisionstringreservedThe binding revision the tuple was resolved through.

ADR-007 pins all three atomically — there is no half-promotion — so a span carrying one of them must carry all three. A span with a definition version and no profile version describes a state the control plane does not produce.

Span attributes — edge and MCP identity ​

These are shipped today, by mctl-telegram internal/mcp/audit_span.go, from the same values already written to the audit row.

AttributeTypeStatusMeaning
mctl.edge.request_idstringshippedThe Cf-Ray of the leg that reached the upstream.
mctl.edge.routestringshippedportal or direct, decided by the presence of Cf-Worker. Unforgeable: Cloudflare strips client-supplied Cf-* at the edge.
mctl.tool.namestringshippedThe MCTL tool invoked.
mctl.tool.statusstringshippedOutcome of the tool call. Bounded set.
mctl.user.idintshippedThe upstream principal the call ran under.
mcp.methodstringshipped, non-conformingSee the divergence table.
mcp.namestringshipped, non-conformingSee the divergence table.
mcp.protocol_versionstringshipped, non-conformingSee the divergence table.

An attribute that was not captured is omitted rather than written empty. An absent attribute is itself information; an empty one is a value that reads as a measurement and is not one. Note this is a producer-side rule about what the code sets — the Collector's redaction never removes an attribute, so an absence in a backend is always the producer's decision, never redaction's.

Span attributes — incident agent ​

mctl-agent turns an alert into a ticket, collects evidence, ranks its skills, runs a diagnosis and may open a fix pull request (mctlhq/mctl-agent#38). Upstream has no model for any of these concepts, so they are mctl.*.

AttributeTypeStatusMeaning
mctl.ticket.idstringreservedThe mctl-agent ticket being processed. One per incident, so a join key.
mctl.ticket.typestringreservedThe ticket's classification, e.g. pod_crashloop, resource_limit, argocd_app_degraded. Bounded by the agent's classifier (internal/ticket).
mctl.skill.namestringreservedThe skill a span ran or evaluated, e.g. oomkilled, llm_diagnosis. Bounded by the skill registry (internal/skill/builtin).
mctl.diagnosis.confidencestringreservedHIGH, MEDIUM or LOW, as the skill reported it.
mctl.diagnosis.fixablebooleanreservedThe skill asserted that a fix can be proposed.
mctl.ticket.outcomestringreservedHow processing ended: pr_created, fix_proposed, escalated, failed. Bounded.

A ticket's target service is deliberately not in this table. The tenant and service an alert is about are not the producer's mctl.team/mctl.component; conflating the two would make every mctl-agent span look like it belongs to the service it was diagnosing. If that dimension becomes necessary, it gets its own reviewed names.

The fix pull request uses the existing mctl.repository.name and mctl.pr.number; model calls use gen_ai.* below.

Span attributes — DevLoop execution trace ​

The DevLoop's execution trace (mctlhq/mctl-agents#195, mctl-agents/docs/observability/execution-traces.md) uses the correlation identity above, gen_ai.* for model and tool spans, and these names for what upstream does not model. All are reserved: the emitting code is merged in mctl-agents orchestrator/tracing.py and orchestrator/temporal/activities/argo.py, but nothing exports in production until the bounded rollout under the producer amendment to the mctlhq/mctl-gitops#1280 soak (mctlhq/mctl-gitops#1788).

AttributeWhereTypeStatusMeaning
mctl.argo.workflow.phaseargo.workflow spanstringreservedThe terminal Argo phase, e.g. Succeeded, Failed, Error. Bounded by Argo.
mctl.github.operationgh / git command spansstringreservedA closed vocabulary, <group>.<subcommand> for gh (e.g. issue.comment, pr.create), api.read / api.write, or the git subcommand (clone, push, commit). Never argv.
mctl.github.mutationgh / git command spansbooleanreservedThe operation writes to GitHub. git commit is local and reads false; push reads true.
mctl.artifact.namemctl.artifact.write eventstringreservedA bounded file name, e.g. requirements.md. Never a path, never contents.
mctl.artifact.kindmctl.artifact.write eventstringreservedA bounded class, e.g. proposal.
mctl.policy.rule_idmctl.policy.decision eventstringreservedThe policy rule that decided.
mctl.policy.decisionmctl.policy.decision eventstringreservedThe verdict. Bounded.
mctl.policy.codemctl.policy.decision eventstringreservedThe reason code, never the free-text reason (which can quote an exception).
mctl.policy.versionmctl.policy.decision eventstringreservedThe policy version that decided.
mctl.policy.action_kindmctl.policy.decision eventstringreservedThe class of action being decided. Bounded.
mctl.policy.operationmctl.policy.decision eventstringreservedThe operation within that class. Bounded. The target it acts on is deliberately not recorded.
mctl.policy.approval_refmctl.policy.decision eventstringreservedThe approval receipt id, for a decision that went through a human. Omitted otherwise, per the producer rule above. A join key.
mctl.policy.approvermctl.policy.decision eventstringreservedWho approved. The same privacy class as mctl.actor.id above; see Privacy.
mctl.policy.decided_atmctl.policy.decision eventstringreservedWhen the approval was observed, which is not the event's own timestamp: the event is recorded later, in the pod. The one timestamp-valued attribute in this catalog, admitted for that reason only.
mctl.context.strategy.namelog fields todaystringreservedContext-assembly strategy (mctlhq/mctl-agents#527). Emitted as CONTEXT_STRATEGY_RELEASE / _COMPARE log fields, not yet on a span.
mctl.context.strategy.versionlog fields todaystringreservedAs above.
mctl.context.strategy.content_hashlog fields todaystringreservedAs above.
mctl.context.binding.revisionlog fields todaystringreservedAs above.
mctl.context.release.modelog fields todaystringreservedAs above.

Events, not spans, carry the artifact and policy names: an artifact write or a policy decision is a point in time inside the span that caused it.

OpenTelemetry mapping ​

Upstream defines MCP attributes in the mcp.* namespace. Three shipped MCTL attributes occupy that namespace with different spellings, which is worse than either conforming or using mctl.*: a consumer configured for the upstream convention sees nothing, and a consumer configured for MCTL sees names that look standard but are not.

Shipped todayUpstream conventionDisposition
mcp.methodmcp.method.nameRename to the upstream name.
mcp.protocol_versionmcp.protocol.versionRename to the upstream name.
mcp.name(no counterpart)Drop — mctl.tool.name is already shipped by the same helper and already carries this value, so nothing is lost and the dual-emit rule below does not apply. The mcp.* namespace is upstream's; MCTL should not extend it.
(not emitted)mcp.session.idReserved. Only meaningful on the legacy 2025-06-18 path, which mints a session; the modern 2026-07-28 path mints none.
(not emitted)mcp.resource.uriReserved. Subject to the privacy rules — a resource URI can carry a document path.

Renaming shipped attributes is a code change in mctl-telegram and is not part of this catalog. It is tracked as mctlhq/mctl-telegram#658.

Two caveats a reader should have before treating upstream as settled:

  • Both the mcp.* and gen_ai.* registries have moved out of open-telemetry/semantic-conventions into open-telemetry/semantic-conventions-genai. The entries left behind in the original repository are marked deprecated purely because of the move.
  • Both namespaces are still marked development, so names can change. That is precisely why MCTL pins a reviewed catalog with an explicit re-review trigger rather than tracking upstream implicitly.

For model calls, use gen_ai.* as upstream defines it — in particular gen_ai.operation.name, gen_ai.provider.name, gen_ai.request.model, gen_ai.usage.input_tokens and gen_ai.usage.output_tokens. Note that gen_ai.usage.prompt_tokens and gen_ai.usage.completion_tokens are the deprecated spellings; use the input/output pair. Upstream defines no cost or price attribute, so cost attribution — mctlhq/.github#48 Phase 1 — is computed from the token counts downstream, not emitted as an attribute.

AttributeTypeStatusMeaning
gen_ai.operation.namestringreservedUpstream convention, e.g. chat, invoke_agent. Bounded.
gen_ai.provider.namestringreservedUpstream convention. Bounded.
gen_ai.request.modelstringreservedThe concrete model. Bounded, and the dimension cost is grouped by.
gen_ai.usage.input_tokensintreservedToken count in. Exempted from redaction — see the tip below.
gen_ai.usage.output_tokensintreservedToken count out. Exempted from redaction — see the tip below.

These five retain reserved catalog status. The mctl-agents tracing code contains GenAI instrumentation; actual export depends on its runtime configuration and rollout gates.

Token counts survive the Collector as integers

The credential pattern reproduced below matches any key containing token. Until mctlhq/mctl-gitops#1332 was fixed, that masked every token counter and rewrote its integer value to the string ****.

The Collector now exempts exactly the GenAI usage counters through ignored_key_patterns, which the processor evaluates before every other rule and which leaves a matching attribute untouched — value and type:

^gen_ai\.usage\.((cache_read|cache_creation)[._])?(input|output|reasoning|prompt|completion|total)_tokens$

The pattern is anchored and enumerates counter names, so a credential-shaped key in the same namespace (gen_ai.usage.access_token) is still masked. A test in mctl-gitops (tests/test_otel_collector_redaction_e2e.py) runs the pinned Collector binary and checks that the counters arrive as integers. Cost attribution can build on them.

Privacy ​

These rules are enforced, not advisory. The Collector's redaction processor runs on every trace before export. The patterns below are reproduced from the deployed configuration:

(?i).*(authorization|cookie|api[-_]?key|token|secret|password|credential).*
(?i)^vault\..*
(?i)^gen_ai\.(prompt|completion).*
(?i).*\.messages$
(?i)^mcp\.tool\.(arguments|result)$
^db\.statement$
^http\.(request|response)\.header\..*

Credential shapes are additionally masked by value, regardless of the key they appear under: GitHub tokens, sk- keys, Vault hvs. tokens and JWTs. Only the matching part of the value is replaced, so the rest of a longer string survives.

What "redacted" actually does here ​

Worth being exact: the processor can either delete an attribute or mask its value, and the deployed configuration only ever masks.

  • Nothing is deleted. Deletion happens only to attributes missing from allowed_keys, and the deployed config sets allow_all_keys: true, which disables that path entirely.
  • A key matching blocked_key_patterns survives with its value replaced by ****. The attribute is still on the span.
  • A non-string value is destroyed, not preserved. The mask is written back with SetStr, unconditionally, so an integer attribute that matches a blocked pattern reaches the backend as the string **** — value and type both gone. redact_all_types does not rescue this. The flag does affect the read this branch uses — value.Str() becomes value.AsString() for both branches alike — but it cannot affect the outcome: the mask regex is .* and hash_function is unset, so the replacement is the fixed string **** whatever was read, and SetStr coerces the type either way. Turning it on changes what this branch sees, not what it writes. (It does change what the value-matching branch writes — that is why the flag exists — just not here.)
  • Masking is countable, not listed. summary: info adds redaction.masked.count and redaction.redacted.count to a span the processor touched. Key names are never listed (that would be debug).
  • Ignored keys skip every rule. A key matching ignored_key_patterns is not checked against the key patterns or the value patterns. Only the GenAI usage counters are ignored, and they are numeric by definition.

The consequences for a producer:

  • Never place meaningful data under a blocked name. It will arrive as ****, and nothing will report that it was replaced. A consumer sees a present attribute with a plausible-looking masked value, which is harder to notice than an absent one.
  • Prompts, completions and message bodies are out of traces by default.gen_ai.input.messages and gen_ai.output.messages both match .*\.messages$ and are masked. This is the epic's success criterion "private prompts, completions and Telegram message bodies remain out of traces by default", implemented rather than promised.
  • Tool arguments and results are out. mcp.tool.arguments and mcp.tool.result are masked. If a tool's shape is worth measuring, emit a bounded derived attribute — an argument count, a result status — not the payload.
  • Redaction is a backstop, not a design. It catches names nobody has invented yet, which is why it uses patterns rather than an exact key list. A producer must still not collect what it does not need.
  • The first pattern is broader than its intent, and the gap is closed by an exemption, not by narrowing it. .*token.* would catch the GenAI usage counters along with the credentials it is aimed at; the ignore list above carves out exactly those counters (mctlhq/mctl-gitops#1332). Any other key containing token is still masked, so do not name a measurement that way.

Personal data deserves its own line. mctl.actor.id, mctl.user.id and mctl.policy.approver identify a person. They are permitted because an audit trail that cannot name the actor is not an audit trail — but they must be the platform's identifier (a GitHub login, an internal user id), never an email address, a phone number or a Telegram handle, and never a customer's end-user identifier.

Cardinality ​

Attribute cardinality decides whether a backend can index a field or merely store it. Three cardinality classes follow, each stating what its members may be used for, plus a note on the measurements and per-decision values that fit none of them:

Bounded — safe to group and aggregate by.mctl.workflow.type, mctl.agent.name, mctl.actor.type, mctl.trigger.type, mctl.edge.route, mctl.tool.name, mctl.tool.status, mctl.repository.name, mctl.epic.name, mctl.work_item.id, mctl.ticket.type, mctl.skill.name, mctl.diagnosis.confidence, mctl.diagnosis.fixable, mctl.ticket.outcome, mctl.argo.workflow.phase, mctl.github.operation, mctl.github.mutation, mctl.artifact.name, mctl.artifact.kind, mctl.policy.rule_id, mctl.policy.decision, mctl.policy.code, mctl.policy.version, mctl.policy.action_kind, mctl.policy.operation, the five mctl.context.* attributes (a strategy's name, version and content hash, the binding revision and the release mode change only when a strategy or binding is released, the same reasoning as the release tuple that follows), the three version-identity attributes (mctl.agent.definition_version, mctl.agent.profile_version, mctl.agent.release_revision — a release tuple is bounded and changes only on promotion, so grouping by it is exactly the intended use), the MCP method and protocol version under both spellings — mcp.method and mcp.protocol_version as shipped today, mcp.method.name and mcp.protocol.version after mctlhq/mctl-telegram#658 — mcp.name, which is bounded because it duplicates mctl.tool.name, though group by mctl.tool.name instead since mctlhq/mctl-telegram#658 removes this one — the three bounded gen_ai.* attributes above, and every resource attribute. Each has a small, slowly-changing domain.

Unbounded but necessary — join keys, not grouping keys.mctl.execution.id, mctl.workflow.id, mctl.workflow.run_id, mctl.argo.workflow.name, mctl.edge.request_id, mctl.ticket.id, mctl.issue.number, mctl.pr.number, mctl.actor.id, mctl.user.id, mctl.policy.approval_ref, mctl.policy.approver, mcp.session.id, mcp.resource.uri. What these share is an unbounded domain, not a single cause — some are one per execution (mctl.execution.id, mctl.workflow.id, mctl.workflow.run_id, mctl.argo.workflow.name) or per incident (mctl.ticket.id), one is one per request (mctl.edge.request_id), one per session (mcp.session.id), and the rest are bounded only by how many issues, pull requests, people or resources exist (mctl.issue.number, mctl.pr.number, mctl.actor.id, mctl.user.id, mcp.resource.uri). Use any of them to retrieve a trace, never as a dashboard dimension.

gen_ai.usage.input_tokens and gen_ai.usage.output_tokens are neither: they are measurements, not dimensions and not join keys. Aggregate them, never group by them. mctl.policy.decided_at is neither as well: a value to read on one decision, never to group, aggregate or join by.

Must not become attributes. Anything unbounded that is not a join key: timestamps already carried by the span (mctl.policy.decided_at is a different moment from its event's own timestamp, and is the one admitted exception), free-form messages, commit diffs, file contents, retry counters that belong in metrics, and full URLs where a repository name and a number carry the same information in bounded form.

One volume note: the Collector already drops /healthz and /readyz spans outright. Do not work around that by renaming a health endpoint.

Changing this catalog ​

Adding, renaming or removing an attribute is a reviewed change to this page, in its own pull request, referencing the epic. Specifically:

  • Adding a reserved attribute requires the concept to have no upstream equivalent — rule 1 — and to be a join key or a bounded dimension, not a payload.
  • Promoting reserved to shipped requires naming the producer and the file that emits it, in the same change that ships it.
  • Renaming a shipped attribute is a breaking change for anything querying it. Emit both names for one release, then remove the old one; do not rename in place.
  • Re-review upstream when the semantic-conventions-genai registry marks mcp.* or gen_ai.* stable, or when any name used here changes there. That is the explicit trigger; nothing tracks it automatically.

References ​

  • Epic: mctlhq/.github#55 — vendor-neutral execution tracing
  • Telemetry gateway: mctlhq/mctl-gitops#902
  • Execution identity and trust model: mctlhq/mctl-agents#196
  • Release tuple: mctl-agents/docs/adr/007-agent-definition-execution-profile-contract.md
  • Edge correlation contract: mctlhq/mctl-telegram#617
  • Cost attribution, which consumes the token counters: mctlhq/.github#48
  • Backend selection: mctlhq/mctl-gitops#1280
  • Collector redaction and deployment.environment defects (fixed): mctlhq/mctl-gitops#1332, mctlhq/mctl-gitops#1724
  • mcp.* rename: mctlhq/mctl-telegram#658
  • Collector configuration: mctl-gitops, platform-gitops/bootstrap/templates/observability/otel-collector.yaml
  • Upstream: https://github.com/open-telemetry/semantic-conventions-genai