Skip to content

Commit 0f69236

Browse files
authored
Merge pull request #1852 from gooddata/jt/eval-trace-labels
feat(gooddata-eval): label gen-ai traces through W3C baggage
2 parents 72655a2 + afb8b79 commit 0f69236

3 files changed

Lines changed: 121 additions & 1 deletion

File tree

‎packages/gooddata-eval/AGENTS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ Run this before guessing a `--model` string.
156156
| `GD_EVAL_AGENT_ID` | which agent to drive, same as `--agent-id` |
157157
| `LANGFUSE_HOST`, `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY` | `--langfuse`, `--langfuse-dataset` |
158158
| `GOODDATA_EVAL_CHAT_*` | SSE retry, backoff and timeout knobs |
159+
| `GOODDATA_EVAL_TRACE_LABELS` | `key=value,...` labels stamped on every observation of a chat conversation via W3C baggage; `model_version` also sets the observation `version`, so set it only for a one-model run: every `--model` in one process gets the same labels. Keys must match `[A-Za-z0-9_-]+`; others are skipped with a warning. Dashboard-summary runs are not labelled |
159160
| `GD_EVAL_TIMERS` | same as `--timers` |
160161

161162
A gitignored `.env` at the repo root is the normal place for these; load it with

‎packages/gooddata-eval/src/gooddata_eval/core/chat/sse_client.py‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,15 @@
1313
protocol, not on this class.
1414
"""
1515

16+
import functools
1617
import json
1718
import logging
1819
import os
20+
import re
1921
import time
2022
from dataclasses import dataclass, field
2123
from typing import Any, Callable, Iterable, TypeVar
24+
from urllib.parse import quote
2225

2326
import httpx
2427

@@ -135,6 +138,45 @@ def _float_env(name: str, default: float) -> float:
135138
# 4-turn conversation run to 4x the budget, which is not what a user would sit through.
136139
_ITEM_TIMEOUT_S = _float_env("GOODDATA_EVAL_CHAT_ITEM_TIMEOUT_S", 0.0)
137140

141+
# Comma-separated `key=value` labels, e.g. `model_version=gpt-5.2,github_run_id=42`, sent as
142+
# W3C baggage on every chat request. gen-ai's Langfuse span processor copies `langfuse_*`
143+
# baggage onto each span it starts, so every observation of the conversation -- the cost-bearing
144+
# generations, retries and the title trace included -- carries them from creation. Langfuse v4
145+
# cannot add them to an observation afterwards. A value cannot contain a comma, and a key must be
146+
# a plain token (letters, digits, `_`, `-`); any other key is skipped with a warning, because httpx
147+
# refuses a non-ASCII header and gen-ai's OTel propagator drops or rewrites the rest.
148+
_TRACE_LABELS_ENV = "GOODDATA_EVAL_TRACE_LABELS"
149+
_TRACE_LABEL_KEY = re.compile(r"[A-Za-z0-9_-]+")
150+
151+
152+
@functools.cache
153+
def _warn_skipped_trace_label(key: str) -> None:
154+
"""Warn once per key per process: every ChatClient re-reads the labels."""
155+
_log.warning("%s: skipping label with key %r, keys must match [A-Za-z0-9_-]+", _TRACE_LABELS_ENV, key)
156+
157+
158+
def _trace_baggage() -> dict[str, str]:
159+
"""The `baggage` header for the labels in ``GOODDATA_EVAL_TRACE_LABELS``, or none.
160+
161+
Each label becomes trace metadata, and ``model_version`` -- the combo the report groups
162+
on -- also becomes the observation ``version``, the dimension Langfuse groups cost by.
163+
Values are percent-encoded: a space or comma would otherwise end the baggage entry.
164+
"""
165+
labels: dict[str, str] = {}
166+
for pair in os.getenv(_TRACE_LABELS_ENV, "").split(","):
167+
key, sep, value = pair.partition("=")
168+
key = key.strip()
169+
if not sep or not key:
170+
continue
171+
if _TRACE_LABEL_KEY.fullmatch(key):
172+
labels[key] = value.strip()
173+
else:
174+
_warn_skipped_trace_label(key)
175+
entries = [f"langfuse_metadata_{k}={quote(v, safe='')}" for k, v in labels.items()]
176+
if labels.get("model_version"):
177+
entries.insert(0, f"langfuse_version={quote(labels['model_version'], safe='')}")
178+
return {"baggage": ",".join(entries)} if entries else {}
179+
138180

139181
def set_default_turn_timeout(seconds: float | None) -> None:
140182
"""Set the per-turn budget every ChatClient built afterwards inherits.
@@ -470,7 +512,7 @@ def __init__(
470512
the ``enableAiContextSetup`` feature flag is on for the organization.
471513
"""
472514
self._base = f"{host.rstrip('/')}/api/v1/ai/workspaces/{workspace_id}/chat/conversations"
473-
self._auth = {"Authorization": f"Bearer {token}"}
515+
self._auth = {"Authorization": f"Bearer {token}", **_trace_baggage()}
474516
# 0/None disables the cap. Also lowered onto the read timeout: the wall-clock check
475517
# fires between events, so a turn that goes silent needs the transport to give up too.
476518
budget = _TURN_TIMEOUT_S if turn_timeout_s is None else turn_timeout_s

‎packages/gooddata-eval/tests/test_sse_client.py‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# (C) 2026 GoodData Corporation
22
import json
3+
import logging
4+
from collections.abc import Callable
5+
from urllib.parse import unquote
36

47
import httpx
58
import pytest
@@ -1053,3 +1056,77 @@ def handler(request):
10531056
)
10541057
client.ask(item)
10551058
assert captured["body"]["userContext"] == _ATTACHMENT
1059+
1060+
1061+
def _baggage_of(requests: list[httpx.Request]) -> list[dict[str, str] | None]:
1062+
"""Each request's `baggage` header as {key: decoded value}, or None when absent."""
1063+
out: list[dict[str, str] | None] = []
1064+
for r in requests:
1065+
raw = r.headers.get("baggage")
1066+
out.append(None if raw is None else {k: unquote(v) for k, v in (e.split("=", 1) for e in raw.split(","))})
1067+
return out
1068+
1069+
1070+
def _record_requests(requests: list[httpx.Request]) -> Callable[[httpx.Request], httpx.Response]:
1071+
def handler(request: httpx.Request) -> httpx.Response:
1072+
requests.append(request)
1073+
if request.url.path.endswith("/conversations"):
1074+
return httpx.Response(200, json={"conversationId": "c1"})
1075+
return httpx.Response(200, content=_OK_SSE)
1076+
1077+
return handler
1078+
1079+
1080+
def test_trace_labels_ride_every_request_as_langfuse_baggage(monkeypatch: pytest.MonkeyPatch) -> None:
1081+
"""gen-ai's Langfuse span processor copies `langfuse_*` baggage onto every span it starts, so
1082+
the labels reach the root generation and its cost-bearing children at creation."""
1083+
monkeypatch.setenv("GOODDATA_EVAL_TRACE_LABELS", "model_version=gpt-5.2 openai,github_run_id=42")
1084+
requests: list[httpx.Request] = []
1085+
client = _client_with_handler(_record_requests(requests))
1086+
1087+
client.send_message(client.create_conversation(), "q")
1088+
1089+
expected = {
1090+
"langfuse_version": "gpt-5.2 openai",
1091+
"langfuse_metadata_model_version": "gpt-5.2 openai",
1092+
"langfuse_metadata_github_run_id": "42",
1093+
}
1094+
assert _baggage_of(requests) == [expected, expected]
1095+
# Encoded, not raw: a space or comma in a value would otherwise split or end the entry.
1096+
assert " " not in requests[0].headers["baggage"]
1097+
1098+
1099+
def test_trace_labels_without_a_model_version_set_no_langfuse_version(monkeypatch: pytest.MonkeyPatch) -> None:
1100+
monkeypatch.setenv("GOODDATA_EVAL_TRACE_LABELS", "github_run_id=42")
1101+
requests: list[httpx.Request] = []
1102+
_client_with_handler(_record_requests(requests)).create_conversation()
1103+
1104+
assert _baggage_of(requests) == [{"langfuse_metadata_github_run_id": "42"}]
1105+
1106+
1107+
@pytest.mark.parametrize("key", ["modèle", "run id", "c++"])
1108+
def test_trace_label_with_a_non_token_key_is_skipped_with_one_warning(
1109+
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, key: str
1110+
) -> None:
1111+
"""httpx refuses a non-ASCII header, gen-ai's OTel propagator drops a key with a space, and
1112+
it reads `+` as a space; each would otherwise fail the run or mislabel the trace."""
1113+
monkeypatch.setenv("GOODDATA_EVAL_TRACE_LABELS", f"github_run_id=42,{key}=1")
1114+
requests: list[httpx.Request] = []
1115+
1116+
for _ in range(2):
1117+
_client_with_handler(_record_requests(requests)).create_conversation()
1118+
1119+
assert _baggage_of(requests) == [{"langfuse_metadata_github_run_id": "42"}] * 2
1120+
assert [r.levelno for r in caplog.records if repr(key) in r.getMessage()] == [logging.WARNING]
1121+
1122+
1123+
@pytest.mark.parametrize("raw", [None, "", "no-equals-sign", " , "])
1124+
def test_no_trace_labels_send_no_baggage(monkeypatch: pytest.MonkeyPatch, raw: str | None) -> None:
1125+
if raw is None:
1126+
monkeypatch.delenv("GOODDATA_EVAL_TRACE_LABELS", raising=False)
1127+
else:
1128+
monkeypatch.setenv("GOODDATA_EVAL_TRACE_LABELS", raw)
1129+
requests: list[httpx.Request] = []
1130+
_client_with_handler(_record_requests(requests)).create_conversation()
1131+
1132+
assert _baggage_of(requests) == [None]

0 commit comments

Comments
 (0)