fix(otel v2): keep the credential's role in a destination's account identity

Comparing values alone folds two accounts together whenever they hold the same
strings in different roles, and the second team would then get no trace at all.
Compare the credential under a normalized name instead, and fold the one alias
that actually exists: Arize's space_id and arize-space-id.
This commit is contained in:
Yucheng He 2026-09-04 16:33:53 -07:00
parent c9929554bb
commit c366b91729
2 changed files with 28 additions and 8 deletions

View file

@ -4,6 +4,7 @@ import queue
import threading
from collections import OrderedDict
from collections.abc import Callable, Iterable, Mapping
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Literal
from opentelemetry import _logs, baggage, metrics
@ -219,7 +220,11 @@ _DRAIN_WORKERS: Final = 2
_SHUTDOWN_DRAIN_SECONDS: Final = 5.0
#: An exporter's account: its normalized endpoint and the credentials it presents.
_SinkKey = tuple[str, tuple[str, ...]]
_SinkKey = tuple[str, tuple[tuple[str, str], ...]]
#: Header names that spell one credential two ways. Arize's operator exporter sends
#: ``space_id`` where a tenant destination sends ``arize-space-id``.
_CREDENTIAL_ALIASES: Final = MappingProxyType({"arize_space_id": "space_id"})
class _DrainPool:
@ -851,16 +856,21 @@ def operator_sink_keys(config: OpenTelemetryV2Config | None) -> frozenset[_SinkK
def _sink_key(endpoint: str | None, headers: Mapping[str, str]) -> "_SinkKey | None":
"""The account an exporter writes to, or ``None`` when it has no fixed one.
The credentials are the identity; the header names are only how each backend
spells them, and one account answers to more than one spelling (Arize takes the
operator's ``space_id`` and a tenant's ``arize-space-id``). The endpoint needs
normalizing too: the operator's spec carries the signal path that a tenant
destination leaves for the exporter to append.
Normalized on the three counts that make one account look like two: the operator's
spec carries the signal path a tenant destination leaves for the exporter to
append, header names survive one round trip lowercased and the other not, and one
credential answers to more than one name (see :data:`_CREDENTIAL_ALIASES`).
"""
normalized: Final = _otlp_traces_endpoint(endpoint)
if normalized is None:
return None
return (normalized, tuple(sorted(headers.values())))
return (normalized, tuple(sorted((_credential_name(name), value) for name, value in headers.items())))
def _credential_name(header: str) -> str:
"""The credential a header carries, under whichever name the backend spells it."""
normalized: Final = header.strip().lower().replace("-", "_")
return _CREDENTIAL_ALIASES.get(normalized, normalized)
def _attached_processors(provider: TracerProvider) -> "tuple[SpanProcessor, ...]":

View file

@ -129,7 +129,7 @@ class TestRoutingMode:
moment a team configures its own is what ``additive`` exists to prevent.
"""
OPERATOR_SINK = ("https://cloud.langfuse.com/api/public/otel/v1/traces", ("Basic op",))
OPERATOR_SINK = ("https://cloud.langfuse.com/api/public/otel/v1/traces", (("authorization", "Basic op"),))
#: What a tenant destination for that same project looks like before normalizing:
#: no signal path yet, and the header name cased the way the backend writes it.
SAME_ACCOUNT_ENDPOINT = "https://cloud.langfuse.com/api/public/otel"
@ -346,6 +346,16 @@ class TestRoutingMode:
assert sink("pk-op", "sk-op") in operator, "a team naming the operator's own project"
assert sink("pk-team", "sk-team") not in operator, "a different project on the same server"
def test_two_accounts_holding_the_same_strings_in_different_roles_are_not_one(self):
"""The values alone are not the identity. Two accounts can hold the same pair
of strings with the space id and the api key the other way round, and folding
them together would leave the second one's team with no trace at all."""
endpoint = "https://otlp.arize.com/v1"
assert _sink_key(endpoint, {"space_id": "a", "api_key": "b"}) != _sink_key(
endpoint, {"space_id": "b", "api_key": "a"}
)
def test_the_operators_own_arize_space_and_a_team_naming_it_are_one_account(self, monkeypatch):
"""One account answers to two header names here: the operator's exporter sends
``space_id`` and a team destination sends ``arize-space-id``. Keyed on the names,