OTLP attribute contract¶
Rules for naming and placing custom OTLP attributes, written to be referenced by every
exporter writing into the same Loki tenant - this one, tailscale2otel and graph2otel.
The rules are not specific to OPNsense; they exist because Loki's promotion model has
three properties that are easy to get wrong, and two of them have already produced a
broken tenant config.
The one structural rule¶
Only OTLP resource attributes can become index labels. otlp_config has an
index_label action under resource_attributes and nowhere else; scope_attributes
and log_attributes accept structured_metadata and drop only. A log attribute is
therefore unindexable by construction, not by configuration - no tenant setting makes it
a label.
This is the whole reason placement matters more than naming. An attribute on the record is a query-time filter forever; an attribute on the resource can be promoted later.
A non-promoted resource attribute is not lost - it lands in structured metadata, exactly where a log attribute would. So moving a key onto the resource is query-compatible until someone promotes it, which is what makes "hoist now, promote later" safe.
The two limits, which are not the same limit¶
These get conflated, and conflating them produces an imaginary scarcity that drives bad design.
| Limit | What it bounds | Shared between exporters? |
|---|---|---|
max_label_names_per_series, default 15 |
Label names on a single stream. Enforced by the distributor at ingest; breach is a non-retryable 400. |
No |
| Grafana Cloud self-serve cap, recorded as 30 | Length of the tenant's promoted-attribute list. | Yes |
The 15 is per stream. A tailscale2otel stream never carries an opnsense_* label, so
promoting opnsense.* costs the other exporters nothing and each gets its own 15.
What is shared is the tenant's attributes_config list. Adding entries to it is
additive and cannot affect another exporter's streams, but it is one list, so edits to it
are a coordinated change. The 30 is recorded from the self-serve API rather than
re-verified on every read; the arithmetic below has enough headroom that it does not
turn on the exact figure.
Naming rules¶
- Namespace every custom resource attribute under an emitter prefix -
opnsense.*,tailscale.*,graph2otel.*. - Never bare, unnamespaced names (
source,action,interface). They collide with future semconv, and a first draft of this tenant's config reached for exactly these. - Never squat a semconv namespace with a different meaning. semconv
device.*means the device the telemetry originates from. Using it for an observed network client is a reserved name carrying a foreign meaning - worse than a private namespace. Using a genuine semconv name where the meaning actually matches is fine and preferred. - Emitter-specific, not shared. Two exporters do not share a dimension unless the
values mean the same thing. A unified
subsystemmatching both an OPNsense DNS record and something else's leavesservice.nameas the only disambiguator, which is what you already had. - Only promote closed, code-defined sets. Anything that arrives off a wire is
attacker-influenceable cardinality.
programcomes off syslog and any process can pick its own tag withlogger(1), so it stays on the record.
Tenant config rules¶
Names in the config are dotted, as sent. Loki matches the OTLP attribute name and
does the dot-to-underscore conversion itself on the way to the label. The config says
opnsense.source and service.instance.id; opnsense_source and service_instance_id
match nothing.
ignore_defaults: true requires re-listing service.name and service.instance.id.
Both are on the distributor's default promotion list, which is why they are labels with
no config at all. Dropping the defaults without re-listing them removes them as labels
and breaks every existing query against the tenant - a strictly worse outcome than the
new labels simply failing to appear.
PUT fully replaces the tenant otlp_config. GET, merge, PUT. Never a blind partial
write. Changes return pending and can take a couple of business days to become
applied.
A key's presence in series output is proof of promotion, because series returns
stream labels only and structured metadata cannot appear there:
Check the deployed build first, or "not promoted" and "not deployed" are indistinguishable.
Hoisting is not free¶
An OTLP resource binds to a LoggerProvider, not to a record. So N distinct values of a
shape dimension means N LoggerProviders. In this repo that is the machinery in
internal/logship/sink_otlp.go: a provider map keyed on resourceKey, one shared
exporter so partitioning costs no extra connections, deterministic per-partition flush
ordering, a maxLogResources cap, and a degrade path that drops the custom attributes
once the cap is hit.
Adopting the naming is free. Hoisting a dimension onto the resource is not, and any exporter wanting a promoted label needs that pattern rather than a rename. Weigh it per repo against what a promoted label actually buys - which is query scan, never ingest cost.
Current registry¶
| Exporter | Custom resource attributes | Promoted |
|---|---|---|
opnsense-exporter |
opnsense.source, opnsense.subsystem, opnsense.action, opnsense.device_category, opnsense.interface |
all five |
tailscale2otel |
none | none |
graph2otel |
none | none |
The siblings' domain attributes are log attributes, so they are unindexable as they stand. Neither is blocked on naming; both would need the hoisting work above first.
Widest stream this exporter produces carries seven label names - the five above plus
service_name and service_instance_id - against a per-stream limit of 15.
Decision record¶
Per-emitter namespaces, no shared namespace, and no migration of opnsense.*.
The case for a shared namespace rested on the three exporters competing for a tenant-wide pool of ~15 promotion slots. They do not: the 15 is per stream, so the pool was never shared and the per-emitter approach scales past the third exporter and past the tenth. Without that pressure, a shared namespace buys cross-exporter selector ergonomics and costs a dual-label transition on a live tenant for the only repo with promoted labels - 13 stream selectors, and every historical line keeping the old label until retention rolls over.
Reconsider only if the exporters ever need to be queried as one stream set, which is a different requirement from the one that motivated this.