Skip to content

Commit 67466db

Browse files
feat(platform): add W3C trace context headers to LLM Gateway requests (#1612)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent af3602e commit 67466db

7 files changed

Lines changed: 216 additions & 5 deletions

File tree

packages/uipath-platform/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-platform"
3-
version = "0.1.43"
3+
version = "0.1.44"
44
description = "HTTP client library for programmatic access to UiPath Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
ToolDefinition,
3636
)
3737
from .llm_throttle import get_llm_semaphore
38+
from .llm_trace_context import build_trace_context_headers
3839

3940
# Common constants
4041
API_VERSION = "2024-10-21" # Standard API version for OpenAI-compatible endpoints
@@ -224,7 +225,7 @@ async def embeddings(
224225
endpoint,
225226
json={"input": input},
226227
params={"api-version": API_VERSION},
227-
headers=self._llm_headers,
228+
headers={**self._llm_headers, **build_trace_context_headers()},
228229
)
229230

230231
return TextEmbedding.model_validate(response.json())
@@ -355,7 +356,7 @@ class Country(BaseModel):
355356
endpoint,
356357
json=request_body,
357358
params={"api-version": API_VERSION},
358-
headers=self._llm_headers,
359+
headers={**self._llm_headers, **build_trace_context_headers()},
359360
)
360361

361362
return ChatCompletion.model_validate(response.json())
@@ -599,6 +600,7 @@ class Country(BaseModel):
599600

600601
headers = {
601602
**self._llm_headers,
603+
**build_trace_context_headers(),
602604
"X-UiPath-LlmGateway-NormalizedApi-ModelName": model,
603605
"X-UiPath-LLMGateway-AllowFull4xxResponse": "true",
604606
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""W3C-style trace context headers for LLM Gateway requests."""
2+
3+
from opentelemetry import trace
4+
from uipath.core.feature_flags import FeatureFlags
5+
6+
from ..common._config import UiPathConfig
7+
8+
9+
def build_trace_context_headers(
10+
extra_baggage: list[str] | None = None,
11+
) -> dict[str, str]:
12+
"""Build W3C-style trace context headers from the current OpenTelemetry span.
13+
14+
Args:
15+
extra_baggage: Additional baggage entries (e.g. ``["source=agents"]``)
16+
that callers can inject alongside the platform-level entries.
17+
18+
Returns an empty dict when the ``EnableTraceContextHeaders`` feature flag
19+
is not enabled, or when no active span is present.
20+
"""
21+
if not FeatureFlags.is_flag_enabled("EnableTraceContextHeaders"):
22+
return {}
23+
24+
headers: dict[str, str] = {}
25+
span = trace.get_current_span()
26+
ctx = span.get_span_context()
27+
if ctx and ctx.trace_id and ctx.span_id:
28+
trace_id = format(ctx.trace_id, "032x")
29+
span_id = format(ctx.span_id, "016x")
30+
headers["x-uipath-traceparent-id"] = f"00-{trace_id}-{span_id}"
31+
32+
baggage_parts: list[str] = list(extra_baggage) if extra_baggage else []
33+
if folder_key := UiPathConfig.folder_key:
34+
baggage_parts.append(f"folderKey={folder_key}")
35+
if agent_id := UiPathConfig.process_uuid:
36+
baggage_parts.append(f"agentId={agent_id}")
37+
if process_key := UiPathConfig.process_key:
38+
baggage_parts.append(f"processKey={process_key}")
39+
if baggage_parts:
40+
headers["x-uipath-tracebaggage"] = ",".join(baggage_parts)
41+
42+
return headers

packages/uipath-platform/src/uipath/platform/common/_config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,12 @@ def folder_path(self) -> str | None:
121121

122122
return os.getenv(ENV_FOLDER_PATH, None)
123123

124+
@property
125+
def process_key(self) -> str | None:
126+
from uipath.platform.common.constants import ENV_PROCESS_KEY
127+
128+
return os.getenv(ENV_PROCESS_KEY, None)
129+
124130
@property
125131
def process_uuid(self) -> str | None:
126132
from uipath.platform.common.constants import ENV_UIPATH_PROCESS_UUID
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"""Tests for build_trace_context_headers."""
2+
3+
import os
4+
from unittest.mock import patch
5+
6+
from opentelemetry import trace
7+
from opentelemetry.sdk.trace import TracerProvider
8+
from opentelemetry.trace import NonRecordingSpan, SpanContext, TraceFlags
9+
from uipath.core.feature_flags import FeatureFlags
10+
11+
from uipath.platform.chat.llm_trace_context import build_trace_context_headers
12+
13+
FEATURE_FLAG = "EnableTraceContextHeaders"
14+
15+
16+
class TestFeatureFlagDisabled:
17+
"""When the feature flag is off, no headers are returned."""
18+
19+
def setup_method(self) -> None:
20+
FeatureFlags.reset_flags()
21+
22+
def test_returns_empty_dict_by_default(self) -> None:
23+
assert build_trace_context_headers() == {}
24+
25+
def test_returns_empty_dict_when_explicitly_disabled(self) -> None:
26+
FeatureFlags.configure_flags({FEATURE_FLAG: False})
27+
assert build_trace_context_headers() == {}
28+
29+
30+
class TestTraceparentHeader:
31+
"""When enabled, x-uipath-traceparent-id is populated from the active span."""
32+
33+
def setup_method(self) -> None:
34+
FeatureFlags.reset_flags()
35+
FeatureFlags.configure_flags({FEATURE_FLAG: True})
36+
37+
def test_traceparent_from_active_span(self) -> None:
38+
provider = TracerProvider()
39+
tracer = provider.get_tracer("test")
40+
with tracer.start_as_current_span("test-span") as span:
41+
ctx = span.get_span_context()
42+
expected_trace_id = format(ctx.trace_id, "032x")
43+
expected_span_id = format(ctx.span_id, "016x")
44+
45+
headers = build_trace_context_headers()
46+
47+
assert "x-uipath-traceparent-id" in headers
48+
value = headers["x-uipath-traceparent-id"]
49+
assert value == f"00-{expected_trace_id}-{expected_span_id}"
50+
# Verify format: version (2) + dash + trace_id (32) + dash + span_id (16)
51+
parts = value.split("-")
52+
assert len(parts) == 3
53+
assert parts[0] == "00"
54+
assert len(parts[1]) == 32
55+
assert len(parts[2]) == 16
56+
57+
def test_no_traceparent_without_active_span(self) -> None:
58+
# INVALID_SPAN has trace_id=0 and span_id=0
59+
from opentelemetry.context import attach, detach
60+
61+
ctx = SpanContext(
62+
trace_id=0,
63+
span_id=0,
64+
is_remote=False,
65+
trace_flags=TraceFlags(0),
66+
)
67+
non_recording = NonRecordingSpan(ctx)
68+
token = attach(trace.set_span_in_context(non_recording))
69+
try:
70+
headers = build_trace_context_headers()
71+
finally:
72+
detach(token)
73+
74+
assert "x-uipath-traceparent-id" not in headers
75+
76+
77+
class TestBaggageHeader:
78+
"""When enabled, x-uipath-tracebaggage is populated from UiPathConfig."""
79+
80+
def setup_method(self) -> None:
81+
FeatureFlags.reset_flags()
82+
FeatureFlags.configure_flags({FEATURE_FLAG: True})
83+
84+
def test_all_env_vars_present(self) -> None:
85+
env = {
86+
"UIPATH_FOLDER_KEY": "folder-abc",
87+
"UIPATH_PROCESS_UUID": "agent-123",
88+
"UIPATH_PROCESS_KEY": "process-789",
89+
}
90+
with patch.dict(os.environ, env, clear=True):
91+
headers = build_trace_context_headers()
92+
93+
baggage = headers["x-uipath-tracebaggage"]
94+
assert "folderKey=folder-abc" in baggage
95+
assert "agentId=agent-123" in baggage
96+
assert "processKey=process-789" in baggage
97+
98+
def test_partial_env_vars(self) -> None:
99+
env = {"UIPATH_FOLDER_KEY": "folder-only"}
100+
with patch.dict(os.environ, env, clear=True):
101+
headers = build_trace_context_headers()
102+
103+
baggage = headers["x-uipath-tracebaggage"]
104+
assert "folderKey=folder-only" in baggage
105+
106+
def test_no_baggage_without_env_vars(self) -> None:
107+
with patch.dict(os.environ, {}, clear=True):
108+
headers = build_trace_context_headers()
109+
110+
assert "x-uipath-tracebaggage" not in headers
111+
112+
def test_baggage_comma_separated(self) -> None:
113+
env = {
114+
"UIPATH_FOLDER_KEY": "f1",
115+
"UIPATH_PROCESS_UUID": "a1",
116+
}
117+
with patch.dict(os.environ, env, clear=True):
118+
headers = build_trace_context_headers()
119+
120+
baggage = headers["x-uipath-tracebaggage"]
121+
parts = baggage.split(",")
122+
assert len(parts) == 2 # folderKey + agentId
123+
124+
def test_extra_baggage_included(self) -> None:
125+
env = {"UIPATH_FOLDER_KEY": "f1"}
126+
with patch.dict(os.environ, env, clear=True):
127+
headers = build_trace_context_headers(extra_baggage=["source=agents"])
128+
129+
baggage = headers["x-uipath-tracebaggage"]
130+
assert "source=agents" in baggage
131+
assert "folderKey=f1" in baggage
132+
133+
def test_extra_baggage_only(self) -> None:
134+
with patch.dict(os.environ, {}, clear=True):
135+
headers = build_trace_context_headers(
136+
extra_baggage=["source=agents", "custom=value"]
137+
)
138+
139+
baggage = headers["x-uipath-tracebaggage"]
140+
assert baggage == "source=agents,custom=value"
141+
142+
143+
class TestBothHeaders:
144+
"""When enabled with an active span and env vars, both headers are present."""
145+
146+
def setup_method(self) -> None:
147+
FeatureFlags.reset_flags()
148+
FeatureFlags.configure_flags({FEATURE_FLAG: True})
149+
150+
def test_both_headers_present(self) -> None:
151+
provider = TracerProvider()
152+
tracer = provider.get_tracer("test")
153+
env = {"UIPATH_FOLDER_KEY": "folder-abc"}
154+
with (
155+
tracer.start_as_current_span("test-span"),
156+
patch.dict(os.environ, env, clear=True),
157+
):
158+
headers = build_trace_context_headers()
159+
160+
assert "x-uipath-traceparent-id" in headers
161+
assert "x-uipath-tracebaggage" in headers

packages/uipath-platform/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/uipath/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)