> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vexlo.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Tags and cross-service correlation

> Declare tag keys at init(), add tags from inside the root, and relate traces across services by a shared key.

A tag is a key/value pair on a trace's `trace-open` record. Tags are how a trace is found
later, by an order id, a customer id, or an OpenTelemetry trace id, and how two traces on two
services are recognised as the same request. A tag never implies a call between services:
sharing a tag means sharing a request.

Tags attach to the trace, never to a step, because whoever searches for a request later does
not know which function was executing when it happened.

## Two ways to set a tag

**Declare keys at `init()`.** `tag_keys=[...]` names the arguments to harvest. When a trace
opens, the SDK looks for each declared key among the root function's bound arguments and copies
the value as a tag. Only the root call is harvested; the same key on an inner step's arguments
is captured in that step's input like any other argument and does not become a tag. The demo
declares one key:

```python vexlo-demo/src/delivery/cli.py theme={null}
    vexlo.init(service="delivery-service", tag_keys=["order_id"])
```

**Call `vexlo.tag()` inside the trace.** `vexlo.tag(**pairs)` adds pairs to the current
trace from inside the root's body, including from inner steps and from code carried onto
another thread with `attach()`. With no trace active, `tag()` does nothing.

Call it early. The `trace-open` record is handed to the outbox when the root is entered, and
its `tags` object is the same object `tag()` mutates, so a pair added before the writer thread
writes the record lands on it. The writer writes every second, or as soon as 512 records or
1 MiB are waiting, and a pair added after that write is silently lost. In this run the root
slept for 1.5 seconds between two `tag()` calls, and only the first is on the record:

```python late_tag.py theme={null}
import time

import vexlo

vexlo.init(service="delivery-service")


@vexlo.trace
def assign_dasher(order_id):
    vexlo.tag(early="yes")
    time.sleep(1.5)
    vexlo.tag(late="yes")
    return {"order_id": order_id}


assign_dasher("o-5001")
vexlo.flush()
```

```json theme={null}
{
  "kind": "trace-open",
  "format_version": 2,
  "trace_id": "d60c45c0a97c35d523103b05807f2ee8",
  "service": "delivery-service",
  "instance_id": "2874d71e64788c8ddf6cec9aea126782",
  "sdk_version": "0.1.0",
  "started_at": "2026-10-20T01:57:42.994Z",
  "tags": {
    "early": "yes"
  }
}
```

This script uses both, and hands `tag()` a secret:

```python tags.py theme={null}
import vexlo

vexlo.init(service="delivery-service", tag_keys=["order_id", "zone"])


@vexlo.trace
def assign_dasher(order_id, password, zone="downtown"):
    vexlo.tag(customer_id="c-42", attempt=2, token=password)
    return {"order_id": order_id, "zone": zone}


print(assign_dasher("o-5001", "hunter2"))
print(vexlo.diagnostics())
vexlo.flush()
```

```bash theme={null}
VEXLO_SPOOL_DIR=spool-tags python tags.py
```

```text theme={null}
{'order_id': 'o-5001', 'zone': 'downtown'}
Diagnostics(internal_errors=0, adapter_errors=0, steps_outside_trace=0, redacted_tags=1, steps_after_trace_close=0, attempts_outlived_trace=0, drives_lost_to_trace_end=0, drives_degraded=0, attempts_dropped_hidden_coroutine=0, traces_evicted=0, markers_declined=0)
```

The `trace-open` record from the segment file, pretty-printed:

```json theme={null}
{
  "kind": "trace-open",
  "format_version": 2,
  "trace_id": "fbc9aa26f32ac16f5c151dae5b7376e1",
  "service": "delivery-service",
  "instance_id": "ced08109fc9234b5a6c09bd5e72e9629",
  "sdk_version": "0.1.0",
  "started_at": "2026-10-20T01:57:42.891Z",
  "tags": {
    "order_id": "o-5001",
    "zone": "downtown",
    "customer_id": "c-42",
    "attempt": "2"
  }
}
```

Four things happened:

* `order_id` and `zone` were harvested from the root's arguments. `zone` was never passed; the
  default applied and was harvested like any other bound argument.
* `attempt=2` became the string `"2"`. Every tag value is text on the wire.
* `customer_id` came from `tag()`, exactly as passed.
* `token="hunter2"` is absent. **A `tag()` key matched by a redaction rule is dropped, not
  shipped.** `token` is one of the 12 default names, so the pair never reached the record, and
  the drop is the `redacted_tags=1` in the line above. The drop consumes none of the 32 tag
  slots. A service deliberately tagging under one of those names loses that tag and any search
  keyed on it: rename the tag to a name worth indexing in clear text, which is what a tag is.

Declaring a redacted name in `tag_keys` is a contradiction rather than a silent drop, so
`init(tag_keys=["password"])` raises; see
[what `init()` raises](https://docs.vexlo.ai/sdk/configuration#what-init-raises).

The same rule redacted the `password` argument in the root's input slot, which the trace's
first `value` record shows:

```json theme={null}
{
  "kind": "value",
  "trace_id": "fbc9aa26f32ac16f5c151dae5b7376e1",
  "content_id": "d38369388097223a9bd22da15b859a679f7cc2dd9a9321c0a81892db26992efc",
  "encoding": "json",
  "bytes_count": 69,
  "truncated": false,
  "value": "{\"order_id\":\"o-5001\",\"password\":{\"$redacted\":true},\"zone\":\"downtown\"}"
}
```

## Bounds

The same rules apply to a harvested key and to a `tag()` pair.

| Bound | What the SDK does at it |
| - | - |
| 32 tags per trace | Keeps the first 32. For `tag_keys`, that is the first 32 distinct declared keys in declared order; for `tag()`, whichever pairs arrive first. A later pair for a key already present replaces its value without counting as a new tag. |
| Key up to 64 characters, printable ASCII (`0x20` to `0x7E`) | A longer key, or one with any other character, drops the whole pair. |
| Value up to 256 characters, printable ASCII | A longer value is cut to exactly 256 characters. A value with any other character drops the pair. |
| Value type `str`, `int`, `float`, `bool`, or `None` | Rendered as text: `2` becomes `"2"`, `True` becomes `"true"`, `None` becomes `"null"`. A value of any other type drops the pair. |

Nothing is counted or reported when a pair is dropped at a bound, on either path. The bounds on
a value are checked at runtime, against what the call actually passed, so a tag built from
request data can fail them: an accented customer name is not printable ASCII and drops the pair.
Check a value before tagging with it. Nothing on the wire will say a tag is missing.

## OpenTelemetry correlation

With `correlate_otel=True`, the default, the SDK checks once per trace, as it opens, whether
`opentelemetry.trace` is importable and has a currently active span. If it does, the span's
trace id, as 32 lowercase hex characters, is added as the tag `otel_trace_id`. The SDK only
reads the span; it creates nothing, injects nothing, and adds no dependency. Whether the module
is importable is checked once per process; whether a span is active is read fresh for every
trace.

In an environment with `opentelemetry-api` and `opentelemetry-sdk` installed alongside `vexlo`:

```python otel.py theme={null}
from opentelemetry import trace as otel_trace
from opentelemetry.sdk.trace import TracerProvider

import vexlo

vexlo.init(service="delivery-service", tag_keys=["order_id"])
otel_trace.set_tracer_provider(TracerProvider())
tracer = otel_trace.get_tracer("delivery")


@vexlo.trace
def assign_dasher(order_id):
    return {"order_id": order_id}


with tracer.start_as_current_span("POST /orders/assign") as span:
    print("otel trace id:", format(span.get_span_context().trace_id, "032x"))
    assign_dasher("o-5001")
vexlo.flush()
```

```bash theme={null}
VEXLO_SPOOL_DIR=spool-otel python otel.py
```

```text theme={null}
otel trace id: 5b6489d5b1e59506ec85e9a2d560eeef
```

```json theme={null}
{
  "kind": "trace-open",
  "format_version": 2,
  "trace_id": "64fa8283723b46a68b960314e9d7d635",
  "service": "delivery-service",
  "instance_id": "792a8c360bc82b142b19b132344b780b",
  "sdk_version": "0.1.0",
  "started_at": "2026-10-20T01:57:44.632Z",
  "tags": {
    "order_id": "o-5001",
    "otel_trace_id": "5b6489d5b1e59506ec85e9a2d560eeef"
  }
}
```

`otel_trace_id` is an ordinary tag: it competes for the same 32-tag budget and is added after
the declared keys, so declared tags win when the budget is contended. The Vexlo `trace_id` is
unrelated to the span's id; path assignment and attempt grouping do not change when
OpenTelemetry is present. `correlate_otel=False` skips the check entirely, and no import of
OpenTelemetry is attempted. A fault inside the OpenTelemetry API is counted and swallowed like
any other SDK-internal fault.

## Relating traces across services

A request that crosses two services produces one trace per service. Neither trace knows about
the other; the SDK never propagates a Vexlo trace id over the network. What relates them is a
shared tag: when both services declare `order_id` in their `tag_keys`, or set it with
`vexlo.tag()`, both traces carry `order_id: "o-5001"`, and the server's related-traces route
returns one from the other with `relation: "shares_tag"`. That route, and the window it
searches, are on the [traces page of the API reference](https://docs.vexlo.ai/api/traces.md); an AI agent reaches the
same lookup through the `vexlo_find_related_traces` tool on the [find tools page](https://docs.vexlo.ai/mcp/tools/find.md).

Pick tag keys that name the request, not the function: an order id, a customer id, a job id.
A value that differs between the two services' calls, or a key only one of them declares,
relates nothing.

Next: [what gets captured](https://docs.vexlo.ai/sdk/what-gets-captured.md) in each slot, and how it is bounded and
redacted.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.