diff --git a/README.md b/README.md index 4cb8f0f..edc91f7 100644 --- a/README.md +++ b/README.md @@ -463,6 +463,22 @@ Parameters description: These settings are subsequently passed to [opentelemetry](https://opentelemetry.io/), finalizing your Opentelemetry integration. +If an authoritative baggage value becomes available only after the current span has started, pass the same mapping to +`opentelemetry_baggage_scope` to also materialize that value on the already-started span: + +```python +from microbootstrap import opentelemetry_baggage_scope + + +with opentelemetry_baggage_scope( + {"conversation_id": conversation_id}, + current_span_attributes=settings.opentelemetry_baggage_span_attributes, +): + ... +``` + +Only non-`None` baggage values supplied to the scope and present in the mapping are added to a recording current span. + #### FastStream For FastStream you also should pass `opentelemetry_middleware_cls` - OpenTelemetry middleware for your broker diff --git a/microbootstrap/instruments/opentelemetry_instrument.py b/microbootstrap/instruments/opentelemetry_instrument.py index 0a90602..dad1ebf 100644 --- a/microbootstrap/instruments/opentelemetry_instrument.py +++ b/microbootstrap/instruments/opentelemetry_instrument.py @@ -17,7 +17,7 @@ from opentelemetry.sdk.trace import TracerProvider as SdkTracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter, SimpleSpanProcessor from opentelemetry.semconv.resource import ResourceAttributes -from opentelemetry.trace import SpanKind, format_span_id, set_tracer_provider +from opentelemetry.trace import SpanKind, format_span_id, get_current_span, set_tracer_provider from opentelemetry.util._importlib_metadata import entry_points from microbootstrap.instruments.base import BaseInstrumentConfig, Instrument @@ -44,6 +44,8 @@ @contextlib.contextmanager def opentelemetry_baggage_scope( baggage_values: typing.Mapping[str, object | None], + *, + current_span_attributes: typing.Mapping[str, str] | None = None, ) -> typing.Iterator[None]: baggage_context = context.get_current() for key, value in baggage_values.items(): @@ -55,6 +57,10 @@ def opentelemetry_baggage_scope( token: typing.Final = context.attach(baggage_context) try: + if current_span_attributes and (current_span := get_current_span()).is_recording(): + for baggage_key, span_attribute in current_span_attributes.items(): + if baggage_key in baggage_values and (value := baggage_values[baggage_key]) is not None: + current_span.set_attribute(span_attribute, str(value)) yield except Exception as exc: with contextlib.suppress(Exception): diff --git a/tests/instruments/test_opentelemetry.py b/tests/instruments/test_opentelemetry.py index 0ebc7c1..72dbe36 100644 --- a/tests/instruments/test_opentelemetry.py +++ b/tests/instruments/test_opentelemetry.py @@ -64,6 +64,51 @@ def test_opentelemetry_baggage_scope_does_not_replace_exception_when_snapshot_fa raise ValueError("application error") +def test_opentelemetry_baggage_scope_materializes_supplied_values_on_current_span( + monkeypatch: pytest.MonkeyPatch, +) -> None: + conversation_id = 42 + current_span = MagicMock(spec=Span) + current_span.is_recording.return_value = True + monkeypatch.setattr(opentelemetry_instrument, "get_current_span", Mock(return_value=current_span)) + + with opentelemetry_baggage_scope( + {"conversation_id": conversation_id, "not_configured": "ignored"}, + current_span_attributes={ + "conversation_id": "conversation.id", + "not_supplied": "not.supplied", + }, + ): + assert baggage.get_baggage("conversation_id") == conversation_id + + current_span.set_attribute.assert_called_once_with("conversation.id", str(conversation_id)) + + +@pytest.mark.parametrize( + ("baggage_value", "is_recording"), + [ + (None, True), + ("conversation-1", False), + ], +) +def test_opentelemetry_baggage_scope_skips_current_span_attribute( + baggage_value: str | None, + is_recording: bool, + monkeypatch: pytest.MonkeyPatch, +) -> None: + current_span = MagicMock(spec=Span) + current_span.is_recording.return_value = is_recording + monkeypatch.setattr(opentelemetry_instrument, "get_current_span", Mock(return_value=current_span)) + + with opentelemetry_baggage_scope( + {"conversation_id": baggage_value}, + current_span_attributes={"conversation_id": "conversation.id"}, + ): + pass + + current_span.set_attribute.assert_not_called() + + @pytest.mark.parametrize( ("span_kind", "expected_attribute"), [