Skip to content

Commit 24cc085

Browse files
feat(platform): add W3C trace context headers to LLM Gateway requests
Add x-uipath-traceparent-id and x-uipath-tracebaggage headers to all LLM Gateway HTTP calls, gated behind the EnableTraceContextHeaders feature flag. Headers follow the W3C Trace Context spec and include source, folderKey, agentId, and processKey in the baggage. Bump uipath-platform to 0.1.41. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent af3602e commit 24cc085

4 files changed

Lines changed: 191 additions & 3 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: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""W3C-style trace context headers for LLM Gateway requests."""
2+
3+
import os
4+
5+
from opentelemetry import trace
6+
from uipath.core.feature_flags import FeatureFlags
7+
8+
from ..common.constants import (
9+
ENV_FOLDER_KEY,
10+
ENV_PROCESS_KEY,
11+
ENV_UIPATH_PROCESS_UUID,
12+
)
13+
14+
15+
def build_trace_context_headers() -> dict[str, str]:
16+
"""Build W3C-style trace context headers from the current OpenTelemetry span.
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] = ["source=agents"]
33+
if folder_key := os.getenv(ENV_FOLDER_KEY):
34+
baggage_parts.append(f"folderKey={folder_key}")
35+
if agent_id := os.getenv(ENV_UIPATH_PROCESS_UUID):
36+
baggage_parts.append(f"agentId={agent_id}")
37+
if process_key := os.getenv(ENV_PROCESS_KEY):
38+
baggage_parts.append(f"processKey={process_key}")
39+
headers["x-uipath-tracebaggage"] = ",".join(baggage_parts)
40+
41+
return headers
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
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+
10+
from uipath.core.feature_flags import FeatureFlags
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+
ctx = SpanContext(
60+
trace_id=0,
61+
span_id=0,
62+
is_remote=False,
63+
trace_flags=TraceFlags(0),
64+
)
65+
non_recording = NonRecordingSpan(ctx)
66+
token = trace.context_api.attach(
67+
trace.set_span_in_context(non_recording)
68+
)
69+
try:
70+
headers = build_trace_context_headers()
71+
finally:
72+
trace.context_api.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 env vars."""
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 "source=agents" in baggage
95+
assert "folderKey=folder-abc" in baggage
96+
assert "agentId=agent-123" in baggage
97+
assert "processKey=process-789" in baggage
98+
99+
def test_partial_env_vars(self) -> None:
100+
env = {"UIPATH_FOLDER_KEY": "folder-only"}
101+
with patch.dict(os.environ, env, clear=True):
102+
headers = build_trace_context_headers()
103+
104+
baggage = headers["x-uipath-tracebaggage"]
105+
assert "source=agents" in baggage
106+
assert "folderKey=folder-only" in baggage
107+
108+
def test_always_includes_source(self) -> None:
109+
with patch.dict(os.environ, {}, clear=True):
110+
headers = build_trace_context_headers()
111+
112+
assert headers["x-uipath-tracebaggage"] == "source=agents"
113+
114+
def test_baggage_comma_separated(self) -> None:
115+
env = {
116+
"UIPATH_FOLDER_KEY": "f1",
117+
"UIPATH_PROCESS_UUID": "a1",
118+
}
119+
with patch.dict(os.environ, env, clear=True):
120+
headers = build_trace_context_headers()
121+
122+
baggage = headers["x-uipath-tracebaggage"]
123+
parts = baggage.split(",")
124+
assert len(parts) == 3 # source + folderKey + agentId
125+
126+
127+
class TestBothHeaders:
128+
"""When enabled with an active span and env vars, both headers are present."""
129+
130+
def setup_method(self) -> None:
131+
FeatureFlags.reset_flags()
132+
FeatureFlags.configure_flags({FEATURE_FLAG: True})
133+
134+
def test_both_headers_present(self) -> None:
135+
provider = TracerProvider()
136+
tracer = provider.get_tracer("test")
137+
env = {"UIPATH_FOLDER_KEY": "folder-abc"}
138+
with (
139+
tracer.start_as_current_span("test-span"),
140+
patch.dict(os.environ, env, clear=True),
141+
):
142+
headers = build_trace_context_headers()
143+
144+
assert "x-uipath-traceparent-id" in headers
145+
assert "x-uipath-tracebaggage" in headers

0 commit comments

Comments
 (0)