How a 'Consistency Fix' in Program.cs Silently Broke Label Promotion Across Mimir and Loki

Renaming an OTel resource attribute from deployment.environment to deployment_environment in application code looked like the fix for inconsistent labels across Grafana Cloud's three signals. It's backwards: Mimir and Loki both promote and convert dotted attribute names on ingestion, so a pre-converted attribute silently fails to match and drops out of both label sets. The real fix belongs in the Alloy pipeline, scoped to traces only.

· 15 min read

How a 'Consistency Fix' in Program.cs Silently Broke Label Promotion Across Mimir and Loki

An engineer noticed deployment.environment showing up as a dotted key on trace resource attributes in Tempo, while the equivalent Prometheus label on the same service’s metrics was deployment_environment. The instinct was reasonable: pick one spelling, standardize it in the OTel SDK, ship it everywhere. So the fix went into Program.cs — the resource attribute was renamed to the underscore form at the source, on the theory that Prometheus can’t have dots in label names anyway, so why not just emit the form every backend supposedly wants.

That fix made things worse, and not in a way that throws an error. It fails silently, which is the dangerous kind of failure. Mimir and Loki both already do dot-to-underscore conversion on ingest — but the promotion logic in both systems is keyed on matching the dotted form against a static list. Feed it the underscore form directly and it doesn’t match, doesn’t promote, and quietly falls out of the label set it was supposed to join. The mechanism that was supposed to guarantee consistency across signals is exactly what the fix broke.


TL;DR

Mimir and Loki both convert dotted OTel resource attribute names to underscore form on ingestion — but the promotion/matching logic in both is keyed on the literal dotted attribute key being present in the incoming OTLP payload. Pre-converting to underscore in application code doesn’t give you consistency; it gives you an attribute that matches neither promotion list and likely gets dropped as an unindexed field. Tempo has no such conversion mechanism at all — it passes resource attribute keys through exactly as received, so it’s the only one of the three signals that actually needs intervention. The fix: keep deployment.environment (dotted, OTel semantic-convention correct) at the SDK, and add a single targeted rename in the Alloy pipeline, scoped to the traces path only. Mimir and Loki keep working exactly as they already do; Tempo gets brought into line without touching a single line of application code.


The Problem

Grafana Cloud’s three signal backends handle OTel resource attribute naming in three genuinely different ways, and none of the three docs pages that describe this individually tell you the combined story.

Here’s the mechanism worth walking through step by step, since it’s the detail the rest of this section builds on.

Stage 1 — match. Both Mimir and Loki hold a static, hardcoded list of attribute names each is willing to promote, written in the dotted OTel semantic-convention spelling — deployment.environment, service.name, k8s.cluster.name, and so on. When a payload arrives, the promotion logic runs a literal string comparison against that list: is deployment.environment present in this resource’s attributes, spelled exactly that way?

Stage 2 — convert. Only after an attribute survives stage 1’s match does mimir-otlptranslator (or Loki’s equivalent) rewrite it from dots to underscores for storage as a label or index field.

Stage 1: MATCH             Stage 2: CONVERT
(literal string compare)   (dot → underscore substitution)

The SDK rename breaks stage 1, not stage 2. Emitting deployment_environment directly looks like it’s producing the output stage 2 would have produced anyway — but it never passes through stage 1 to get there. The match check is doing "deployment_environment" == "deployment.environment", which is false: the list has one entry, spelled with a dot, and the incoming key no longer has one. Since the match fails, the attribute is never even considered for promotion. Stage 2 doesn’t fail to convert it — stage 2 never runs at all, because stage 1 gates access to it.

This is also why the failure is worse than an ordinary error. An unmatched resource attribute isn’t an error condition to Mimir or Loki — it’s just an ordinary, non-promoted resource attribute. There’s no rejected-sample counter, no ingestion warning, nothing written to a log. From the system’s point of view nothing went wrong; it simply didn’t recognize this attribute as one of the special ones to lift into the label or index set. The data isn’t lost — it’s still ingested and stored, as unindexed structured metadata in Loki’s case, or simply absent from the label set in Mimir’s case — only its promotion status is. A query like count by (deployment_environment) (up{...}) in Mimir, or a Loki stream selector {deployment_environment="production"}, just quietly returns nothing for that service, while every other correctly-spelled attribute keeps working — which is what makes it look like a per-service instrumentation gap rather than a spelling mismatch against a list most application engineers don’t know exists, since it’s a platform-team-owned distributor flag, not something visible from application code.

Mimir promotes selected OTLP resource attributes to Prometheus labels via -distributor.otel-promote-resource-attributes (YAML: promote_otel_resource_attributes), a distributor-level flag holding a static list of attribute names. Once an attribute is on that list and present in the incoming resource attributes, it’s handed to mimir-otlptranslator, which performs the dot-to-underscore substitution — deployment.environment becomes the label deployment_environment. The promotion step and the character-substitution step are two separate stages, and the first stage is a literal string match against the list.

The default promotion list and the label each entry becomes after mimir-otlptranslator runs:

OpenTelemetry resource attribute (dotted)Prometheus label after normalization
service.instance.idservice_instance_id (also mapped to instance)
service.nameservice_name (also contributes to job)
service.namespaceservice_namespace
service.versionservice_version
cloud.availability_zonecloud_availability_zone
cloud.regioncloud_region
container.namecontainer_name
deployment.environmentdeployment_environment
deployment.environment.namedeployment_environment_name
k8s.cluster.namek8s_cluster_name
k8s.container.namek8s_container_name
k8s.cronjob.namek8s_cronjob_name
k8s.daemonset.namek8s_daemonset_name
k8s.deployment.namek8s_deployment_name
k8s.job.namek8s_job_name
k8s.namespace.namek8s_namespace_name
k8s.pod.namek8s_pod_name
k8s.replicaset.namek8s_replicaset_name
k8s.statefulset.namek8s_statefulset_name

Note that deployment.environment and deployment.environment.name are both on the list — Mimir matches whichever spelling your SDK actually emits, but each is a distinct list entry, not interchangeable aliases of each other.

Loki does the identical shape of conversion for logs, which surprises people who’ve internalized “labels are a metrics thing.” default_resource_attributes_as_index_labels ships with an explicit list of dotted semantic-convention names — service.name, deployment.environment, deployment.environment.name, k8s.cluster.name, and others — and converts each to its underscore index-label form automatically, the same two-stage shape as Mimir: match the dotted key, then convert.

Tempo has no comparable mechanism. There’s no label storage model in Tempo the way there is in Mimir or Loki’s index — resource and span attributes are stored and queried as-is. Whatever key arrives in the OTLP payload is the key TraceQL sees.

The failure mode only appears when you look at all three at once. Both Mimir’s and Loki’s promotion lists are matched against the literal incoming key, and every entry on both lists is written in the dotted form because that’s the OTel semantic-convention spelling. If the resource attribute arrives pre-converted to deployment_environment, it fails the match on both lists. It doesn’t error. It doesn’t warn. It falls through as an unindexed resource attribute in Loki (present in the log line’s structured metadata but absent from the stream selector), and in Mimir it’s simply absent from the metric’s label set entirely, because there’s no unindexed fallback for a distributor-promoted label — it just isn’t promoted.

flowchart LR
    subgraph Correct["Correct: SDK emits the dotted, semconv form"]
        direction TB
        A1["Resource attribute<br/>deployment.environment"]
        A2{"Promotion list match?<br/>list entry: deployment.environment"}
        A3["MATCH"]
        A4["mimir-otlptranslator /<br/>Loki index-label converter runs<br/>dot-to-underscore substitution"]
        A5["Promoted<br/>deployment_environment<br/>(Prometheus label / Loki index label)"]
        A1 --> A2
        A2 -->|dotted key present| A3
        A3 --> A4
        A4 --> A5
    end

    subgraph Wrong["Anti-pattern: SDK pre-converts to underscore"]
        direction TB
        B1["Resource attribute<br/>deployment_environment"]
        B2{"Promotion list match?<br/>list entry: deployment.environment"}
        B3["NO MATCH<br/>underscore key isn't on the list"]
        B4["Falls through as an ordinary,<br/>non-promoted resource attribute"]
        B5["Silently absent from the<br/>label set / stream selector —<br/>no error, no warning"]
        B1 --> B2
        B2 -->|underscore key present| B3
        B3 --> B4
        B4 --> B5
    end

    classDef good fill:#C7F0DB,stroke:#4C9A78,stroke-width:1px,color:#1B3B2C
    classDef bad fill:#FFC9C9,stroke:#C4514F,stroke-width:1px,color:#4A1414
    classDef decision fill:#FFF3B0,stroke:#C9A227,stroke-width:1px,color:#4A3B00

    class A1,A4,A5,A3 good
    class B1,B4,B5,B3 bad
    class A2,B2 decision

The two paths diverge at exactly one point — the promotion-list match — and that point produces no observable signal either way. A match promotes silently; a miss drops silently. Nothing short of explicitly checking for the label’s presence distinguishes the two at runtime.


Failure Chain

Phase 1: SDK emits the “consistent” form. The OTel resource builder is configured to set deployment_environment (underscore) instead of the semantic-convention deployment.environment (dotted). This looks correct in isolation — the attribute shows up fine in any tool that reads resource attributes verbatim, including a local OTel Collector debug exporter or a raw trace payload inspector.

Phase 2: Mimir’s and Loki’s promotion lists silently fail to match. Both systems check the incoming resource attributes against their respective static, dotted-form lists during ingestion. deployment_environment (already underscored) is not a member of either list — the entry on the list is deployment.environment. The match fails. Neither system treats this as an error condition; there’s no rejected-sample counter incrementing, no ingestion warning logged. The attribute is processed as an ordinary, non-promoted resource attribute.

Phase 3: The label disappears from queries, but the data doesn’t. In Mimir, the attribute never becomes a label, so any PromQL query filtering or grouping by deployment_environment returns nothing for series from this service — while every other resource attribute on the promotion list keeps working normally, making the gap look like a per-service instrumentation bug rather than a naming-format mismatch. In Loki, the attribute is present as structured metadata on the log line but absent from the indexed label set, so {deployment_environment="production"} as a stream selector returns zero results even though the log line exists and can be found by full-text search.

Phase 4: Tempo shows a technically-present but non-standard key. Tempo takes whatever it’s given — in this case, deployment_environment — and stores it verbatim. No cross-check happens against OTel semantic conventions, no rejection, nothing else observes the discrepancy. The result after all four phases: traces carry a non-standard resource-attribute key, and metrics/logs have silently lost a label they previously had. Three signals now disagree with each other in the exact place the fix was meant to make them agree.


Why It Persists

The half-truth that drives this fix is nearly always the same sentence: “Prometheus doesn’t allow dots in label names, so I should just emit the underscore form everywhere.” That’s true as a statement about Prometheus’s own label syntax — but it’s not a reason to pre-convert upstream of a system whose entire job is to perform that conversion for you. The translation step exists precisely so the SDK doesn’t have to guess a per-backend spelling; short-circuiting it doesn’t save the translation, it just breaks the lookup that triggers it.

The second reason this keeps happening: when an engineer notices deployment_environment missing from a Mimir label set or a Loki stream selector, the fix they reach for is the layer they control — application code — because that’s the layer that’s visible in their own repo, in their own PR review, in their own CI pipeline. The Mimir distributor flag and the Loki index-label config live in platform-team-owned Terraform or Helm values, one or two repos away, and checking them first requires knowing they exist. Ownership boundaries route the fix to the wrong layer even when the wrong layer is the one that’s easiest to change.


Anti-Pattern Code

// Context: OpenTelemetry .NET SDK resource configuration — the "consistency fix" attempt

// [WRONG] pre-converts a semconv-correct dotted key to underscore before it ever
// reaches Mimir or Loki, whose promotion lists match on the dotted form
var resourceBuilder = ResourceBuilder.CreateDefault()
    .AddService(serviceName: "checkout-api")
    .AddAttributes(new Dictionary<string, object>
    {
        // Looks reasonable in isolation: "Prometheus can't have dots in labels,
        // so ship the underscore form and be done with it." This is the line that
        // silently drops the label from both Mimir's and Loki's promotion lists —
        // neither list has an entry spelled "deployment_environment".
        ["deployment_environment"] = "production",
    });

var tracerProvider = Sdk.CreateTracerProviderBuilder()
    .SetResourceBuilder(resourceBuilder)
    .AddAspNetCoreInstrumentation()
    .AddOtlpExporter()
    .Build();

The danger here is specifically that this code compiles, runs, exports spans successfully, and looks correct under a debug exporter that prints raw resource attributes verbatim. Nothing about the local development loop surfaces the problem — it only appears once the payload reaches Mimir’s distributor and Loki’s ingester and fails a list match neither system reports as an error.


Correct Design

Principle: own the OTel semantic-convention-correct dotted attribute name at the SDK boundary. Let each backend’s own ingestion-time translator do the job it was built for. Intervene only at the one layer that has no translator of its own — Tempo, via the collector.

OptionSDK attribute formTempoMimirLokiVerdict
A: dotted in SDK, no pipeline changedeployment.environmentShows the dotted key — no other signal uses this spellingPromotes correctlyPromotes correctlyTwo of three consistent; Tempo is the outlier
B: dotted in SDK + Alloy transform on traces only (chosen)deployment.environmentRenamed to deployment_environment by AlloyUnaffected, already correctUnaffected, already correctAll three consistent; zero application code changes
C: dotted in SDK + Alloy transform on all three signal pathsdeployment.environmentRenamedRedundant — Mimir’s own translator would do this anywayRedundant — Loki’s own translator would do this anywayWorks, but doubles the maintenance surface for no additional consistency

Option B is the minimum-surface-area fix: it touches exactly the one signal path that has no built-in translation, and leaves the two paths that already work untouched.

flowchart TD
    OTLP["otelcol.receiver.otlp<br/>'default'"]
    OTLP -->|metrics| Batch["otelcol.processor.batch<br/>'default'"]
    OTLP -->|logs| Batch
    OTLP -->|traces| Transform["otelcol.processor.transform<br/>'rename_resource_attrs'<br/>(traces only)"]
    Transform --> Batch
    Batch --> Exporter["otelcol.exporter.otlphttp<br/>'grafana_cloud'"]
    Exporter --> Mimir["Mimir<br/>own dot-to-underscore translator<br/>(deployment.environment untouched, unaffected)"]
    Exporter --> Loki["Loki<br/>own dot-to-underscore translator<br/>(deployment.environment untouched, unaffected)"]
    Exporter --> Tempo["Tempo<br/>no translator<br/>(receives deployment_environment,<br/>renamed by Alloy)"]

    classDef pipeline fill:#FFD6E8,stroke:#C77DA3,stroke-width:1px,color:#3D1A2B
    classDef highlight fill:#FFF3B0,stroke:#C9A227,stroke-width:2px,color:#4A3B00
    classDef backend fill:#C7F0DB,stroke:#4C9A78,stroke-width:1px,color:#1B3B2C
    classDef needsfix fill:#D8CFF7,stroke:#8B72C7,stroke-width:2px,color:#2E2150

    class OTLP,Batch,Exporter pipeline
    class Transform highlight
    class Mimir,Loki backend
    class Tempo needsfix
# Context: Grafana Alloy River config — rename applied only on the traces path

otelcol.receiver.otlp "default" {
  http { }

  output {
    metrics = [otelcol.processor.batch.default.input]
    logs    = [otelcol.processor.batch.default.input]
    // [CORRECT] traces are the only signal routed through the rename —
    // metrics and logs go straight to batch because Mimir and Loki already
    // handle the dotted form correctly on their own ingestion path
    traces  = [otelcol.processor.transform.rename_resource_attrs.input]
  }
}

otelcol.processor.transform "rename_resource_attrs" {
  // error_mode = "ignore" means a missing or malformed attribute drops through
  // without failing the pipeline — acceptable here because the statements are
  // idempotent no-ops when the dotted key isn't present, not a silent data-loss risk
  error_mode = "ignore"

  trace_statements {
    context    = "resource"
    statements = [
      `set(attributes["deployment_environment"], attributes["deployment.environment"])`,
      `delete_key(attributes, "deployment.environment")`,
    ]
  }

  output {
    traces = [otelcol.processor.batch.default.input]
  }
}

otelcol.processor.batch "default" {
  output {
    metrics = [otelcol.exporter.otlphttp.grafana_cloud.input]
    logs    = [otelcol.exporter.otlphttp.grafana_cloud.input]
    traces  = [otelcol.exporter.otlphttp.grafana_cloud.input]
  }
}

The set / delete_key pair matters: set copies the value under the new key without touching the original, and delete_key removes the dotted key afterward so Tempo doesn’t end up with both spellings sitting side by side as separate resource attributes. Skipping the delete_key step is a common half-fix that leaves Tempo technically correct but doubled up.


Validation Test

This was confirmed against real ingested data during POC rollout, not just inferred from docs. Five checks, run directly against Grafana Cloud — each isolating one stage of the pipeline so a pass on one signal can’t hide a failure on another.

Loki — confirms deployment_environment is present as an index label (a stream selector), not merely as unindexed structured metadata. Returns zero results if promotion failed:

{deployment_environment="production", service_name="checkout-api"}

Mimir — confirms the label was promoted onto a metric series for this service. An empty result set means the attribute never matched Mimir’s promotion list:

count by (deployment_environment) (up{service_name="checkout-api"})

Tempo — confirms the Alloy rename applied before export:

{ resource.deployment_environment = "production" && resource.service.name = "checkout-api" }

And confirms the original dotted key was actually removed by delete_key, not left behind alongside the renamed one. TraceQL requires quoting an attribute name that contains a literal dot, since an unquoted dot is parsed as a path separator — this should return zero traces once the Alloy transform is applied:

{ resource."deployment.environment" = "production" }

Alloy — an end-to-end bookend confirming the transform stage under error_mode = "ignore" isn’t silently dropping spans. The OTel Collector’s documented per-processor accepted/refused span counters are specific to the memory_limiter processor, not universal across every processor type — so rather than assume an equivalent counter exists for the transform component, compare the pipeline’s own receiver and exporter bookends directly. A sustained non-zero gap means spans are being lost somewhere between receipt and export, and the transform stage is the first place to check:

sum(rate(otelcol_receiver_accepted_spans{receiver="otlp/default"}[5m]))
  -
sum(rate(otelcol_exporter_sent_spans{exporter="otlphttp/grafana_cloud"}[5m]))

Run all five together, not just the one that matches the signal you think is broken — the whole point of this failure mode is that it looks fine on any single signal checked in isolation.


Key Metrics

SignalExact nameWhat it tells you
Mimir promotion config-distributor.otel-promote-resource-attributesStatic, distributor-level list of dotted attribute names Mimir matches before invoking mimir-otlptranslator’s dot-to-underscore substitution
Loki promotion configdefault_resource_attributes_as_index_labelsSame two-stage shape — dotted-name list, then underscore conversion — documented per Loki’s OTel ingestion config
Alloy pipeline health (end-to-end)otelcol_receiver_accepted_spans vs. otelcol_exporter_sent_spansA sustained gap between receiver-accepted and exporter-sent trace counts means the transform stage (or something else downstream) is dropping spans under error_mode = "ignore"
Tempo resource attribute presenceTraceQL resource.deployment_environment vs. resource."deployment.environment"Confirms which spelling actually reached Tempo — the dotted form present here means the Alloy rename isn’t applying

If the exact list contents for a given Grafana Cloud stack aren’t visible from the tenant side, that’s a real limitation, not an approximation — the promotion list is provisioned at the distributor/Loki config layer, which is not always exposed per-tenant in the Grafana Cloud console.


Pattern Generalization

The underlying shape: multiple independent consumers each apply their own normalization step, keyed on a specific, documented input format. Pre-normalizing upstream of all of them doesn’t buy consistency — it produces a shape none of the consumers recognize as the one they know how to convert, because their matching logic was written against the original spec form, not against whatever normalized form you picked.

The same failure shape shows up in Kafka topic-naming conventions when producer teams pre-sanitize topic names to match what they assume a downstream consumer’s routing rules expect, breaking the consumer’s actual pattern-match rules in the process. It shows up in DNS and HTTP header handling, where case-folding is supposed to happen at the resolver or server layer — a client that pre-lowercases a header name it assumes needs it can break a downstream system that matches on the original case for an unrelated reason. And it shows up in Kubernetes label versus annotation key rules, where a value normalized to fit label syntax constraints upstream of a controller that expects the annotation’s original, unconstrained form will fail that controller’s selector match silently.

The generalizable rule: normalize at the layer that owns the matching logic, not at the layer that merely produces the data. If you don’t control every consumer’s matching rules, you can’t safely guess a single upstream normalization that satisfies all of them.


Production Incident

This surfaced during a Grafana Cloud Application Observability rollout, not as a dashboard outage but as a quieter kind of drift: a query that filtered by deployment_environment worked for some services and returned nothing for others, with no error anywhere in the pipeline. The debugging path went from “is this an instrumentation gap” to “is this a query problem” before landing on the actual cause — the promotion-list mechanism in Mimir and Loki, and the fact that both are keyed on the dotted attribute form specifically. Reading the actual ingestion-time translation logic (rather than assuming “Prometheus doesn’t like dots” was the whole story) is what reframed the fix from application code to the collector layer. The fix has since been validated against live Loki and Mimir data in a proof-of-concept environment using the exact queries in the Validation Test section above. No blast-radius or MTTR figures are cited here deliberately — the mechanism is the load-bearing part of this story, not a manufactured timeline.


MAANG-Scale Considerations

At the scale of a handful of services, this is a one-time fix in one Alloy config. At hyperscale — hundreds or thousands of services, heterogeneous OTel SDK versions, multiple teams shipping instrumentation independently — two things change.

First, Mimir’s resource-attribute promotion list is a limit that can be overridden per-tenant via runtime configuration — but “tenant” in Grafana Cloud maps to an org/stack, not to an individual team or service. A fleet of hundreds of services across dozens of teams sharing one Grafana Cloud stack still shares one promotion list. SDKs on different semantic-convention versions — some still emitting deployment.environment, others migrated to the newer deployment.environment.name — can’t diverge on promotion policy within that shared tenant; the list has to be a superset that satisfies every team on the stack, which pushes attribute-naming governance toward a centralized schema-registry model rather than ad hoc fixes. This is the same problem Avro and Protobuf schema registries solve for Kafka producers and consumers, applied to resource-attribute naming instead of message schemas — and OTel already has the primitive for it in schema_url, which most collector pipelines at this scale under-use.

Second, every attribute added to a promotion list is a new label — and a new label on a high-cardinality resource attribute is a cardinality budget decision, not a free consistency win. At MAANG scale, promoting an attribute without first estimating its cardinality contribution across every service and environment combination is how a well-intentioned naming fix turns into an ingest-cost regression that isn’t attributed back to this change until someone audits the next billing cycle.


Summary Table

DimensionAnti-pattern (SDK rename)Correct design (Alloy rename, traces only)
ApproachRename attribute to underscore form in application codeKeep dotted form in SDK; rename only on the traces path in Alloy
Failure modeSilently fails Mimir’s and Loki’s promotion-list matchNone — Mimir/Loki untouched, Tempo receives the renamed key
Detection signalMissing label in PromQL/LogQL queries, no error logged anywhereConfirmed present via LogQL/PromQL/TraceQL checks in Validation Test
Operational costRequires an application redeploy per service to fix or revertSingle Alloy config change, applies fleet-wide with no app changes
Fix complexityLow effort, high blast radius (touches every service’s code)Low effort, zero blast radius on application code
Stack-specific noteBreaks two working ingestion paths to “fix” a thirdTouches exactly the one signal (Tempo) with no built-in translator

Conclusion

Audit any OTel resource-attribute renames currently living in application code today. If any exist purely to “match what Prometheus expects,” revert them to the OTel semantic-convention dotted form and push the translation into the collector layer — scoped only to the signal path that actually lacks a built-in translator. Don’t guess a single upstream spelling to satisfy multiple independently-normalizing downstream consumers; check what each one actually matches on first.

Discussion