Skip to content

Commit c489053

Browse files
committed
add w3c trace context, send trace_id top-level from middleware
1 parent 9e8e22f commit c489053

13 files changed

Lines changed: 632 additions & 42 deletions

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.9.2] - 2026-06-11
9+
10+
### Added
11+
12+
- W3C Trace Context support (`logtide_sdk.tracecontext`): `parse_traceparent`, `format_traceparent`, `generate_trace_id`, `generate_span_id`, `resolve_trace_id` — all exported from the package root
13+
- All log methods (sync and async) accept keyword-only `trace_id` and `span_id`, sent as top-level fields
14+
- `LogEntry.span_id` field, serialized as top-level `span_id`
15+
16+
### Changed
17+
18+
- **Middleware (Flask, Django, Starlette/FastAPI) now resolve the inbound trace context per the W3C spec**: `traceparent` header first, legacy `X-Trace-ID` as deprecated fallback, otherwise a new W3C trace ID is generated. The resolved trace ID is sent as the top-level `trace_id` field instead of `metadata["trace_id"]`, so trace correlation in the platform UI now works for middleware logs
19+
- Auto-generated trace IDs (`auto_trace_id`, `with_new_trace_id`) are now 32-char lowercase-hex W3C IDs instead of UUIDs
20+
821
## [0.9.1] - 2026-05-29
922

1023
### Fixed

logtide_sdk/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
"""LogTide SDK - Official Python SDK for LogTide."""
22

33
from logtide_sdk.client import LogTideClient, serialize_exception
4+
from logtide_sdk.tracecontext import (
5+
TraceContext,
6+
format_traceparent,
7+
generate_span_id,
8+
generate_trace_id,
9+
parse_traceparent,
10+
resolve_trace_id,
11+
)
412
from logtide_sdk.enums import CircuitState, LogLevel
513
from logtide_sdk.exceptions import BufferFullError, CircuitBreakerOpenError, LogTideError
614
from logtide_sdk.handler import LogTideHandler
@@ -41,6 +49,12 @@
4149
"LogsResponse",
4250
"PayloadLimitsOptions",
4351
"QueryOptions",
52+
"TraceContext",
53+
"format_traceparent",
54+
"generate_span_id",
55+
"generate_trace_id",
56+
"parse_traceparent",
57+
"resolve_trace_id",
4458
"serialize_exception",
4559
]
4660

logtide_sdk/async_client.py

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import dataclasses
55
import json
66
import time
7-
import uuid
87
from collections.abc import Callable
98
from threading import Lock as ThreadingLock
109
from typing import Any
@@ -22,6 +21,7 @@
2221
from logtide_sdk.enums import CircuitState, LogLevel
2322
from logtide_sdk.exceptions import CircuitBreakerOpenError
2423
from logtide_sdk.json_encoder import logtide_json_dumps
24+
from logtide_sdk.tracecontext import generate_trace_id
2525
from logtide_sdk.models import (
2626
AggregatedStatsOptions,
2727
AggregatedStatsResponse,
@@ -154,7 +154,7 @@ async def log(self, entry: LogEntry) -> None:
154154

155155
if entry.trace_id is None:
156156
if self.options.auto_trace_id:
157-
entry.trace_id = str(uuid.uuid4())
157+
entry.trace_id = generate_trace_id()
158158
elif self._trace_id is not None:
159159
entry.trace_id = self._trace_id
160160

@@ -184,7 +184,13 @@ async def log(self, entry: LogEntry) -> None:
184184
await self.flush()
185185

186186
async def debug(
187-
self, service: str, message: str, metadata: dict[str, Any] | None = None
187+
self,
188+
service: str,
189+
message: str,
190+
metadata: dict[str, Any] | None = None,
191+
*,
192+
trace_id: str | None = None,
193+
span_id: str | None = None,
188194
) -> None:
189195
"""Log a DEBUG-level message."""
190196
await self.log(
@@ -193,11 +199,19 @@ async def debug(
193199
level=LogLevel.DEBUG,
194200
message=message,
195201
metadata=metadata or {},
202+
trace_id=trace_id,
203+
span_id=span_id,
196204
)
197205
)
198206

199207
async def info(
200-
self, service: str, message: str, metadata: dict[str, Any] | None = None
208+
self,
209+
service: str,
210+
message: str,
211+
metadata: dict[str, Any] | None = None,
212+
*,
213+
trace_id: str | None = None,
214+
span_id: str | None = None,
201215
) -> None:
202216
"""Log an INFO-level message."""
203217
await self.log(
@@ -206,11 +220,19 @@ async def info(
206220
level=LogLevel.INFO,
207221
message=message,
208222
metadata=metadata or {},
223+
trace_id=trace_id,
224+
span_id=span_id,
209225
)
210226
)
211227

212228
async def warn(
213-
self, service: str, message: str, metadata: dict[str, Any] | None = None
229+
self,
230+
service: str,
231+
message: str,
232+
metadata: dict[str, Any] | None = None,
233+
*,
234+
trace_id: str | None = None,
235+
span_id: str | None = None,
214236
) -> None:
215237
"""Log a WARN-level message."""
216238
await self.log(
@@ -219,6 +241,8 @@ async def warn(
219241
level=LogLevel.WARN,
220242
message=message,
221243
metadata=metadata or {},
244+
trace_id=trace_id,
245+
span_id=span_id,
222246
)
223247
)
224248

@@ -227,6 +251,9 @@ async def error(
227251
service: str,
228252
message: str,
229253
metadata_or_error: dict[str, Any] | Exception | None = None,
254+
*,
255+
trace_id: str | None = None,
256+
span_id: str | None = None,
230257
) -> None:
231258
"""Log an ERROR-level message. Accepts an Exception for automatic serialization."""
232259
metadata = self._process_metadata_or_error(metadata_or_error)
@@ -236,6 +263,8 @@ async def error(
236263
level=LogLevel.ERROR,
237264
message=message,
238265
metadata=metadata,
266+
trace_id=trace_id,
267+
span_id=span_id,
239268
)
240269
)
241270

@@ -244,6 +273,9 @@ async def critical(
244273
service: str,
245274
message: str,
246275
metadata_or_error: dict[str, Any] | Exception | None = None,
276+
*,
277+
trace_id: str | None = None,
278+
span_id: str | None = None,
247279
) -> None:
248280
"""Log a CRITICAL-level message. Accepts an Exception for automatic serialization."""
249281
metadata = self._process_metadata_or_error(metadata_or_error)
@@ -253,6 +285,8 @@ async def critical(
253285
level=LogLevel.CRITICAL,
254286
message=message,
255287
metadata=metadata,
288+
trace_id=trace_id,
289+
span_id=span_id,
256290
)
257291
)
258292

logtide_sdk/client.py

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import re
77
import time
88
import traceback
9-
import uuid
109
from collections.abc import Callable, Iterator
1110
from contextlib import contextmanager
1211
from threading import Event, Lock, Thread, Timer
@@ -18,6 +17,7 @@
1817
from logtide_sdk.enums import CircuitState, LogLevel
1918
from logtide_sdk.exceptions import CircuitBreakerOpenError
2019
from logtide_sdk.json_encoder import logtide_json_dumps
20+
from logtide_sdk.tracecontext import generate_trace_id
2121
from logtide_sdk.models import (
2222
AggregatedStatsOptions,
2323
AggregatedStatsResponse,
@@ -202,7 +202,7 @@ def with_new_trace_id(self) -> Iterator[None]:
202202
with client.with_new_trace_id():
203203
client.info('worker', 'Background job')
204204
"""
205-
with self.with_trace_id(str(uuid.uuid4())):
205+
with self.with_trace_id(generate_trace_id()):
206206
yield
207207

208208
# -----------------------------------------------------------------------
@@ -223,7 +223,7 @@ def log(self, entry: LogEntry) -> None:
223223
# Inject trace ID
224224
if entry.trace_id is None:
225225
if self.options.auto_trace_id:
226-
entry.trace_id = str(uuid.uuid4())
226+
entry.trace_id = generate_trace_id()
227227
elif self._trace_id is not None:
228228
entry.trace_id = self._trace_id
229229

@@ -255,36 +255,66 @@ def log(self, entry: LogEntry) -> None:
255255
if should_flush:
256256
self.flush()
257257

258-
def debug(self, service: str, message: str, metadata: dict[str, Any] | None = None) -> None:
258+
def debug(
259+
self,
260+
service: str,
261+
message: str,
262+
metadata: dict[str, Any] | None = None,
263+
*,
264+
trace_id: str | None = None,
265+
span_id: str | None = None,
266+
) -> None:
259267
"""Log a DEBUG-level message."""
260268
self.log(
261269
LogEntry(
262270
service=service,
263271
level=LogLevel.DEBUG,
264272
message=message,
265273
metadata=metadata or {},
274+
trace_id=trace_id,
275+
span_id=span_id,
266276
)
267277
)
268278

269-
def info(self, service: str, message: str, metadata: dict[str, Any] | None = None) -> None:
279+
def info(
280+
self,
281+
service: str,
282+
message: str,
283+
metadata: dict[str, Any] | None = None,
284+
*,
285+
trace_id: str | None = None,
286+
span_id: str | None = None,
287+
) -> None:
270288
"""Log an INFO-level message."""
271289
self.log(
272290
LogEntry(
273291
service=service,
274292
level=LogLevel.INFO,
275293
message=message,
276294
metadata=metadata or {},
295+
trace_id=trace_id,
296+
span_id=span_id,
277297
)
278298
)
279299

280-
def warn(self, service: str, message: str, metadata: dict[str, Any] | None = None) -> None:
300+
def warn(
301+
self,
302+
service: str,
303+
message: str,
304+
metadata: dict[str, Any] | None = None,
305+
*,
306+
trace_id: str | None = None,
307+
span_id: str | None = None,
308+
) -> None:
281309
"""Log a WARN-level message."""
282310
self.log(
283311
LogEntry(
284312
service=service,
285313
level=LogLevel.WARN,
286314
message=message,
287315
metadata=metadata or {},
316+
trace_id=trace_id,
317+
span_id=span_id,
288318
)
289319
)
290320

@@ -293,6 +323,9 @@ def error(
293323
service: str,
294324
message: str,
295325
metadata_or_error: dict[str, Any] | Exception | None = None,
326+
*,
327+
trace_id: str | None = None,
328+
span_id: str | None = None,
296329
) -> None:
297330
"""
298331
Log an ERROR-level message.
@@ -309,6 +342,8 @@ def error(
309342
level=LogLevel.ERROR,
310343
message=message,
311344
metadata=metadata,
345+
trace_id=trace_id,
346+
span_id=span_id,
312347
)
313348
)
314349

@@ -317,6 +352,9 @@ def critical(
317352
service: str,
318353
message: str,
319354
metadata_or_error: dict[str, Any] | Exception | None = None,
355+
*,
356+
trace_id: str | None = None,
357+
span_id: str | None = None,
320358
) -> None:
321359
"""
322360
Log a CRITICAL-level message.
@@ -333,6 +371,8 @@ def critical(
333371
level=LogLevel.CRITICAL,
334372
message=message,
335373
metadata=metadata,
374+
trace_id=trace_id,
375+
span_id=span_id,
336376
)
337377
)
338378

logtide_sdk/middleware/django.py

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
)
1414

1515
from logtide_sdk.client import LogTideClient, serialize_exception
16+
from logtide_sdk.tracecontext import resolve_trace_id
1617

1718

1819
class LogTideDjangoMiddleware:
@@ -64,9 +65,13 @@ def __call__(self, request: HttpRequest) -> HttpResponse:
6465
if self._should_skip(request.path):
6566
return self.get_response(request)
6667

67-
# Extract trace ID from headers (kept local — not set on the shared client
68-
# to avoid race conditions across concurrent requests).
69-
trace_id: str | None = request.headers.get("X-Trace-ID")
68+
# Resolve the inbound trace context (kept local — not set on the shared
69+
# client to avoid race conditions across concurrent requests).
70+
# Order: W3C traceparent -> legacy X-Trace-ID -> generated (spec 005).
71+
trace_id: str = resolve_trace_id(
72+
request.headers.get("traceparent"),
73+
request.headers.get("X-Trace-ID"),
74+
)
7075

7176
# Log request
7277
start_time = time.time()
@@ -103,13 +108,12 @@ def _log_request(self, request: HttpRequest, trace_id: str | None = None) -> Non
103108
}
104109
if self.include_headers:
105110
metadata["headers"] = dict(request.headers)
106-
if trace_id:
107-
metadata["trace_id"] = trace_id
108111

109112
self.client.info(
110113
self.service_name,
111114
f"{request.method} {request.path}",
112115
metadata,
116+
trace_id=trace_id,
113117
)
114118

115119
def _log_response(
@@ -128,17 +132,15 @@ def _log_response(
128132
}
129133
if self.include_headers:
130134
metadata["response_headers"] = dict(response.items())
131-
if trace_id:
132-
metadata["trace_id"] = trace_id
133135

134136
message = f"{request.method} {request.path} {response.status_code} ({duration_ms:.0f}ms)"
135137

136138
if response.status_code >= 500:
137-
self.client.error(self.service_name, message, metadata)
139+
self.client.error(self.service_name, message, metadata, trace_id=trace_id)
138140
elif response.status_code >= 400:
139-
self.client.warn(self.service_name, message, metadata)
141+
self.client.warn(self.service_name, message, metadata, trace_id=trace_id)
140142
else:
141-
self.client.info(self.service_name, message, metadata)
143+
self.client.info(self.service_name, message, metadata, trace_id=trace_id)
142144

143145
def _log_error(
144146
self,
@@ -154,12 +156,11 @@ def _log_error(
154156
"duration_ms": round(duration_ms, 2),
155157
"exception": serialize_exception(error),
156158
}
157-
if trace_id:
158-
metadata["trace_id"] = trace_id
159159
self.client.error(
160160
self.service_name,
161161
f"Request error: {request.method} {request.path} - {str(error)}",
162162
metadata,
163+
trace_id=trace_id,
163164
)
164165

165166
def _get_client_ip(self, request: HttpRequest) -> str | None:

0 commit comments

Comments
 (0)