Skip to content

Commit 444b362

Browse files
lmeyerovclaude
andauthored
feat: add W3C traceparent propagation for distributed tracing (#36)
* feat: add W3C traceparent propagation for distributed tracing - Add _tracing.py module with traceparent generation helpers - Cursor generates session trace_id, shared across new() children - LouieClient._get_headers() injects traceparent into all HTTP requests - Priority: OTel context > session trace > none - Add opentelemetry-api to dev deps for type checking - Add unit tests for tracing functionality Closes #35 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * docs: add distributed tracing section to architecture docs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * docs: update changelog for v0.7.0 release 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 79105e5 commit 444b362

File tree

14 files changed

+537
-19
lines changed

14 files changed

+537
-19
lines changed

.secrets.baseline

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@
9696
},
9797
{
9898
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
99-
"min_level": 3
99+
"min_level": 2
100100
},
101101
{
102102
"path": "detect_secrets.filters.heuristic.is_indirect_reference"
@@ -133,5 +133,5 @@
133133
}
134134
],
135135
"results": {},
136-
"generated_at": "2025-09-23T23:57:52Z"
136+
"generated_at": "2026-01-16T23:24:34Z"
137137
}

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
### Changed
1414
- None.
1515

16+
## [0.7.0] - 2026-01-16
17+
18+
### Added
19+
- **Distributed tracing**: W3C `traceparent` propagation for correlating requests with OpenTelemetry
20+
- Automatic OTel context propagation when available
21+
- Session-level trace ID for correlation when OTel is not configured
22+
- `Cursor.new()` children inherit parent trace ID for session-wide correlation
23+
- Zero configuration required - works automatically
24+
25+
### Changed
26+
- `LouieClient._get_headers()` now accepts `session_trace_id` and `traceparent` parameters
27+
- All HTTP methods (`add_cell`, `upload_*`, streaming) propagate trace context
28+
1629
## [0.6.2] - 2025-12-22
1730

1831
### Added

docs/developer/architecture.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,4 +129,31 @@ User Query → Agent Selection → LouieAI API → Agent Processing → Structur
129129
- IPython.display integration for rich output
130130
- Real-time streaming with progressive updates
131131
- Automatic dataframe and visualization rendering
132-
- History management with indexed access (`lui[-1]`, `lui[-2]`, etc.)
132+
- History management with indexed access (`lui[-1]`, `lui[-2]`, etc.)
133+
134+
### Distributed Tracing (OpenTelemetry)
135+
136+
LouieAI automatically propagates W3C `traceparent` headers for distributed tracing:
137+
138+
**With OpenTelemetry configured:**
139+
```python
140+
# Your existing OTel span context is automatically propagated
141+
with tracer.start_as_current_span("my_analysis"):
142+
lui("analyze data") # Request includes your trace context
143+
```
144+
145+
**Without OpenTelemetry:**
146+
```python
147+
# Session-level trace ID generated automatically for correlation
148+
lui = louieai.Cursor()
149+
lui("query 1") # All requests share a session trace_id
150+
lui("query 2") # Same trace_id, different span_id
151+
152+
child = lui.new()
153+
child("query 3") # Same session trace_id (inherited)
154+
```
155+
156+
This enables:
157+
- Linking client requests to server-side traces in Tempo, Jaeger, etc.
158+
- Correlating all prompts within a Cursor session
159+
- No configuration required - works automatically

pyproject.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ dev = [
5555
"mkdocs-jupyter>=0.24.0",
5656
"nbclient>=0.10.2",
5757
"python-dotenv>=1.0.0",
58-
"ipython>=8.0.0"
58+
"ipython>=8.0.0",
59+
"opentelemetry-api>=1.0.0", # For type checking OTel integration
5960
]
6061
docs = [
6162
"mkdocs>=1.6.0",
@@ -143,6 +144,10 @@ ignore_missing_imports = true
143144
module = "IPython.*"
144145
ignore_missing_imports = true
145146

147+
[[tool.mypy.overrides]]
148+
module = "opentelemetry.*"
149+
ignore_missing_imports = true
150+
146151
# Ignore mypy issues in test files for complex mocking
147152
[[tool.mypy.overrides]]
148153
module = "tests.*"

src/louieai/_client.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
collect_table_ai_kwargs,
1919
normalize_table_ai_overrides,
2020
)
21+
from ._tracing import get_traceparent
2122
from .auth import AuthManager, auto_retry_auth
2223

2324
logger = logging.getLogger(__name__)
@@ -368,8 +369,21 @@ def _fetch_dataframe_arrow(
368369
logger.debug("Full error details: ", exc_info=True)
369370
return None
370371

371-
def _get_headers(self) -> dict[str, str]:
372-
"""Get authorization headers using auth manager."""
372+
def _get_headers(
373+
self, session_trace_id: str | None = None, traceparent: str | None = None
374+
) -> dict[str, str]:
375+
"""Get authorization headers using auth manager.
376+
377+
Args:
378+
session_trace_id: Optional session trace ID for correlation when
379+
OTel is not available. Used to generate traceparent if no
380+
explicit traceparent is provided and OTel is not active.
381+
traceparent: Optional explicit traceparent header value. If provided,
382+
takes precedence over auto-generated values.
383+
384+
Returns:
385+
Headers dict with Authorization and optionally traceparent.
386+
"""
373387
token = self._auth_manager.get_token()
374388
headers = {"Authorization": f"Bearer {token}"}
375389

@@ -383,6 +397,15 @@ def _get_headers(self) -> dict[str, str]:
383397
org_slug = self._to_slug(str(org_name))
384398
headers["X-Graphistry-Org"] = org_slug
385399

400+
# Add traceparent for distributed tracing
401+
# Priority: explicit traceparent > OTel context > session trace
402+
if traceparent:
403+
headers["traceparent"] = traceparent
404+
else:
405+
tp = get_traceparent(session_trace_id)
406+
if tp:
407+
headers["traceparent"] = tp
408+
386409
return headers
387410

388411
def _to_slug(self, text: str) -> str:
@@ -619,6 +642,7 @@ def add_cell(
619642
share_mode: str = "Private",
620643
table_ai_overrides: TableAIOverrides | Mapping[str, Any] | None = None,
621644
use_batch: bool | None = None,
645+
session_trace_id: str | None = None,
622646
**legacy_overrides: Any,
623647
) -> Response:
624648
"""Add a cell (query) to a thread and get response.
@@ -634,13 +658,15 @@ def add_cell(
634658
table_ai_overrides: Structured overrides via dataclass or mapping.
635659
use_batch: Force singleshot (`True`) or streaming (`False`); defaults to
636660
singleshot when overrides are provided.
661+
session_trace_id: Optional session trace ID for distributed tracing
662+
correlation when OpenTelemetry is not available.
637663
**legacy_overrides: Backwards-compatible Table AI keyword arguments like
638664
``table_ai_semantic_mode``. Prefer `table_ai_overrides`.
639665
640666
Returns:
641667
Response object containing thread_id and all elements
642668
"""
643-
headers = self._get_headers()
669+
headers = self._get_headers(session_trace_id=session_trace_id)
644670

645671
# Build query parameters
646672
params: dict[str, Any] = {
@@ -940,6 +966,7 @@ def upload_dataframe(
940966
name: str | None = None,
941967
folder: str | None = None,
942968
parsing_options: dict[str, Any] | None = None,
969+
session_trace_id: str | None = None,
943970
) -> Response:
944971
"""Upload a DataFrame with a natural language query for AI analysis.
945972
@@ -954,6 +981,8 @@ def upload_dataframe(
954981
name: Optional thread name
955982
folder: Optional folder path for the thread (server support required)
956983
parsing_options: Format-specific parsing options
984+
session_trace_id: Optional session trace ID for distributed tracing
985+
correlation when OpenTelemetry is not available.
957986
958987
Returns:
959988
Response object with analysis results
@@ -975,6 +1004,7 @@ def upload_dataframe(
9751004
name=name,
9761005
folder=folder,
9771006
parsing_options=parsing_options,
1007+
session_trace_id=session_trace_id,
9781008
)
9791009

9801010
def upload_image(
@@ -988,6 +1018,7 @@ def upload_image(
9881018
share_mode: str = "Private",
9891019
name: str | None = None,
9901020
folder: str | None = None,
1021+
session_trace_id: str | None = None,
9911022
) -> Response:
9921023
"""Upload an image with a natural language query for analysis.
9931024
@@ -1000,6 +1031,8 @@ def upload_image(
10001031
share_mode: Visibility setting
10011032
name: Optional thread name
10021033
folder: Optional folder path for the thread (server support required)
1034+
session_trace_id: Optional session trace ID for distributed tracing
1035+
correlation when OpenTelemetry is not available.
10031036
10041037
Returns:
10051038
Response object with analysis results
@@ -1018,6 +1051,7 @@ def upload_image(
10181051
share_mode=share_mode,
10191052
name=name,
10201053
folder=folder,
1054+
session_trace_id=session_trace_id,
10211055
)
10221056

10231057
def upload_binary(
@@ -1032,6 +1066,7 @@ def upload_binary(
10321066
name: str | None = None,
10331067
folder: str | None = None,
10341068
filename: str | None = None,
1069+
session_trace_id: str | None = None,
10351070
) -> Response:
10361071
"""Upload a binary file with a natural language query for analysis.
10371072
@@ -1045,6 +1080,8 @@ def upload_binary(
10451080
name: Optional thread name
10461081
folder: Optional folder path for the thread (server support required)
10471082
filename: Optional filename to use
1083+
session_trace_id: Optional session trace ID for distributed tracing
1084+
correlation when OpenTelemetry is not available.
10481085
10491086
Returns:
10501087
Response object with analysis results
@@ -1064,6 +1101,7 @@ def upload_binary(
10641101
name=name,
10651102
folder=folder,
10661103
filename=filename,
1104+
session_trace_id=session_trace_id,
10671105
)
10681106

10691107
def __enter__(self):

src/louieai/_tracing.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""W3C Traceparent propagation for distributed tracing.
2+
3+
This module provides traceparent header generation for correlating
4+
louie-py requests with distributed traces. It supports:
5+
6+
1. OpenTelemetry integration: If OTel is configured with an active span,
7+
the current trace context is automatically propagated.
8+
9+
2. Session-level correlation: When OTel is not available, a session
10+
trace_id is used to correlate all requests from a Cursor instance.
11+
12+
The traceparent header follows the W3C Trace Context specification:
13+
https://www.w3.org/TR/trace-context/
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import secrets
19+
20+
21+
def generate_trace_id() -> str:
22+
"""Generate a random 32-character hex trace ID."""
23+
return secrets.token_hex(16)
24+
25+
26+
def generate_span_id() -> str:
27+
"""Generate a random 16-character hex span ID."""
28+
return secrets.token_hex(8)
29+
30+
31+
def build_traceparent(trace_id: str, span_id: str, sampled: bool = True) -> str:
32+
"""Build a W3C traceparent header value.
33+
34+
Format: {version}-{trace_id}-{span_id}-{trace_flags}
35+
Example: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01
36+
37+
Args:
38+
trace_id: 32-character hex trace ID
39+
span_id: 16-character hex span ID
40+
sampled: Whether the trace is sampled (default True)
41+
42+
Returns:
43+
W3C traceparent header value
44+
"""
45+
flags = "01" if sampled else "00"
46+
return f"00-{trace_id}-{span_id}-{flags}"
47+
48+
49+
def inject_otel_traceparent(headers: dict[str, str]) -> bool:
50+
"""Inject traceparent from OpenTelemetry context if available.
51+
52+
Uses the standard OTel propagate.inject() mechanism which handles
53+
W3C Trace Context, baggage, and any configured propagators.
54+
55+
Args:
56+
headers: Dict to inject traceparent into (modified in place)
57+
58+
Returns:
59+
True if OTel context was injected, False otherwise
60+
"""
61+
try:
62+
from opentelemetry.propagate import inject
63+
from opentelemetry.trace import INVALID_SPAN, get_current_span
64+
65+
span = get_current_span()
66+
if span == INVALID_SPAN or not span.is_recording():
67+
return False
68+
69+
inject(headers)
70+
return True
71+
except ImportError:
72+
return False
73+
74+
75+
def get_traceparent(session_trace_id: str | None = None) -> str | None:
76+
"""Get traceparent header value for an outgoing request.
77+
78+
Priority:
79+
1. Active OTel span context (if available)
80+
2. Session trace_id with new span_id (if provided)
81+
3. None (no tracing)
82+
83+
Args:
84+
session_trace_id: Optional session-level trace ID for correlation
85+
when OTel is not available
86+
87+
Returns:
88+
traceparent header value or None
89+
"""
90+
# Try OTel first
91+
headers: dict[str, str] = {}
92+
if inject_otel_traceparent(headers):
93+
return headers.get("traceparent")
94+
95+
# Fallback to session trace
96+
if session_trace_id:
97+
span_id = generate_span_id()
98+
return build_traceparent(session_trace_id, span_id)
99+
100+
return None

src/louieai/_upload.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ def upload_dataframe(
5050
folder: str | None = None,
5151
parsing_options: dict[str, Any] | None = None,
5252
table_ai_overrides: TableAIOverrides | Mapping[str, Any] | None = None,
53+
session_trace_id: str | None = None,
5354
**legacy_overrides: Any,
5455
) -> Response:
5556
"""Upload a DataFrame with a natural language query for analysis.
@@ -106,8 +107,8 @@ def upload_dataframe(
106107
... thread_id=response.thread_id
107108
... )
108109
"""
109-
# Get headers with auth
110-
headers = self._client._get_headers()
110+
# Get headers with auth and tracing
111+
headers = self._client._get_headers(session_trace_id=session_trace_id)
111112

112113
# Serialize DataFrame to specified format
113114
file_data, filename, content_type = self._serialize_dataframe(df, format)
@@ -334,6 +335,7 @@ def upload_image(
334335
share_mode: str = "Private",
335336
name: str | None = None,
336337
folder: str | None = None,
338+
session_trace_id: str | None = None,
337339
) -> Response:
338340
"""Upload an image with a natural language query for analysis.
339341
@@ -375,8 +377,8 @@ def upload_image(
375377
... img_bytes = f.read()
376378
>>> response = client.upload_image("Extract text", img_bytes)
377379
"""
378-
# Get headers with auth
379-
headers = self._client._get_headers()
380+
# Get headers with auth and tracing
381+
headers = self._client._get_headers(session_trace_id=session_trace_id)
380382

381383
# Serialize image
382384
file_data, filename, content_type = self._serialize_image(image)
@@ -601,6 +603,7 @@ def upload_binary(
601603
name: str | None = None,
602604
folder: str | None = None,
603605
filename: str | None = None,
606+
session_trace_id: str | None = None,
604607
) -> Response:
605608
"""Upload a binary file with a natural language query for analysis.
606609
@@ -641,8 +644,8 @@ def upload_binary(
641644
... file_bytes = f.read()
642645
>>> response = client.upload_binary("Key points from this doc", file_bytes)
643646
"""
644-
# Get headers with auth
645-
headers = self._client._get_headers()
647+
# Get headers with auth and tracing
648+
headers = self._client._get_headers(session_trace_id=session_trace_id)
646649

647650
# Serialize binary file
648651
file_data, file_name, content_type = self._serialize_binary(file, filename)

0 commit comments

Comments
 (0)