From 983420ffe73f61cf7e8563d4a71fc00d772a2fe6 Mon Sep 17 00:00:00 2001 From: RKest Date: Wed, 17 Jun 2026 15:48:49 +0000 Subject: [PATCH] Add OTEL_SEMCONV_EXCEPTION_SIGNAL_OPT_IN support to record_exception `Span.record_exception` now honors the `OTEL_SEMCONV_EXCEPTION_SIGNAL_OPT_IN` environment variable, per the semantic conventions for exceptions in logs (https://opentelemetry.io/docs/specs/semconv/exceptions/exceptions-logs/): - unset: record exceptions as span events only (unchanged default) - `logs`: record exceptions as logs only - `logs/dup`: record exceptions as both logs and span events Exception logs are emitted at ERROR severity, correlated with the originating span's context, and use the span's instrumentation scope. --- .changelog/5323.added | 1 + .../src/opentelemetry/trace/span.py | 10 ++ .../tests/test_implementation.py | 3 + opentelemetry-api/tests/trace/test_globals.py | 11 +- .../src/opentelemetry/sdk/trace/__init__.py | 93 +++++++++++- .../tests/trace/test_record_exception_logs.py | 143 ++++++++++++++++++ 6 files changed, 259 insertions(+), 2 deletions(-) create mode 100644 .changelog/5323.added create mode 100644 opentelemetry-sdk/tests/trace/test_record_exception_logs.py diff --git a/.changelog/5323.added b/.changelog/5323.added new file mode 100644 index 00000000000..187c6346590 --- /dev/null +++ b/.changelog/5323.added @@ -0,0 +1 @@ +`opentelemetry-sdk`: `Span.record_exception` now honors `OTEL_SEMCONV_EXCEPTION_SIGNAL_OPT_IN` (`logs`/`logs/dup`) to record exceptions as logs instead of (or in addition to) span events. `Span.record_exception` also takes optional `event_name` and `severity_number` arguments, in `opentelemetry-api` and `opentelemetry-sdk`, applying to the log representation diff --git a/opentelemetry-api/src/opentelemetry/trace/span.py b/opentelemetry-api/src/opentelemetry/trace/span.py index a68489c8d63..811aa0b00be 100644 --- a/opentelemetry-api/src/opentelemetry/trace/span.py +++ b/opentelemetry-api/src/opentelemetry/trace/span.py @@ -14,6 +14,10 @@ from opentelemetry.trace.status import Status, StatusCode from opentelemetry.util import types +if typing.TYPE_CHECKING: + # Only imported for typing: `opentelemetry._logs` imports this module. + from opentelemetry._logs import SeverityNumber + # The key MUST begin with a lowercase letter or a digit, # and can only contain lowercase letters (a-z), digits (0-9), # underscores (_), dashes (-), asterisks (*), and forward slashes (/). @@ -175,6 +179,9 @@ def record_exception( attributes: types.Attributes = None, timestamp: int | None = None, escaped: bool = False, + *, + event_name: str | None = None, + severity_number: SeverityNumber | None = None, ) -> None: """Records an exception as a span event.""" @@ -560,6 +567,9 @@ def record_exception( attributes: types.Attributes = None, timestamp: int | None = None, escaped: bool = False, + *, + event_name: str | None = None, + severity_number: SeverityNumber | None = None, ) -> None: pass diff --git a/opentelemetry-api/tests/test_implementation.py b/opentelemetry-api/tests/test_implementation.py index 1357e4a2423..eb40ce8d804 100644 --- a/opentelemetry-api/tests/test_implementation.py +++ b/opentelemetry-api/tests/test_implementation.py @@ -56,6 +56,9 @@ def record_exception( attributes=None, timestamp=None, escaped=False, + *, + event_name=None, + severity_number=None, ) -> None: pass diff --git a/opentelemetry-api/tests/trace/test_globals.py b/opentelemetry-api/tests/trace/test_globals.py index 1035607a658..83280ee4673 100644 --- a/opentelemetry-api/tests/trace/test_globals.py +++ b/opentelemetry-api/tests/trace/test_globals.py @@ -27,7 +27,16 @@ def end(self, end_time=None): def is_recording(self): return not self.has_ended - def record_exception(self, exception, attributes=None, timestamp=None, escaped=False): + def record_exception( + self, + exception, + attributes=None, + timestamp=None, + escaped=False, + *, + event_name=None, + severity_number=None, + ): self.recorded_exception = exception diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py index 14d6573d0cf..16bae3e8fa0 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py @@ -30,9 +30,11 @@ from typing_extensions import deprecated +from opentelemetry import _logs from opentelemetry import context as context_api from opentelemetry import metrics as metrics_api from opentelemetry import trace as trace_api +from opentelemetry._logs import LogRecord, SeverityNumber from opentelemetry.attributes import BoundedAttributes from opentelemetry.sdk import util from opentelemetry.sdk.environment_variables import ( @@ -86,6 +88,23 @@ _ENV_VALUE_UNSET = "" +# OTEL_SEMCONV_EXCEPTION_SIGNAL_OPT_IN is an experimental, transitional opt-in +# that may be removed once exceptions-as-logs is stable, so it is kept private +# here rather than exported from opentelemetry.sdk.environment_variables. See +# https://opentelemetry.io/docs/specs/semconv/exceptions/exceptions-logs/. +_OTEL_SEMCONV_EXCEPTION_SIGNAL_OPT_IN = "OTEL_SEMCONV_EXCEPTION_SIGNAL_OPT_IN" +_EXCEPTION_SIGNAL_LOGS = "logs" +_EXCEPTION_SIGNAL_LOGS_DUP = "logs/dup" + + +def _exception_signal_opt_in() -> str: + """Returns the OTEL_SEMCONV_EXCEPTION_SIGNAL_OPT_IN value. + + Anything other than ``logs``/``logs/dup`` (unset or unrecognized) keeps the + existing span-event behavior. + """ + return environ.get(_OTEL_SEMCONV_EXCEPTION_SIGNAL_OPT_IN, _ENV_VALUE_UNSET) + class SpanProcessor: """Interface which allows hooks for SDK's `Span` start and end method @@ -1029,12 +1048,37 @@ def record_exception( attributes: types.Attributes = None, timestamp: int | None = None, escaped: bool = False, + *, + event_name: str | None = None, + severity_number: SeverityNumber | None = None, ) -> None: - """Records an exception as a span event.""" + """Records an exception as a span event and/or a log. + + By default the exception is recorded as a span event. Set + ``OTEL_SEMCONV_EXCEPTION_SIGNAL_OPT_IN`` to ``logs`` to record it + as a log instead, or ``logs/dup`` to record both. See + https://opentelemetry.io/docs/specs/semconv/exceptions/exceptions-logs/. + """ stacktrace = "".join(traceback.format_exception(exception)) module = type(exception).__module__ qualname = type(exception).__qualname__ exception_type = f"{module}.{qualname}" if module and module != "builtins" else qualname + event_name = event_name if event_name is not None else "exception" + severity_number = severity_number if severity_number is not None else SeverityNumber.ERROR + signal = _exception_signal_opt_in() + if signal in (_EXCEPTION_SIGNAL_LOGS, _EXCEPTION_SIGNAL_LOGS_DUP): + self._record_exception_log( + exception_type=exception_type, + exception_message=str(exception), + stacktrace=stacktrace, + attributes=attributes, + timestamp=timestamp, + event_name=event_name, + severity_number=severity_number, + ) + if signal == _EXCEPTION_SIGNAL_LOGS: + return + _attributes: MutableMapping[str, types.AnyValue] = { EXCEPTION_TYPE: exception_type, EXCEPTION_MESSAGE: str(exception), @@ -1045,6 +1089,53 @@ def record_exception( _attributes.update(attributes) self.add_event(name="exception", attributes=_attributes, timestamp=timestamp) + def _record_exception_log( + self, + *, + exception_type: str, + exception_message: str, + stacktrace: str, + attributes: types.Attributes, + timestamp: int | None, + event_name: str, + severity_number: SeverityNumber, + ) -> None: + """Emits the exception as a log correlated with this span.""" + # Checked without `self._lock` on purpose; the lock would be held + # across `emit()`, blocking `end()` for the duration of a synchronous + # export, and a concurrently ending span is not worth that. + if self._end_time is not None: + logger.warning("Tried calling record_exception on an ended span.") + return + + log_attributes: dict[str, types.AnyValue] = { + EXCEPTION_TYPE: exception_type, + EXCEPTION_MESSAGE: exception_message, + EXCEPTION_STACKTRACE: stacktrace, + } + if attributes: + log_attributes.update(attributes) + + scope = self.instrumentation_scope + exception_logger = _logs.get_logger( + scope.name if scope else __name__, + (scope.version or "") if scope else "", + schema_url=scope.schema_url if scope else None, + attributes=scope.attributes if scope else None, + ) + # The `exception.escaped` attribute is intentionally omitted as it is + # deprecated for the logs representation. + exception_logger.emit( + LogRecord( + timestamp=timestamp, + context=trace_api.set_span_in_context(self), + event_name=event_name, + severity_number=severity_number, + severity_text=severity_number.name, + attributes=log_attributes, + ) + ) + class _Span(Span): """Protected implementation of `opentelemetry.trace.Span`. diff --git a/opentelemetry-sdk/tests/trace/test_record_exception_logs.py b/opentelemetry-sdk/tests/trace/test_record_exception_logs.py new file mode 100644 index 00000000000..f2506a85d9c --- /dev/null +++ b/opentelemetry-sdk/tests/trace/test_record_exception_logs.py @@ -0,0 +1,143 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for OTEL_SEMCONV_EXCEPTION_SIGNAL_OPT_IN handling in record_exception.""" + +import os +import unittest +from unittest import mock + +from opentelemetry._logs import SeverityNumber +from opentelemetry.sdk.trace import ( + _OTEL_SEMCONV_EXCEPTION_SIGNAL_OPT_IN as OTEL_SEMCONV_EXCEPTION_SIGNAL_OPT_IN, +) +from opentelemetry.sdk.trace import ( + TracerProvider, +) +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) + + +class _CapturingLogger: + """A minimal Logger that records emitted LogRecords.""" + + def __init__(self): + self.records = [] + + def emit(self, record): + self.records.append(record) + + +class TestRecordExceptionSignalOptIn(unittest.TestCase): + def setUp(self): + self.span_exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(self.span_exporter)) + self.tracer = provider.get_tracer("test-scope", "1.0", attributes={"scope.key": "v"}) + self.logger = _CapturingLogger() + patcher = mock.patch( + "opentelemetry.sdk.trace._logs.get_logger", + return_value=self.logger, + ) + self.mock_get_logger = patcher.start() + self.addCleanup(patcher.stop) + + def _raise_in_span(self): + with self.assertRaises(ValueError): + with self.tracer.start_as_current_span("op"): + raise ValueError("boom") + + def _exception_events(self): + finished_span = self.span_exporter.get_finished_spans()[0] + return [e for e in finished_span.events if e.name == "exception"] + + def test_unset_records_span_event_only(self): + with mock.patch.dict(os.environ, {}, clear=True): + self._raise_in_span() + self.assertEqual(len(self._exception_events()), 1) + self.assertEqual(self.logger.records, []) + + def test_unrecognized_value_records_span_event_only(self): + with mock.patch.dict(os.environ, {OTEL_SEMCONV_EXCEPTION_SIGNAL_OPT_IN: "bogus"}): + self._raise_in_span() + self.assertEqual(len(self._exception_events()), 1) + self.assertEqual(self.logger.records, []) + + def test_logs_records_log_only(self): + with mock.patch.dict(os.environ, {OTEL_SEMCONV_EXCEPTION_SIGNAL_OPT_IN: "logs"}): + self._raise_in_span() + self.assertEqual(self._exception_events(), []) + self.assertEqual(len(self.logger.records), 1) + record = self.logger.records[0] + self.assertEqual(record.event_name, "exception") + self.assertEqual(record.severity_number, SeverityNumber.ERROR) + self.assertEqual(record.attributes["exception.type"], "ValueError") + self.assertEqual(record.attributes["exception.message"], "boom") + self.assertIn("ValueError: boom", record.attributes["exception.stacktrace"]) + # The deprecated exception.escaped attribute is not set on logs. + self.assertNotIn("exception.escaped", record.attributes) + + def test_logs_dup_records_both(self): + with mock.patch.dict(os.environ, {OTEL_SEMCONV_EXCEPTION_SIGNAL_OPT_IN: "logs/dup"}): + self._raise_in_span() + self.assertEqual(len(self._exception_events()), 1) + self.assertEqual(len(self.logger.records), 1) + + def test_log_is_correlated_with_span(self): + with mock.patch.dict(os.environ, {OTEL_SEMCONV_EXCEPTION_SIGNAL_OPT_IN: "logs"}): + self._raise_in_span() + finished_span = self.span_exporter.get_finished_spans()[0] + record = self.logger.records[0] + self.assertEqual(record.trace_id, finished_span.context.trace_id) + self.assertEqual(record.span_id, finished_span.context.span_id) + + def test_logs_uses_instrumentation_scope_for_logger(self): + with mock.patch.dict(os.environ, {OTEL_SEMCONV_EXCEPTION_SIGNAL_OPT_IN: "logs"}): + self._raise_in_span() + # The logger is obtained using the span's instrumentation scope. + args, kwargs = self.mock_get_logger.call_args + self.assertEqual(args[0], "test-scope") + self.assertEqual(dict(kwargs["attributes"]), {"scope.key": "v"}) + + def test_ended_span_does_not_emit_log(self): + with mock.patch.dict(os.environ, {OTEL_SEMCONV_EXCEPTION_SIGNAL_OPT_IN: "logs"}): + with self.tracer.start_as_current_span("op") as span: + pass + span.record_exception(ValueError("boom")) + self.assertEqual(self.logger.records, []) + + def test_directly_recorded_exception_is_logged(self): + with mock.patch.dict(os.environ, {OTEL_SEMCONV_EXCEPTION_SIGNAL_OPT_IN: "logs"}): + with self.tracer.start_as_current_span("op") as span: + try: + raise ValueError("handled") + except ValueError as err: + span.record_exception(err) + record = self.logger.records[0] + self.assertEqual(record.event_name, "exception") + self.assertEqual(record.severity_number, SeverityNumber.ERROR) + self.assertEqual(record.attributes["exception.type"], "ValueError") + + def test_event_name_and_severity_can_be_overridden(self): + with mock.patch.dict(os.environ, {OTEL_SEMCONV_EXCEPTION_SIGNAL_OPT_IN: "logs/dup"}): + with self.tracer.start_as_current_span("op") as span: + span.record_exception( + ValueError("boom"), + event_name="http.client.request.exception", + severity_number=SeverityNumber.WARN, + ) + record = self.logger.records[0] + self.assertEqual(record.event_name, "http.client.request.exception") + self.assertEqual(record.severity_number, SeverityNumber.WARN) + self.assertEqual(record.severity_text, "WARN") + # The span event representation always uses the "exception" name. + self.assertEqual(len(self._exception_events()), 1) + + def test_extra_attributes_forwarded_to_log(self): + with mock.patch.dict(os.environ, {OTEL_SEMCONV_EXCEPTION_SIGNAL_OPT_IN: "logs"}): + with self.tracer.start_as_current_span("op") as span: + span.record_exception(ValueError("boom"), attributes={"custom.key": "v"}) + record = self.logger.records[0] + self.assertEqual(record.attributes["custom.key"], "v")