Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

- logs: add exception support to Logger emit and LogRecord attributes
([#4907](https://github.com/open-telemetry/opentelemetry-python/issues/4907))
- `opentelemetry-sdk`: Add file configuration support with YAML/JSON loading, environment variable substitution, and schema validation against the vendored OTel config JSON schema
([#4898](https://github.com/open-telemetry/opentelemetry-python/pull/4898))
- Fix intermittent CI failures in `getting-started` and `tracecontext` jobs caused by GitHub git CDN SHA propagation lag by installing contrib packages from the already-checked-out local copy instead of a second git clone
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ def __init__(
body: AnyValue = None,
attributes: Optional[_ExtendedAttributes] = None,
event_name: Optional[str] = None,
exception: Optional[BaseException] = None,
) -> None: ...

@overload
Expand Down Expand Up @@ -110,6 +111,7 @@ def __init__(
body: AnyValue = None,
attributes: Optional[_ExtendedAttributes] = None,
event_name: Optional[str] = None,
exception: Optional[BaseException] = None,
) -> None:
if not context:
context = get_current()
Expand All @@ -127,6 +129,7 @@ def __init__(
self.body = body
self.attributes = attributes
self.event_name = event_name
self.exception = exception


class Logger(ABC):
Expand Down Expand Up @@ -157,12 +160,15 @@ def emit(
body: AnyValue | None = None,
attributes: _ExtendedAttributes | None = None,
event_name: str | None = None,
exception: BaseException | None = None,
) -> None: ...

@overload
def emit(
self,
record: LogRecord,
*,
exception: BaseException | None = None,
) -> None: ...

@abstractmethod
Expand All @@ -178,6 +184,7 @@ def emit(
body: AnyValue | None = None,
attributes: _ExtendedAttributes | None = None,
event_name: str | None = None,
exception: BaseException | None = None,
) -> None:
"""Emits a :class:`LogRecord` representing a log to the processing pipeline."""

Expand All @@ -200,12 +207,15 @@ def emit(
body: AnyValue | None = None,
attributes: _ExtendedAttributes | None = None,
event_name: str | None = None,
exception: BaseException | None = None,
) -> None: ...

@overload
def emit( # pylint:disable=arguments-differ
self,
record: LogRecord,
*,
exception: BaseException | None = None,
) -> None: ...

def emit(
Expand All @@ -220,6 +230,7 @@ def emit(
body: AnyValue | None = None,
attributes: _ExtendedAttributes | None = None,
event_name: str | None = None,
exception: BaseException | None = None,
) -> None:
pass

Expand Down Expand Up @@ -266,12 +277,15 @@ def emit(
body: AnyValue | None = None,
attributes: _ExtendedAttributes | None = None,
event_name: str | None = None,
exception: BaseException | None = None,
) -> None: ...

@overload
def emit( # pylint:disable=arguments-differ
self,
record: LogRecord,
*,
exception: BaseException | None = None,
) -> None: ...

def emit(
Expand All @@ -286,9 +300,10 @@ def emit(
body: AnyValue | None = None,
attributes: _ExtendedAttributes | None = None,
event_name: str | None = None,
exception: BaseException | None = None,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: When record is provided alongside an exception kwarg, the exception is silently dropped here (only forwarded in the else branch). The SDK's Logger.emit() explicitly handles both (exception or getattr(record, "exception", None)), so there's a subtle inconsistency between the proxy and SDK paths.

Not a contract violation (the overloads define them as separate call forms), but it might be worth either forwarding exception here too (self._logger.emit(record, exception=exception)) or adding a comment explaining why it's intentionally omitted.

) -> None:
if record:
self._logger.emit(record)
self._logger.emit(record, exception=exception)
else:
self._logger.emit(
timestamp=timestamp,
Expand All @@ -299,6 +314,7 @@ def emit(
body=body,
attributes=attributes,
event_name=event_name,
exception=exception,
)


Expand Down
5 changes: 5 additions & 0 deletions opentelemetry-api/tests/logs/test_log_record.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,8 @@ class TestLogRecord(unittest.TestCase):
def test_log_record_observed_timestamp_default(self, time_ns_mock): # type: ignore
time_ns_mock.return_value = OBSERVED_TIMESTAMP
self.assertEqual(LogRecord().observed_timestamp, OBSERVED_TIMESTAMP)

def test_log_record_exception(self):
exc = ValueError("boom")
log_record = LogRecord(exception=exc)
self.assertIs(log_record.exception, exc)
15 changes: 15 additions & 0 deletions opentelemetry-api/tests/logs/test_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
# pylint: disable=W0212,W0222,W0221
import typing
import unittest
from unittest.mock import Mock

import opentelemetry._logs._internal as _logs_internal
from opentelemetry import _logs
Expand Down Expand Up @@ -46,6 +47,7 @@ def emit(
body=None,
attributes=None,
event_name=None,
exception: typing.Optional[BaseException] = None,
) -> None:
pass

Expand Down Expand Up @@ -74,3 +76,16 @@ def test_proxy_logger(self):
# references to the old provider still work but return real logger now
real_logger = provider.get_logger("proxy-test")
self.assertIsInstance(real_logger, LoggerTest)

def test_proxy_logger_forwards_exception_with_record(self):
logger = _logs_internal.ProxyLogger("proxy-test")
logger._real_logger = Mock(spec=LoggerTest("proxy-test"))
record = _logs.LogRecord()
exception = ValueError("boom")

self.assertIsNotNone(logger._real_logger)
logger.emit(record, exception=exception)

logger._real_logger.emit.assert_called_once_with(
record, exception=exception
)
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,78 @@ def force_flush(self, timeout_millis: int = 30000) -> bool:
)


def _get_exception_attributes(
exception: BaseException,
) -> dict[str, AnyValue]:
stacktrace = "".join(
traceback.format_exception(
type(exception), value=exception, tb=exception.__traceback__
)
)
module = type(exception).__module__
qualname = type(exception).__qualname__
exception_type = (
f"{module}.{qualname}" if module and module != "builtins" else qualname
)
return {
exception_attributes.EXCEPTION_TYPE: exception_type,
exception_attributes.EXCEPTION_MESSAGE: str(exception),
exception_attributes.EXCEPTION_STACKTRACE: stacktrace,
}


def _get_attributes_with_exception(
attributes: _ExtendedAttributes | None,
exception: BaseException | None,
) -> _ExtendedAttributes | None:
if exception is None:
return attributes

exception_attributes_map = _get_exception_attributes(exception)
if attributes is None:
attributes_map: _ExtendedAttributes = {}
else:
attributes_map = attributes

if isinstance(attributes_map, BoundedAttributes):
bounded_attributes = attributes_map
merged = BoundedAttributes(
maxlen=bounded_attributes.maxlen,
attributes=bounded_attributes,
immutable=False,
max_value_len=bounded_attributes.max_value_len,
extended_attributes=bounded_attributes._extended_attributes, # pylint: disable=protected-access
)
merged.dropped = bounded_attributes.dropped
for key, value in exception_attributes_map.items():
if key not in merged:
merged[key] = value
return merged

return exception_attributes_map | dict(attributes_map.items())


def _copy_log_record(
record: LogRecord,
attributes: _ExtendedAttributes | None,
) -> LogRecord:
copied_record = LogRecord(
timestamp=record.timestamp,
observed_timestamp=record.observed_timestamp,
context=record.context,
severity_text=record.severity_text,
severity_number=record.severity_number,
body=record.body,
attributes=attributes,
event_name=record.event_name,
exception=getattr(record, "exception", None),
)
copied_record.trace_id = record.trace_id
copied_record.span_id = record.span_id
copied_record.trace_flags = record.trace_flags
return copied_record


class LoggingHandler(logging.Handler):
"""A handler class which writes logging records, in OTLP format, to
a network destination or file. Supports signals from the `logging` module.
Expand Down Expand Up @@ -671,32 +743,54 @@ def emit(
body: AnyValue | None = None,
attributes: _ExtendedAttributes | None = None,
event_name: str | None = None,
exception: BaseException | None = None,
) -> None:
"""Emits the :class:`ReadWriteLogRecord` by setting instrumentation scope
and forwarding to the processor.
"""
# If a record is provided, use it directly
if record is not None:
record_exception = exception or getattr(record, "exception", None)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally, I'm not a huge fan of modifying attributes of an already created log record. For example, if the attributes in the created log record are immutable, the _apply_exception_attributes function will raise an exception at runtime.

if record_exception is None and isinstance(
record, ReadWriteLogRecord
):
record_exception = getattr(
record.log_record, "exception", None
)
if not isinstance(record, ReadWriteLogRecord):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might not be relevant to this PR but do we know what this instance check is here? Doesn't emit expect a LogRecord and not a ReadWriteLogRecord? When would the latter be ever passed in?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReadWriteLogRecord is an SDK-internal type, so (I guess) this branch is just a defensive fast path for already-materialized SDK records, while the public Logger.emit() API still expects callers to pass a LogRecord.

if record_exception is not None:
record = _copy_log_record(
record,
_get_attributes_with_exception(
record.attributes, record_exception
),
)
# pylint:disable=protected-access
writable_record = ReadWriteLogRecord._from_api_log_record(
record=record,
resource=self._resource,
instrumentation_scope=self._instrumentation_scope,
)
else:
record.log_record.attributes = _get_attributes_with_exception(
record.log_record.attributes, record_exception
)
writable_record = record
else:
# Create a record from individual parameters
log_record_attributes = _get_attributes_with_exception(
attributes, exception
)
log_record = LogRecord(
timestamp=timestamp,
observed_timestamp=observed_timestamp,
context=context,
severity_number=severity_number,
severity_text=severity_text,
body=body,
attributes=attributes,
attributes=log_record_attributes,
event_name=event_name,
exception=exception,
)
# pylint:disable=protected-access
writable_record = ReadWriteLogRecord._from_api_log_record(
Expand Down
Loading
Loading