diff --git a/src/rius/__init__.py b/src/rius/__init__.py index ca7fa66..3b61923 100644 --- a/src/rius/__init__.py +++ b/src/rius/__init__.py @@ -9,7 +9,7 @@ from .observe import observe from .semconv import SpanKind from .session import session -from .spans import Observation, start_as_current_span, start_span +from .spans import Observation, current_trace_id, start_as_current_span, start_span __all__ = [ "Generation", @@ -20,6 +20,7 @@ "SpanKind", "__version__", "build_span_exporter", + "current_trace_id", "get_tracer", "init", "observe", diff --git a/src/rius/spans.py b/src/rius/spans.py index 1726f0a..976edb3 100644 --- a/src/rius/spans.py +++ b/src/rius/spans.py @@ -11,6 +11,9 @@ ``start_generation`` / ``start_as_current_generation`` are the LLM-specialized equivalents. + +``current_trace_id`` is the read side of the same context: it reports the trace +the caller is already running inside, without creating anything. """ from __future__ import annotations @@ -20,7 +23,7 @@ from typing import Any from opentelemetry import trace -from opentelemetry.trace import Span +from opentelemetry.trace import Span, format_trace_id from . import __version__ from ._serde import serialize @@ -131,3 +134,21 @@ def start_as_current_span( observation = Observation(span) _configure(observation, kind, input) yield observation + + +def current_trace_id() -> str | None: + """Return the trace id of the caller's active trace, or ``None`` if untraced. + + Reads the ambient OpenTelemetry context, so it reports whatever trace the + caller is running inside — the enclosing ``start_as_current_span`` / + ``@observe`` scope, or a context propagated in from upstream — not a trace + this SDK created. The id is the standard 32-character lowercase hex form, + ready to hand to the platform API or embed in a link. + + ``None`` means there is genuinely no active trace (no span in context, or a + sampled-out placeholder), rather than the all-zeros invalid id. + """ + span_context = trace.get_current_span().get_span_context() + if not span_context.is_valid: + return None + return format_trace_id(span_context.trace_id) diff --git a/tests/test_spans.py b/tests/test_spans.py index 32cfe65..b6246f9 100644 --- a/tests/test_spans.py +++ b/tests/test_spans.py @@ -1,8 +1,10 @@ +import re + import pytest from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter -from opentelemetry.trace import StatusCode +from opentelemetry.trace import INVALID_SPAN, StatusCode, format_trace_id, use_span -from rius import start_as_current_span, start_span +from rius import current_trace_id, start_as_current_span, start_span from rius.semconv import SpanKind # --- context manager: start_as_current_span --- @@ -74,3 +76,33 @@ def test_manual_span_is_not_current(exported_spans: InMemorySpanExporter) -> Non obs.end() spans = {s.name: s for s in exported_spans.get_finished_spans()} assert spans["inner"].parent is None # manual span is not activated as current + + +# --- ambient trace id: current_trace_id --- + + +def test_current_trace_id_inside_span_matches_span_context( + exported_spans: InMemorySpanExporter, +) -> None: + with start_as_current_span("op"): + trace_id = current_trace_id() + span = exported_spans.get_finished_spans()[0] + assert trace_id == format_trace_id(span.context.trace_id) + assert re.fullmatch(r"[0-9a-f]{32}", trace_id or "") + + +def test_current_trace_id_nested_span_reports_the_enclosing_trace() -> None: + with start_as_current_span("outer"): + outer = current_trace_id() + with start_as_current_span("inner"): + inner = current_trace_id() + assert inner == outer + + +def test_current_trace_id_outside_any_span_is_none() -> None: + assert current_trace_id() is None + + +def test_current_trace_id_ignores_the_invalid_span() -> None: + with use_span(INVALID_SPAN): + assert current_trace_id() is None