Skip to content

Commit b690447

Browse files
adriangbalexmojaki
andauthored
Generate trace IDs as ULIDs by default (#783)
Co-authored-by: Alex Hall <alex.mojaki@gmail.com>
1 parent 488f399 commit b690447

File tree

7 files changed

+102
-14
lines changed

7 files changed

+102
-14
lines changed

logfire/_internal/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -582,7 +582,7 @@ def _load_configuration(
582582
# This is particularly for deserializing from a dict as in executors.py
583583
advanced = AdvancedOptions(**advanced) # type: ignore
584584
id_generator = advanced.id_generator
585-
if isinstance(id_generator, dict) and list(id_generator.keys()) == ['seed']: # type: ignore # pragma: no branch
585+
if isinstance(id_generator, dict) and list(id_generator.keys()) == ['seed', '_ms_timestamp_generator']: # type: ignore # pragma: no branch
586586
advanced.id_generator = SeededRandomIdGenerator(**id_generator) # type: ignore
587587
elif advanced is None:
588588
advanced = AdvancedOptions(base_url=param_manager.load_param('base_url'))

logfire/_internal/ulid.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from __future__ import annotations
2+
3+
from random import Random
4+
from typing import Callable
5+
6+
7+
def ulid(random: Random, ms_timestamp_generator: Callable[[], int]) -> int:
8+
"""Generate an integer ULID compatible with UUID v4.
9+
10+
ULIDs as defined by the [spec](https://github.com/ulid/spec) look like this:
11+
12+
01AN4Z07BY 79KA1307SR9X4MV3
13+
|----------| |----------------|
14+
Timestamp Randomness
15+
48bits 80bits
16+
17+
In the future it would be nice to make this compatible with a UUID,
18+
e.g. v4 UUIDs by setting the version and variant bits correctly.
19+
We can't currently do this because setting these bits would leave us with only 7 bytes of randomness,
20+
which isn't enough for the Python SDK's sampler that currently expects 8 bytes of randomness.
21+
In the future OTEL will probably adopt https://www.w3.org/TR/trace-context-2/#random-trace-id-flag
22+
which relies only on the lower 7 bytes of the trace ID, then all SDKs and tooling should be updated
23+
and leaving only 7 bytes of randomness should be fine.
24+
25+
Right now we only care about:
26+
- Our SDK / Python SDK's in general.
27+
- The OTEL collector.
28+
29+
And both behave properly with 8 bytes of randomness because trace IDs were originally 64 bits
30+
so to be compatible with old trace IDs nothing in OTEL can assume >8 bytes of randomness in trace IDs
31+
unless they generated the trace ID themselves (e.g. the Go SDK _does_ expect >8 bytes of randomness internally).
32+
"""
33+
# Timestamp: first 6 bytes of the ULID (48 bits)
34+
# Note that it's not important that this timestamp is super precise or unique.
35+
# It just needs to be roughly monotonically increasing so that the ULID is sortable, at least for our purposes.
36+
timestamp = ms_timestamp_generator().to_bytes(6, byteorder='big')
37+
# Randomness: next 10 bytes of the ULID (80 bits)
38+
randomness = random.getrandbits(80).to_bytes(10, byteorder='big')
39+
# Convert to int and return
40+
return int.from_bytes(timestamp + randomness, byteorder='big')

logfire/_internal/utils.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@
99
from contextlib import contextmanager
1010
from dataclasses import dataclass
1111
from pathlib import Path
12+
from time import time
1213
from types import TracebackType
13-
from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Sequence, Tuple, TypedDict, TypeVar, Union
14+
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Mapping, Sequence, Tuple, TypedDict, TypeVar, Union
1415

1516
from opentelemetry import context, trace as trace_api
1617
from opentelemetry.sdk.resources import Resource
@@ -22,6 +23,7 @@
2223
from requests import RequestException, Response
2324

2425
from logfire._internal.stack_info import is_user_code
26+
from logfire._internal.ulid import ulid
2527

2628
if TYPE_CHECKING:
2729
from packaging.version import Version
@@ -358,7 +360,11 @@ def is_asgi_send_receive_span_name(name: str) -> bool:
358360
return name.endswith((' http send', ' http receive', ' websocket send', ' websocket receive'))
359361

360362

361-
@dataclass(repr=True)
363+
def _default_ms_timestamp_generator() -> int:
364+
return int(time() * 1000)
365+
366+
367+
@dataclass(repr=True, eq=True)
362368
class SeededRandomIdGenerator(IdGenerator):
363369
"""Generate random span/trace IDs from a seed for deterministic tests.
364370
@@ -371,6 +377,8 @@ class SeededRandomIdGenerator(IdGenerator):
371377
"""
372378

373379
seed: int | None = 0
380+
_ms_timestamp_generator: Callable[[], int] = _default_ms_timestamp_generator
381+
"""Private argument, do not set this directly."""
374382

375383
def __post_init__(self) -> None:
376384
self.random = random.Random(self.seed)
@@ -384,7 +392,7 @@ def generate_span_id(self) -> int:
384392
return span_id
385393

386394
def generate_trace_id(self) -> int:
387-
trace_id = self.random.getrandbits(128)
395+
trace_id = ulid(self.random, self._ms_timestamp_generator)
388396
while trace_id == trace_api.INVALID_TRACE_ID: # pragma: no cover
389-
trace_id = self.random.getrandbits(128)
397+
trace_id = ulid(self.random, self._ms_timestamp_generator)
390398
return trace_id

tests/conftest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from opentelemetry import trace
1212
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
1313
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
14+
from opentelemetry.sdk.trace.id_generator import IdGenerator
1415

1516
import logfire
1617
from logfire import configure
@@ -55,7 +56,7 @@ def metrics_reader() -> InMemoryMetricReader:
5556
@pytest.fixture
5657
def config_kwargs(
5758
exporter: TestExporter,
58-
id_generator: IncrementalIdGenerator,
59+
id_generator: IdGenerator,
5960
time_generator: TimeGenerator,
6061
) -> dict[str, Any]:
6162
"""

tests/test_logfire.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
from logfire._internal.formatter import FormattingFailedWarning, InspectArgumentsFailedWarning
3939
from logfire._internal.main import NoopSpan
4040
from logfire._internal.tracer import record_exception
41-
from logfire._internal.utils import is_instrumentation_suppressed
41+
from logfire._internal.utils import SeededRandomIdGenerator, is_instrumentation_suppressed
4242
from logfire.integrations.logging import LogfireLoggingHandler
4343
from logfire.testing import TestExporter
4444
from tests.test_metrics import get_collected_metrics
@@ -3213,3 +3213,42 @@ def test_exit_ended_span(exporter: TestExporter):
32133213
}
32143214
]
32153215
)
3216+
3217+
3218+
_ns_currnet_ts = 0
3219+
3220+
3221+
def incrementing_ms_ts_generator() -> int:
3222+
global _ns_currnet_ts
3223+
_ns_currnet_ts += 420_000 # some randon number that results in non-whole ms
3224+
return _ns_currnet_ts // 1_000_000
3225+
3226+
3227+
@pytest.mark.parametrize(
3228+
'id_generator',
3229+
[SeededRandomIdGenerator(_ms_timestamp_generator=incrementing_ms_ts_generator)],
3230+
)
3231+
def test_default_id_generator(exporter: TestExporter) -> None:
3232+
"""Test that SeededRandomIdGenerator generates trace and span ids without errors."""
3233+
for i in range(1024):
3234+
logfire.info('log', i=i)
3235+
3236+
exported = exporter.exported_spans_as_dict()
3237+
3238+
# sanity check: there are 1024 trace ids
3239+
assert len({export['context']['trace_id'] for export in exported}) == 1024
3240+
# sanity check: there are multiple milliseconds (first 6 bytes)
3241+
assert len({export['context']['trace_id'] >> 80 for export in exported}) == snapshot(431)
3242+
3243+
# Check that trace ids are sortable and unique
3244+
# We use ULIDs to generate trace ids, so they should be sortable.
3245+
sorted_by_trace_id = [
3246+
export['attributes']['i']
3247+
# sort by trace_id and start_time so that if two trace ids were generated in the same ms and thus may sort randomly
3248+
# we disambiguate with the start time
3249+
for export in sorted(exported, key=lambda span: (span['context']['trace_id'] >> 80, span['start_time']))
3250+
]
3251+
sorted_by_start_timestamp = [
3252+
export['attributes']['i'] for export in sorted(exported, key=lambda span: span['start_time'])
3253+
]
3254+
assert sorted_by_trace_id == sorted_by_start_timestamp

tests/test_sampling.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ def test_sample_rate_config(exporter: TestExporter, config_kwargs: dict[str, Any
7070

7171
# 1000 iterations of 2 spans -> 2000 spans
7272
# 30% sampling -> 600 spans (approximately)
73-
assert len(exporter.exported_spans_as_dict()) == 634
73+
assert len(exporter.exported_spans_as_dict()) == 588, len(exporter.exported_spans_as_dict())
7474

7575

7676
@pytest.mark.skipif(

tests/test_tail_sampling.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ def test_background_rate(config_kwargs: dict[str, Any], exporter: TestExporter):
345345
# None of them meet the tail sampling criteria.
346346
for _ in range(1000):
347347
logfire.info('info')
348-
assert len(exporter.exported_spans) == 100 + 321
348+
assert len(exporter.exported_spans) - 100 == snapshot(299)
349349

350350

351351
class TestSampler(Sampler):
@@ -406,7 +406,7 @@ def test_raw_head_sampler_with_tail_sampling(config_kwargs: dict[str, Any], expo
406406
# None of them meet the tail sampling criteria.
407407
for _ in range(1000):
408408
logfire.info('info')
409-
assert len(exporter.exported_spans) == 100 + 315
409+
assert len(exporter.exported_spans) - 100 == snapshot(293)
410410

411411

412412
def test_custom_head_and_tail(config_kwargs: dict[str, Any], exporter: TestExporter):
@@ -432,20 +432,20 @@ def get_tail_sample_rate(span_info: TailSamplingSpanInfo) -> float:
432432

433433
for _ in range(1000):
434434
logfire.warn('warn')
435-
assert span_counts == snapshot({'start': 720, 'end': 617})
436-
assert len(exporter.exported_spans) == snapshot(103)
435+
assert span_counts == snapshot({'start': 719, 'end': 611})
436+
assert len(exporter.exported_spans) == snapshot(108)
437437
assert span_counts['end'] + len(exporter.exported_spans) == span_counts['start']
438438

439439
exporter.clear()
440440
for _ in range(1000):
441441
with logfire.span('span'):
442442
pass
443-
assert len(exporter.exported_spans_as_dict()) == snapshot(505)
443+
assert len(exporter.exported_spans_as_dict()) == snapshot(511)
444444

445445
exporter.clear()
446446
for _ in range(1000):
447447
logfire.error('error')
448-
assert len(exporter.exported_spans) == snapshot(282)
448+
assert len(exporter.exported_spans) == snapshot(298)
449449

450450

451451
def test_span_levels():

0 commit comments

Comments
 (0)