LinkMesh
LinkMesh Observability Data Collection Management
OpenTelemetry Telemetry Pipelines

Route Telemetry by Attribute

Send the right data to the right backend.

linkmesh.io
Roman Hüsler Roman Hüsler ← Back to blog
9 min read

Not every log line deserves the same backend. Error-level logs and the traces behind an SLO belong somewhere fast and expensive to query. Full-fidelity access logs and audit trails belong somewhere cheap, kept for compliance and rarely opened. Debug noise from a chatty sidecar belongs nowhere at all. Left alone, most fleets fan everything out to one destination anyway — because splitting it by hand means editing OTTL on every collector, keeping the copies in sync, and hoping nobody typos the condition on host 14.

This post is about routing telemetry by attribute: matching on what’s actually in the record — a severity, a namespace, a status code — and sending different matches to different places. We’ll cover how the OpenTelemetry Collector does this natively, how LinkMesh turns the condition into something you build instead of hand-write, and a second, easily confused mechanic — joining a destination to a route by label instead of wiring it in by hand.

What routing by attribute actually means

The OpenTelemetry Collector’s routing connector is the OTel-native mechanic: one receiver takes everything in, a table of conditions decides which downstream pipeline each record takes, and anything that matches nothing falls through to a default. We covered the same connector for splitting traffic by tenant in our multi-tenant Collector architecture guide — attribute routing is the same connector, evaluating a different kind of condition: not “which tenant owns this,” but “which destination does this record’s content earn.”

connectors:
  routing:
    default_pipelines: [logs/fallthrough]
    table:
      - context: log
        condition: severity_number >= SEVERITY_NUMBER_ERROR
        pipelines: [logs/archive]

service:
  pipelines:
    logs/in:
      receivers: [otlp]
      exporters: [routing]
    logs/archive:
      receivers: [routing]
      exporters: [otlphttp/archive]
    logs/fallthrough:
      receivers: [routing]
      exporters: [otlphttp/default]

That’s the whole mechanic: one condition, one match destination, one catch-all. The version LinkMesh renders and pushes to your fleet is richer — more than one condition, evaluated in order — but it’s the same idea: one stream in, conditions checked in priority order, a default catch-all for whatever matches nothing.

Step 1 — Routes evaluate in priority order, first match wins

The important thing to get right mentally, because it’s easy to over-imagine: a route list is not a declarative table where every row gets checked and every match fires. It’s a priority-ordered cascade — the same model Cribl uses. Route 1’s condition is checked first; if it matches, that record goes to Route 1’s destination and evaluation stops. If it doesn’t match, Route 2 is checked, then Route 3, and so on, until something matches or the record falls through to whatever you’ve set as the default.

Incoming telemetry Route 1 (priority 0) severity ≥ ERROR ? match Archive destination no match, next route Route 2 (priority 1) catch-all (fallthrough) Default destination

That ordering is also the safety valve. Put a specific, narrow condition first (route this one noisy namespace to /dev/null-equivalent storage) and a broad catch-all last, and you get exactly the override behavior you’d expect — without the ambiguity of “what happens when two rows both match,” because in a cascade only the first one that matches ever gets to.

Step 2 — Build the match filter without hand-writing OTTL

Every LinkMesh route carries a match filter compiled to OTTL under the hood, but you don’t start in a textarea. The route matcher is a field / operator / value picker: choose the field (a resource attribute, a log attribute, severity, status code, …), an operator (equals, contains, greater-than, exists, and so on), a value, and LinkMesh compiles it into the same condition syntax the routing connector evaluates.

The LinkMesh route matcher: field, operator, and value fields that compile to an OTTL condition, with an Advanced tab for raw OTTL.

Stacking a couple of fields with AND gets you most of what you need day to day — severity ≥ ERROR and service.namespace == "checkout", say. For anything past that — an OR across fields, a regex, a nested boolean expression — the builder has an Advanced tab that drops to raw OTTL. That’s a deliberate, honest trade-off: the guided builder covers the common single- and AND-condition case well; it isn’t trying to be a full visual expression editor, and expressions that need one still go through Advanced.

Step 3 — Give the route somewhere to go

A route without a destination just evaluates and drops the match on the floor, which is occasionally what you want (a route with no destination doubles as a documented drop rule) but usually isn’t. Point a route at one or more destinations and a match ships there; leave the priority list’s last route as a broad catch-all pointed at your default backend, and nothing goes unrouted by accident.

Step 4 — Verify it with real traffic

Config that looks right and config that behaves right are two different claims, so the last step is watching it work. LinkMesh’s topology canvas shows per-edge throughput — records per second on every branch — so a route split shows up as a number, not a hope:

The LinkMesh topology canvas showing a collector's routes splitting into two destination branches, with live per-edge throughput on each.

Send real traffic through and you can watch it happen: error-level records take the archive branch, everything else takes fallthrough, and the two edge counters prove the split rather than asserting it. (The same per-edge throughput view is what we lean on for measuring where observability spend actually goes — routing and cost control are the same muscle pointed at different goals.)

Step 5 — A second axis: joining destinations by label

Everything above is one axis — which telemetry goes where, decided by matching the record’s content. There’s a second, separate axis: which destinations a route’s match feeds, decided by matching a destination’s tags instead of the telemetry.

Tag a destination once —

The destination editor in LinkMesh: a "tier: archive" tag added to a destination.

— and any route whose destinationSelector matches that tag picks it up automatically, without you going back and wiring the destination into every route by hand. New destination shares the tag, it joins every matching route; retag it, it drops out. The canvas visualizes the join as a dashed ghost edge so you can see it without digging into config:

A dashed ghost edge on the LinkMesh canvas showing a route auto-joined to a destination through a matching tag.

destinations:
  - name: cold-archive
    type: otlphttp
    endpoint: https://archive.internal:4318
    tags:
      tier: archive

routes:
  - name: errors-to-archive
    priority: 0
    match: 'severity_number >= SEVERITY_NUMBER_ERROR'
    destinationSelector:
      tier: archive
  - name: fallthrough
    priority: 1
    match: 'true'
    destinations: [default-egress]

Two honest limits on this, worth knowing before you lean on it. First, matching is AND-equality only — a selector matches a destination whose tags equal what you specified, on every key you list. There’s no In, NotIn, or “tag exists” operator today. Second, there’s no UI yet for hand-authoring a custom selector; you set it via the API or in the entity YAML, and the canvas’s job is to visualize the resulting join, not to build it. If you need something more elaborate than “route X wants any destination tagged tier=archive,” today that’s still a config file, not a form.

Don’t conflate the two axes

It’s easy to blur these together because they both use the word “route,” so it’s worth saying plainly: the match filter decides which telemetry a route accepts; the destination selector decides which destinations that route’s matched telemetry fans out to. One reads the record’s content. The other reads a destination’s tags. A route can have a narrow content match and a broad label-selected fan-out, or the reverse — they’re independent knobs, not two names for the same thing.

Where to start

  1. List your destinations by value, not by habit — which telemetry is actually queried, and which just needs to sit somewhere cheap for compliance.
  2. Write the narrow routes first, the catch-all last — priority order is the whole safety model, so specific conditions belong ahead of broad ones.
  3. Build the match with the field/operator/value picker; reach for Advanced OTTL only when a condition genuinely needs it.
  4. Tag destinations instead of hand-wiring them into every route that should reach them, and let the canvas’s ghost edges show you the result.
  5. Watch the per-edge throughput after you ship it — a route that looks right on paper still has to prove itself in records per second.

Splitting telemetry by what it actually is — not shipping everything to one backend because that’s the path of least resistance — is most of what turns a pipeline from a pass-through wire into something that earns its place between your services and your bill. (For where routing fits among the rest of a pipeline’s jobs, see our telemetry pipeline guide; for how LinkMesh compares to Cribl’s routing model specifically, see our Cribl alternatives rundown.)

Sending everything to one backend because splitting it by hand isn't worth the effort?

LinkMesh routes telemetry by attribute with a field/operator/value builder (Advanced OTTL when you need it), joins destinations to routes by label instead of manual wiring, and shows you per-edge throughput so a route split is a number you can verify, not a hope. Stand one up in minutes, or see what it does.