ADR-0022: LiteLLM requests carry the propagated trace tags¶
Status: Accepted Date: 2026-10-15 Deciders: Alex Nodeland Amends: relayr ADR-0024: its "LiteLLM's metadata doesn't carry the tags yet" (lattice #86)
Context¶
relayr ADR-0024 tags a run that a thread event started with thread:<id>, and a turn relayr started with rule:<name> and chain:<correlation id>. It does so through the Langfuse attributes each library propagates around a turn or run. The libraries' LiteLLMGateway builds a request's tags from its own fixed fields and its static tags=, so those tags reached Langfuse's traces but not LiteLLM's spend and logs. stackr RFC-0002 asks for the same tags in both.
Langfuse's propagate_attributes(tags=...) keeps the tags in OpenTelemetry's context, where the code running in the turn or run reads them. The Langfuse SDK has no public getter.
LiteLLM adds up each tag's spend per day, in a row for each tag and day (ADR-0019). A tag with an id for each thread or each chain adds rows without bound.
Decision¶
- A gateway adds the bounded trace tags Langfuse is propagating to a request's tags:
lattice_core.litellm.add_propagated_tags(tags)returns the request's own tags, then the propagated tags whose prefix is inPROPAGATED_TAG_PREFIXES, each once, in the order given and then held. The prefixes arerule:andkind:: as many as the application defines rules and artifact kinds.tenant:andworkspace:are not among them, since a request carries them already. thread:andchain:stay in Langfuse. Each thread and each chain would add a row to LiteLLM's tag spend per day, without bound. A thread's or a chain's cost is read from Langfuse, by the session and the tag.- The function reads the context key Langfuse 4 keeps the tags under,
langfuse.propagated.tags, through OpenTelemetry's API: no Langfuse import, so thelitellmextra needs onlyopentelemetry-apibeside pydantic-ai. - artifactr's and reflexr's gateways both call it, so they behave the same, and nothing changes for an application that sets no Langfuse tags. relayr changes nothing: its contexts already set them.
- A turn's tags are set by its
TurnContext. artifactr'sRunnerruns each turn in a context of its own, so a tag propagated aroundRunner.senddoes not reach the turn; theturn_contextargument does, as relayr'sbridged_turnsandlangfuse_turnuse it. reflexr's reactor runs a rule in the caller's context, and itsRunContextsets them the same way. - The key is Langfuse's internal one, and the lockfile's version is the verified range. It is built by
_get_propagated_context_keyinlangfuse/_client/propagation.py, and lattice-core'slangfuseextra,langfuse>=4.15,<5, locks to 4.16.0, which the tests ran against. The libraries' gateway tests set tags withpropagate_attributesand read them in the request, so a release that renames the key fails them. There is no runtime check.
Options considered¶
| Option | For | Against |
|---|---|---|
| Read the propagated tags from the context, those with a bounded prefix (chosen) | Automatic, for relayr and for any application's own contexts; no change for relayr; one function in lattice-core | Depends on Langfuse 4's context key, which its SDK doesn't document |
| The same, with every propagated tag | Simplest | thread: and chain: rows grow without bound in LiteLLM's tag spend |
| A callable on the gateway, returning extra tags for the current turn or run | No coupling to Langfuse | Each application wires it, and repeats what it already propagates for Langfuse |
| The libraries record the tags they propagate themselves | No coupling to Langfuse's key | relayr sets its tags around the libraries' contexts, not through them, so relayr would change too |
Consequences¶
- Easier: a rule's cost, and an artifact kind's, are LiteLLM's spend by tag as well as Langfuse's traces, with no wiring.
- Harder: a thread's or a chain's cost is read from Langfuse only, not from LiteLLM's spend.
- Harder:
rule:andkind:tags carry their names as they are, unlike ADR-0019's encoded tag: a name with a comma splits in a tag query.tenant:andworkspace:already behaved so. - Harder: a request lists a
rule:tag once only when the two spellings are the same. reflexr's ownrule:<name>and the one its Langfuse attributes propagate differ for a rule name with non-ASCII characters or over 200 characters, which the Langfuse tag limits (_limitturns the characters into?and truncates), so both appear. - Revisit: when Langfuse offers a getter for the propagated tags, read it there.