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
1 change: 1 addition & 0 deletions .changelog/5851.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`opentelemetry-api`, `opentelemetry-sdk`: accept forward-compatible `**kwargs` (`LoggerOptions`, `LoggerProviderOptions`) in `Logger.emit`, `Logger.enabled` and `LoggerProvider.get_logger`
4 changes: 4 additions & 0 deletions opentelemetry-api/src/opentelemetry/_logs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@

from opentelemetry._logs._internal import (
Logger,
LoggerOptions,
LoggerProvider,
LoggerProviderOptions,
LogRecord,
NoOpLogger,
NoOpLoggerProvider,
Expand All @@ -37,7 +39,9 @@
__all__ = [
"LogRecord",
"Logger",
"LoggerOptions",
"LoggerProvider",
"LoggerProviderOptions",
"NoOpLogger",
"NoOpLoggerProvider",
"SeverityNumber",
Expand Down
48 changes: 46 additions & 2 deletions opentelemetry-api/src/opentelemetry/_logs/_internal/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
from logging import getLogger
from os import environ
from time import time_ns
from typing import cast, overload
from typing import TYPE_CHECKING, TypedDict, cast, overload

from typing_extensions import deprecated

Expand All @@ -42,9 +42,26 @@
from opentelemetry.util._providers import _load_provider
from opentelemetry.util.types import AnyValue, Attributes

if TYPE_CHECKING:
from typing_extensions import Unpack

_logger = getLogger(__name__)


class LoggerProviderOptions(TypedDict):
"""Additional keyword-only arguments accepted by `LoggerProvider.get_logger`.

Reserved for forward compatibility, currently empty.
"""


class LoggerOptions(TypedDict):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Would we leave this empty and add new parameters to the actual signatures, or would we actually add new params here?

@herin049 herin049 Oct 29, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I guess there's a few options here:

  • Use **kwargs: Never as you suggested above, and just add additional keyword arguments to the API/SDK signatures directly. This should be safe since if an implementation has **kwargs: Never, it must always accept any keyword arguments (at runtime), even though it might ignore them.
  • Stick with **kwargs: Never initially, and then switch to **kwargs: Unpack[LoggerOptions] once we have additional options we want to add.

I actually didn't think about the first case until you brought it up, and I do somewhat like it better.

"""Additional keyword-only arguments accepted by `Logger.emit` and `Logger.enabled`.

Reserved for forward compatibility, currently empty.
"""


class LogRecord(ABC):
"""A LogRecord instance represents an event being logged.

Expand Down Expand Up @@ -165,12 +182,14 @@ def emit(
attributes: Attributes = None,
event_name: str | None = None,
exception: BaseException | None = None,
**kwargs: Unpack[LoggerOptions],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

WDYT of using **kwargs: Never? Based on some (AI) testing I did, mypy and pyright don't behave consistently; mypy doesn't force implementations to have a catch all **kwargs in their signature if the TypedDict is empty (like we have here).

Table from AI testing

Base-class annotation (2) typos at call site are caught (3) override that drops **kwargs Notes
**kwargs: Any ❌ pyright, ❌ mypy ✅ both What OTel metrics does today (implicitly). Forces implementers, but callers can pass anything.
**kwargs: Never ✅ both ("int not assignable to Never") ✅ both Single method, no runtime cost, works in both checkers. Slightly unusual-looking
**kwargs: Unpack[EmptyTypedDict] ✅ both ✅ pyright, ❌ mypy mypy treats an empty TypedDict unpack as "no extra params" and accepts an implementation declared with **kwargs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Sure, let me give it a go. May also update the advisory attributes PR with this convention as well.

) -> None: ...

@overload
def emit(
self,
record: LogRecord,
**kwargs: Unpack[LoggerOptions],
) -> None: ...

@abstractmethod
Expand All @@ -187,6 +206,7 @@ def emit(
attributes: Attributes = None,
event_name: str | None = None,
exception: BaseException | None = None,
**kwargs: Unpack[LoggerOptions],
) -> None:
"""Emits a :class:`LogRecord` representing a log to the processing pipeline."""

Expand All @@ -197,6 +217,7 @@ def enabled(
context: Context | None = None,
severity_number: SeverityNumber | None = None,
event_name: str | None = None,
**kwargs: Unpack[LoggerOptions],
) -> bool:
"""Returns whether the logger is enabled for the given arguments.

Expand Down Expand Up @@ -227,12 +248,14 @@ def emit(
attributes: Attributes = None,
event_name: str | None = None,
exception: BaseException | None = None,
**kwargs: Unpack[LoggerOptions],
) -> None: ...

@overload
def emit( # pylint:disable=arguments-differ
self,
record: LogRecord,
**kwargs: Unpack[LoggerOptions],
) -> None: ...

def emit(
Expand All @@ -248,6 +271,7 @@ def emit(
attributes: Attributes = None,
event_name: str | None = None,
exception: BaseException | None = None,
**kwargs: Unpack[LoggerOptions],
) -> None:
pass

Expand All @@ -257,6 +281,7 @@ def enabled(
context: Context | None = None,
severity_number: SeverityNumber | None = None,
event_name: str | None = None,
**kwargs: Unpack[LoggerOptions],
) -> bool:
return False

Expand All @@ -268,11 +293,13 @@ def __init__( # pylint: disable=super-init-not-called
version: str | None = None,
schema_url: str | None = None,
attributes: Attributes = None,
**kwargs: Unpack[LoggerProviderOptions],
):
self._name = name
self._version = version
self._schema_url = schema_url
self._attributes = attributes
self._get_logger_kwargs = kwargs
self._real_logger: Logger | None = None
self._noop_logger = NoOpLogger(name)

Expand All @@ -287,6 +314,7 @@ def _logger(self) -> Logger:
self._version,
self._schema_url,
self._attributes,
**self._get_logger_kwargs,
)
return self._real_logger
return self._noop_logger
Expand All @@ -304,12 +332,14 @@ def emit(
attributes: Attributes = None,
event_name: str | None = None,
exception: BaseException | None = None,
**kwargs: Unpack[LoggerOptions],
) -> None: ...

@overload
def emit( # pylint:disable=arguments-differ
self,
record: LogRecord,
**kwargs: Unpack[LoggerOptions],
) -> None: ...

def emit(
Expand All @@ -325,9 +355,10 @@ def emit(
attributes: Attributes = None,
event_name: str | None = None,
exception: BaseException | None = None,
**kwargs: Unpack[LoggerOptions],
) -> None:
if record:
self._logger.emit(record)
self._logger.emit(record, **kwargs)
else:
self._logger.emit(
timestamp=timestamp,
Expand All @@ -339,6 +370,7 @@ def emit(
attributes=attributes,
event_name=event_name,
exception=exception,
**kwargs,
)

def enabled(
Expand All @@ -347,11 +379,13 @@ def enabled(
context: Context | None = None,
severity_number: SeverityNumber | None = None,
event_name: str | None = None,
**kwargs: Unpack[LoggerOptions],
) -> bool:
return self._logger.enabled(
context=context,
severity_number=severity_number,
event_name=event_name,
**kwargs,
)


Expand All @@ -367,6 +401,7 @@ def get_logger(
version: str | None = None,
schema_url: str | None = None,
attributes: Attributes = None,
**kwargs: Unpack[LoggerProviderOptions],
) -> Logger:
"""Returns a `Logger` for use by the given instrumentation library.

Expand Down Expand Up @@ -394,6 +429,9 @@ def get_logger(

attributes: Optional. Specifies the instrumentation scope attributes to
associate with emitted telemetry.

kwargs: Additional keyword-only arguments, see `LoggerProviderOptions`.
Reserved for forward compatibility.
"""


Expand All @@ -406,6 +444,7 @@ def get_logger(
version: str | None = None,
schema_url: str | None = None,
attributes: Attributes = None,
**kwargs: Unpack[LoggerProviderOptions],
) -> Logger:
"""Returns a NoOpLogger."""
return NoOpLogger(name, version=version, schema_url=schema_url, attributes=attributes)
Expand All @@ -418,19 +457,22 @@ def get_logger(
version: str | None = None,
schema_url: str | None = None,
attributes: Attributes = None,
**kwargs: Unpack[LoggerProviderOptions],
) -> Logger:
if _LOGGER_PROVIDER:
return _LOGGER_PROVIDER.get_logger(
name,
version=version,
schema_url=schema_url,
attributes=attributes,
**kwargs,
)
return ProxyLogger(
name,
version=version,
schema_url=schema_url,
attributes=attributes,
**kwargs,
)


Expand Down Expand Up @@ -481,6 +523,7 @@ def get_logger(
logger_provider: LoggerProvider | None = None,
schema_url: str | None = None,
attributes: Attributes = None,
**kwargs: Unpack[LoggerProviderOptions],
) -> Logger:
"""Returns a `Logger` for use within a python process.

Expand All @@ -496,4 +539,5 @@ def get_logger(
instrumenting_library_version,
schema_url,
attributes,
**kwargs,
)
78 changes: 77 additions & 1 deletion opentelemetry-api/tests/logs/test_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,19 @@

# pylint: disable=W0212,W0222,W0221
import unittest
from typing import cast
from unittest.mock import Mock

from typing_extensions import Unpack

import opentelemetry._logs._internal as _logs_internal
from opentelemetry import _logs
from opentelemetry.test.globals_test import LoggingGlobalsTest
from opentelemetry.util.types import Attributes

_FUTURE_LOGGER_PROVIDER_OPTIONS = cast(_logs.LoggerProviderOptions, {"future_option": True})
_FUTURE_LOGGER_OPTIONS = cast(_logs.LoggerOptions, {"future_option": True})


class TestProvider(_logs.NoOpLoggerProvider):
def get_logger(
Expand All @@ -18,12 +24,13 @@ def get_logger(
version: str | None = None,
schema_url: str | None = None,
attributes: Attributes = None,
**kwargs: Unpack[_logs.LoggerProviderOptions],
) -> _logs.Logger:
return LoggerTest(name)


class LoggerTest(_logs.NoOpLogger):
def emit(
def emit( # pyright: ignore[reportIncompatibleMethodOverride]
self,
record: _logs.LogRecord | None = None,
*,
Expand All @@ -36,6 +43,7 @@ def emit(
attributes=None,
event_name=None,
exception: BaseException | None = None,
**kwargs: Unpack[_logs.LoggerOptions],
) -> None:
pass

Expand Down Expand Up @@ -96,3 +104,71 @@ def test_noop_logger_enabled_returns_false(self):
logger = _logs.NoOpLogger("noop-test")
self.assertFalse(logger.enabled())
self.assertFalse(logger.enabled(severity_number=_logs.SeverityNumber.ERROR, event_name="e"))

def test_noop_logger_accepts_unknown_kwargs(self):
logger = _logs.NoOpLoggerProvider().get_logger("noop-test", **_FUTURE_LOGGER_PROVIDER_OPTIONS)
logger.emit(body="body", **_FUTURE_LOGGER_OPTIONS)
logger.emit(_logs.LogRecord(), **_FUTURE_LOGGER_OPTIONS)
self.assertFalse(logger.enabled(**_FUTURE_LOGGER_OPTIONS))

def test_proxy_logger_provider_forwards_kwargs(self):
proxy_provider = _logs.get_logger_provider()
self.assertIsInstance(proxy_provider, _logs_internal.ProxyLoggerProvider)
proxy_logger = proxy_provider.get_logger("proxy-test", **_FUTURE_LOGGER_PROVIDER_OPTIONS)
self.assertIsInstance(proxy_logger, _logs_internal.ProxyLogger)

real_provider = Mock(spec=TestProvider())
_logs.set_logger_provider(real_provider)

# lazily resolved proxy logger passes the stored kwargs along
proxy_logger.enabled()
real_provider.get_logger.assert_called_once_with("proxy-test", None, None, None, future_option=True)

real_provider.get_logger.reset_mock()
proxy_provider.get_logger("fresh", **_FUTURE_LOGGER_PROVIDER_OPTIONS)
real_provider.get_logger.assert_called_once_with(
"fresh",
version=None,
schema_url=None,
attributes=None,
future_option=True,
)

def test_get_logger_forwards_kwargs(self):
provider = Mock(spec=TestProvider())
logger = _logs.get_logger("test", logger_provider=provider, **_FUTURE_LOGGER_PROVIDER_OPTIONS)
self.assertIs(logger, provider.get_logger.return_value)
provider.get_logger.assert_called_once_with("test", "", None, None, future_option=True)

def test_proxy_logger_forwards_emit_and_enabled_kwargs(self):
logger = _logs_internal.ProxyLogger("proxy-test")
real_logger = Mock(spec=LoggerTest("proxy-test"))
logger._real_logger = real_logger
self.assertIs(logger._logger, real_logger)
record = _logs.LogRecord()

logger.emit(record, **_FUTURE_LOGGER_OPTIONS)
real_logger.emit.assert_called_once_with(record, future_option=True)

real_logger.emit.reset_mock()
logger.emit(body="body", **_FUTURE_LOGGER_OPTIONS)
real_logger.emit.assert_called_once_with(
timestamp=None,
observed_timestamp=None,
context=None,
severity_number=None,
severity_text=None,
body="body",
attributes=None,
event_name=None,
exception=None,
future_option=True,
)

logger.enabled(**_FUTURE_LOGGER_OPTIONS)
real_logger.enabled.assert_called_once_with(
context=None,
severity_number=None,
event_name=None,
future_option=True,
)
Loading
Loading