From b380e53335c8777e05ff35d241c24a5e63c44d43 Mon Sep 17 00:00:00 2001 From: soumyadeepm04 <84105194+soumyadeepm04@users.noreply.github.com> Date: Wed, 21 Aug 2024 13:29:17 -0400 Subject: [PATCH] Events API implementation (#4054) --- CHANGELOG.md | 2 + .../src/opentelemetry/_events/__init__.py | 229 ++++++++++++++++++ .../environment_variables/__init__.py | 5 + opentelemetry-api/tests/events/test_event.py | 13 + .../events/test_event_logger_provider.py | 47 ++++ .../tests/events/test_proxy_event.py | 50 ++++ .../src/opentelemetry/test/globals_test.py | 24 ++ 7 files changed, 370 insertions(+) create mode 100644 opentelemetry-api/src/opentelemetry/_events/__init__.py create mode 100644 opentelemetry-api/tests/events/test_event.py create mode 100644 opentelemetry-api/tests/events/test_event_logger_provider.py create mode 100644 opentelemetry-api/tests/events/test_proxy_event.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4edac969001..c80a2a37140 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- Implementation of Events API + ([#4054](https://github.com/open-telemetry/opentelemetry-python/pull/4054)) - Make log sdk add `exception.message` to logRecord for exceptions whose argument is an exception not a string message ([#4122](https://github.com/open-telemetry/opentelemetry-python/pull/4122)) diff --git a/opentelemetry-api/src/opentelemetry/_events/__init__.py b/opentelemetry-api/src/opentelemetry/_events/__init__.py new file mode 100644 index 00000000000..8284f0830db --- /dev/null +++ b/opentelemetry-api/src/opentelemetry/_events/__init__.py @@ -0,0 +1,229 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from abc import ABC, abstractmethod +from logging import getLogger +from os import environ +from typing import Any, Optional, cast + +from opentelemetry._logs import LogRecord +from opentelemetry._logs.severity import SeverityNumber +from opentelemetry.environment_variables import ( + _OTEL_PYTHON_EVENT_LOGGER_PROVIDER, +) +from opentelemetry.trace.span import TraceFlags +from opentelemetry.util._once import Once +from opentelemetry.util._providers import _load_provider +from opentelemetry.util.types import Attributes + +_logger = getLogger(__name__) + + +class Event(LogRecord): + + def __init__( + self, + name: str, + timestamp: Optional[int] = None, + trace_id: Optional[int] = None, + span_id: Optional[int] = None, + trace_flags: Optional["TraceFlags"] = None, + body: Optional[Any] = None, + severity_number: Optional[SeverityNumber] = None, + attributes: Optional[Attributes] = None, + ): + attributes = attributes or {} + event_attributes = {**attributes, "event.name": name} + super().__init__( + timestamp=timestamp, + trace_id=trace_id, + span_id=span_id, + trace_flags=trace_flags, + body=body, # type: ignore + severity_number=severity_number, + attributes=event_attributes, + ) + self.name = name + + +class EventLogger(ABC): + + def __init__( + self, + name: str, + version: Optional[str] = None, + schema_url: Optional[str] = None, + attributes: Optional[Attributes] = None, + ): + self._name = name + self._version = version + self._schema_url = schema_url + self._attributes = attributes + + @abstractmethod + def emit(self, event: "Event") -> None: + """Emits a :class:`Event` representing an event.""" + + +class NoOpEventLogger(EventLogger): + + def emit(self, event: Event) -> None: + pass + + +class ProxyEventLogger(EventLogger): + def __init__( + self, + name: str, + version: Optional[str] = None, + schema_url: Optional[str] = None, + attributes: Optional[Attributes] = None, + ): + super().__init__( + name=name, + version=version, + schema_url=schema_url, + attributes=attributes, + ) + self._real_event_logger: Optional[EventLogger] = None + self._noop_event_logger = NoOpEventLogger(name) + + @property + def _event_logger(self) -> EventLogger: + if self._real_event_logger: + return self._real_event_logger + + if _EVENT_LOGGER_PROVIDER: + self._real_event_logger = _EVENT_LOGGER_PROVIDER.get_event_logger( + self._name, + self._version, + self._schema_url, + self._attributes, + ) + return self._real_event_logger + return self._noop_event_logger + + def emit(self, event: Event) -> None: + self._event_logger.emit(event) + + +class EventLoggerProvider(ABC): + + @abstractmethod + def get_event_logger( + self, + name: str, + version: Optional[str] = None, + schema_url: Optional[str] = None, + attributes: Optional[Attributes] = None, + ) -> EventLogger: + """Returns an EventLoggerProvider for use.""" + + +class NoOpEventLoggerProvider(EventLoggerProvider): + + def get_event_logger( + self, + name: str, + version: Optional[str] = None, + schema_url: Optional[str] = None, + attributes: Optional[Attributes] = None, + ) -> EventLogger: + return NoOpEventLogger( + name, version=version, schema_url=schema_url, attributes=attributes + ) + + +class ProxyEventLoggerProvider(EventLoggerProvider): + + def get_event_logger( + self, + name: str, + version: Optional[str] = None, + schema_url: Optional[str] = None, + attributes: Optional[Attributes] = None, + ) -> EventLogger: + if _EVENT_LOGGER_PROVIDER: + return _EVENT_LOGGER_PROVIDER.get_event_logger( + name, + version=version, + schema_url=schema_url, + attributes=attributes, + ) + return ProxyEventLogger( + name, + version=version, + schema_url=schema_url, + attributes=attributes, + ) + + +_EVENT_LOGGER_PROVIDER_SET_ONCE = Once() +_EVENT_LOGGER_PROVIDER: Optional[EventLoggerProvider] = None +_PROXY_EVENT_LOGGER_PROVIDER = ProxyEventLoggerProvider() + + +def get_event_logger_provider() -> EventLoggerProvider: + + global _EVENT_LOGGER_PROVIDER # pylint: disable=global-variable-not-assigned + if _EVENT_LOGGER_PROVIDER is None: + if _OTEL_PYTHON_EVENT_LOGGER_PROVIDER not in environ: + return _PROXY_EVENT_LOGGER_PROVIDER + + event_logger_provider: EventLoggerProvider = _load_provider( # type: ignore + _OTEL_PYTHON_EVENT_LOGGER_PROVIDER, "event_logger_provider" + ) + + _set_event_logger_provider(event_logger_provider, log=False) + + return cast("EventLoggerProvider", _EVENT_LOGGER_PROVIDER) + + +def _set_event_logger_provider( + event_logger_provider: EventLoggerProvider, log: bool +) -> None: + def set_elp() -> None: + global _EVENT_LOGGER_PROVIDER # pylint: disable=global-statement + _EVENT_LOGGER_PROVIDER = event_logger_provider + + did_set = _EVENT_LOGGER_PROVIDER_SET_ONCE.do_once(set_elp) + + if log and did_set: + _logger.warning( + "Overriding of current EventLoggerProvider is not allowed" + ) + + +def set_event_logger_provider( + event_logger_provider: EventLoggerProvider, +) -> None: + + _set_event_logger_provider(event_logger_provider, log=True) + + +def get_event_logger( + name: str, + version: Optional[str] = None, + schema_url: Optional[str] = None, + attributes: Optional[Attributes] = None, + event_logger_provider: Optional[EventLoggerProvider] = None, +) -> "EventLogger": + if event_logger_provider is None: + event_logger_provider = get_event_logger_provider() + return event_logger_provider.get_event_logger( + name, + version, + schema_url, + attributes, + ) diff --git a/opentelemetry-api/src/opentelemetry/environment_variables/__init__.py b/opentelemetry-api/src/opentelemetry/environment_variables/__init__.py index c15b96be14a..bd8ed1cbfbb 100644 --- a/opentelemetry-api/src/opentelemetry/environment_variables/__init__.py +++ b/opentelemetry-api/src/opentelemetry/environment_variables/__init__.py @@ -81,3 +81,8 @@ """ .. envvar:: OTEL_PYTHON_LOGGER_PROVIDER """ + +_OTEL_PYTHON_EVENT_LOGGER_PROVIDER = "OTEL_PYTHON_EVENT_LOGGER_PROVIDER" +""" +.. envvar:: OTEL_PYTHON_EVENT_LOGGER_PROVIDER +""" diff --git a/opentelemetry-api/tests/events/test_event.py b/opentelemetry-api/tests/events/test_event.py new file mode 100644 index 00000000000..b741cc65e23 --- /dev/null +++ b/opentelemetry-api/tests/events/test_event.py @@ -0,0 +1,13 @@ +import unittest + +from opentelemetry._events import Event + + +class TestEvent(unittest.TestCase): + def test_event(self): + event = Event("example", 123, attributes={"key": "value"}) + self.assertEqual(event.name, "example") + self.assertEqual(event.timestamp, 123) + self.assertEqual( + event.attributes, {"key": "value", "event.name": "example"} + ) diff --git a/opentelemetry-api/tests/events/test_event_logger_provider.py b/opentelemetry-api/tests/events/test_event_logger_provider.py new file mode 100644 index 00000000000..35a8ea0c44e --- /dev/null +++ b/opentelemetry-api/tests/events/test_event_logger_provider.py @@ -0,0 +1,47 @@ +# type:ignore +import unittest +from unittest.mock import Mock, patch + +import opentelemetry._events as events +from opentelemetry._events import ( + get_event_logger_provider, + set_event_logger_provider, +) +from opentelemetry.test.globals_test import EventsGlobalsTest + + +class TestGlobals(EventsGlobalsTest, unittest.TestCase): + def test_set_event_logger_provider(self): + elp_mock = Mock() + # pylint: disable=protected-access + self.assertIsNone(events._EVENT_LOGGER_PROVIDER) + set_event_logger_provider(elp_mock) + self.assertIs(events._EVENT_LOGGER_PROVIDER, elp_mock) + self.assertIs(get_event_logger_provider(), elp_mock) + + def test_get_event_logger_provider(self): + # pylint: disable=protected-access + self.assertIsNone(events._EVENT_LOGGER_PROVIDER) + + self.assertIsInstance( + get_event_logger_provider(), events.ProxyEventLoggerProvider + ) + + events._EVENT_LOGGER_PROVIDER = None + + with patch.dict( + "os.environ", + { + "OTEL_PYTHON_EVENT_LOGGER_PROVIDER": "test_event_logger_provider" + }, + ): + + with patch("opentelemetry._events._load_provider", Mock()): + with patch( + "opentelemetry._events.cast", + Mock(**{"return_value": "test_event_logger_provider"}), + ): + self.assertEqual( + get_event_logger_provider(), + "test_event_logger_provider", + ) diff --git a/opentelemetry-api/tests/events/test_proxy_event.py b/opentelemetry-api/tests/events/test_proxy_event.py new file mode 100644 index 00000000000..736dcf35d60 --- /dev/null +++ b/opentelemetry-api/tests/events/test_proxy_event.py @@ -0,0 +1,50 @@ +# pylint: disable=W0212,W0222,W0221 +import typing +import unittest + +import opentelemetry._events as events +from opentelemetry.test.globals_test import EventsGlobalsTest +from opentelemetry.util.types import Attributes + + +class TestProvider(events.NoOpEventLoggerProvider): + def get_event_logger( + self, + name: str, + version: typing.Optional[str] = None, + schema_url: typing.Optional[str] = None, + attributes: typing.Optional[Attributes] = None, + ) -> events.EventLogger: + return LoggerTest(name) + + +class LoggerTest(events.NoOpEventLogger): + def emit(self, event: events.Event) -> None: + pass + + +class TestProxy(EventsGlobalsTest, unittest.TestCase): + def test_proxy_logger(self): + provider = events.get_event_logger_provider() + # proxy provider + self.assertIsInstance(provider, events.ProxyEventLoggerProvider) + + # provider returns proxy logger + event_logger = provider.get_event_logger("proxy-test") + self.assertIsInstance(event_logger, events.ProxyEventLogger) + + # set a real provider + events.set_event_logger_provider(TestProvider()) + + # get_logger_provider() now returns the real provider + self.assertIsInstance(events.get_event_logger_provider(), TestProvider) + + # logger provider now returns real instance + self.assertIsInstance( + events.get_event_logger_provider().get_event_logger("fresh"), + LoggerTest, + ) + + # references to the old provider still work but return real logger now + real_logger = provider.get_event_logger("proxy-test") + self.assertIsInstance(real_logger, LoggerTest) diff --git a/tests/opentelemetry-test-utils/src/opentelemetry/test/globals_test.py b/tests/opentelemetry-test-utils/src/opentelemetry/test/globals_test.py index ed210f27973..fb85840634f 100644 --- a/tests/opentelemetry-test-utils/src/opentelemetry/test/globals_test.py +++ b/tests/opentelemetry-test-utils/src/opentelemetry/test/globals_test.py @@ -14,6 +14,7 @@ import unittest +from opentelemetry import _events as events_api from opentelemetry import trace as trace_api from opentelemetry._logs import _internal as logging_api from opentelemetry.metrics import _internal as metrics_api @@ -45,6 +46,14 @@ def reset_logging_globals() -> None: logging_api._PROXY_LOGGER_PROVIDER = logging_api.ProxyLoggerProvider() # type: ignore[attr-defined] +# pylint: disable=protected-access +def reset_event_globals() -> None: + """WARNING: only use this for tests.""" + events_api._EVENT_LOGGER_PROVIDER_SET_ONCE = Once() # type: ignore[attr-defined] + events_api._EVENT_LOGGER_PROVIDER = None # type: ignore[attr-defined] + events_api._PROXY_EVENT_LOGGER_PROVIDER = events_api.ProxyEventLoggerProvider() # type: ignore[attr-defined] + + class TraceGlobalsTest(unittest.TestCase): """Resets trace API globals in setUp/tearDown @@ -88,3 +97,18 @@ def setUp(self) -> None: def tearDown(self) -> None: super().tearDown() reset_logging_globals() + + +class EventsGlobalsTest(unittest.TestCase): + """Resets logging API globals in setUp/tearDown + + Use as a base class or mixin for your test that modifies logging API globals. + """ + + def setUp(self) -> None: + super().setUp() + reset_event_globals() + + def tearDown(self) -> None: + super().tearDown() + reset_event_globals()