Skip to content

Commit f58f126

Browse files
committed
opentelemetry-instrumentation-logging: move the sdk logging handler here
And hook it up via entry point
1 parent 09137c7 commit f58f126

2 files changed

Lines changed: 258 additions & 0 deletions

File tree

instrumentation/opentelemetry-instrumentation-logging/pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ instruments = []
3535
[project.entry-points.opentelemetry_instrumentor]
3636
logging = "opentelemetry.instrumentation.logging:LoggingInstrumentor"
3737

38+
[project.entry-points.opentelemetry_logging_handlers]
39+
logging = "opentelemetry.instrumentation.logging.handler:_setup_logging_handler"
40+
3841
[project.urls]
3942
Homepage = "https://github.com/open-telemetry/opentelemetry-python-contrib/tree/main/instrumentation/opentelemetry-instrumentation-logging"
4043
Repository = "https://github.com/open-telemetry/opentelemetry-python-contrib"
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
import logging
2+
import threading
3+
import traceback
4+
from time import time_ns
5+
from typing import Callable
6+
7+
from opentelemetry._logs import (
8+
LoggerProvider,
9+
LogRecord,
10+
NoOpLogger,
11+
SeverityNumber,
12+
get_logger,
13+
get_logger_provider,
14+
)
15+
from opentelemetry.attributes import _VALID_ANY_VALUE_TYPES
16+
from opentelemetry.context import get_current
17+
from opentelemetry.semconv._incubating.attributes import code_attributes
18+
from opentelemetry.semconv.attributes import exception_attributes
19+
from opentelemetry.util.types import _ExtendedAttributes
20+
21+
22+
def _setup_logging_handler(logger_provider: LoggerProvider):
23+
handler = LoggingHandler(
24+
level=logging.NOTSET, logger_provider=logger_provider
25+
)
26+
logging.getLogger().addHandler(handler)
27+
_overwrite_logging_config_fns(handler)
28+
29+
30+
def _overwrite_logging_config_fns(handler: "LoggingHandler") -> None:
31+
root = logging.getLogger()
32+
33+
def wrapper(config_fn: Callable) -> Callable:
34+
def overwritten_config_fn(*args, **kwargs):
35+
removed_handler = False
36+
# We don't want the OTLP handler to be modified or deleted by the logging config functions.
37+
# So we remove it and then add it back after the function call.
38+
if handler in root.handlers:
39+
removed_handler = True
40+
root.handlers.remove(handler)
41+
try:
42+
config_fn(*args, **kwargs)
43+
finally:
44+
# Ensure handler is added back if logging function throws exception.
45+
if removed_handler:
46+
root.addHandler(handler)
47+
48+
return overwritten_config_fn
49+
50+
logging.config.fileConfig = wrapper(logging.config.fileConfig)
51+
logging.config.dictConfig = wrapper(logging.config.dictConfig)
52+
logging.basicConfig = wrapper(logging.basicConfig)
53+
54+
55+
# skip natural LogRecord attributes
56+
# http://docs.python.org/library/logging.html#logrecord-attributes
57+
_RESERVED_ATTRS = frozenset(
58+
(
59+
"asctime",
60+
"args",
61+
"created",
62+
"exc_info",
63+
"exc_text",
64+
"filename",
65+
"funcName",
66+
"getMessage",
67+
"message",
68+
"levelname",
69+
"levelno",
70+
"lineno",
71+
"module",
72+
"msecs",
73+
"msg",
74+
"name",
75+
"pathname",
76+
"process",
77+
"processName",
78+
"relativeCreated",
79+
"stack_info",
80+
"thread",
81+
"threadName",
82+
"taskName",
83+
)
84+
)
85+
86+
87+
class LoggingHandler(logging.Handler):
88+
"""A handler class which writes logging records, in OTLP format, to
89+
a network destination or file. Supports signals from the `logging` module.
90+
https://docs.python.org/3/library/logging.html
91+
"""
92+
93+
def __init__(
94+
self,
95+
level: int = logging.NOTSET,
96+
logger_provider: LoggerProvider | None = None,
97+
) -> None:
98+
super().__init__(level=level)
99+
self._logger_provider = logger_provider or get_logger_provider()
100+
101+
@staticmethod
102+
def _get_attributes(record: logging.LogRecord) -> _ExtendedAttributes:
103+
attributes = {
104+
k: v for k, v in vars(record).items() if k not in _RESERVED_ATTRS
105+
}
106+
107+
# FIXME: good time to disable these by default
108+
if False:
109+
# Add standard code attributes for logs.
110+
attributes[code_attributes.CODE_FILE_PATH] = record.pathname
111+
attributes[code_attributes.CODE_FUNCTION_NAME] = record.funcName
112+
attributes[code_attributes.CODE_LINE_NUMBER] = record.lineno
113+
114+
if record.exc_info:
115+
exctype, value, tb = record.exc_info
116+
if exctype is not None:
117+
attributes[exception_attributes.EXCEPTION_TYPE] = (
118+
exctype.__name__
119+
)
120+
if value is not None and value.args:
121+
attributes[exception_attributes.EXCEPTION_MESSAGE] = str(
122+
value.args[0]
123+
)
124+
if tb is not None:
125+
# https://opentelemetry.io/docs/specs/semconv/exceptions/exceptions-spans/#stacktrace-representation
126+
attributes[exception_attributes.EXCEPTION_STACKTRACE] = (
127+
"".join(traceback.format_exception(*record.exc_info))
128+
)
129+
return attributes
130+
131+
def _translate(self, record: logging.LogRecord) -> LogRecord:
132+
timestamp = int(record.created * 1e9)
133+
observered_timestamp = time_ns()
134+
attributes = self._get_attributes(record)
135+
severity_number = std_to_otel(record.levelno)
136+
if self.formatter:
137+
body = self.format(record)
138+
else:
139+
# `record.getMessage()` uses `record.msg` as a template to format
140+
# `record.args` into. There is a special case in `record.getMessage()`
141+
# where it will only attempt formatting if args are provided,
142+
# otherwise, it just stringifies `record.msg`.
143+
#
144+
# Since the OTLP body field has a type of 'any' and the logging module
145+
# is sometimes used in such a way that objects incorrectly end up
146+
# set as record.msg, in those cases we would like to bypass
147+
# `record.getMessage()` completely and set the body to the object
148+
# itself instead of its string representation.
149+
# For more background, see: https://github.com/open-telemetry/opentelemetry-python/pull/4216
150+
if not record.args and not isinstance(record.msg, str):
151+
# if record.msg is not a value we can export, cast it to string
152+
if not isinstance(record.msg, _VALID_ANY_VALUE_TYPES):
153+
body = str(record.msg)
154+
else:
155+
body = record.msg
156+
else:
157+
body = record.getMessage()
158+
159+
# related to https://github.com/open-telemetry/opentelemetry-python/issues/3548
160+
# Severity Text = WARN as defined in https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/logs/data-model.md#displaying-severity.
161+
level_name = (
162+
"WARN" if record.levelname == "WARNING" else record.levelname
163+
)
164+
165+
return LogRecord(
166+
timestamp=timestamp,
167+
observed_timestamp=observered_timestamp,
168+
context=get_current() or None,
169+
severity_text=level_name,
170+
severity_number=severity_number,
171+
body=body,
172+
attributes=attributes,
173+
)
174+
175+
def emit(self, record: logging.LogRecord) -> None:
176+
"""
177+
Emit a record. Skip emitting if logger is NoOp.
178+
179+
The record is translated to OTel format, and then sent across the pipeline.
180+
"""
181+
logger = get_logger(record.name, logger_provider=self._logger_provider)
182+
if not isinstance(logger, NoOpLogger):
183+
logger.emit(self._translate(record))
184+
185+
def flush(self) -> None:
186+
"""
187+
Flushes the logging output. Skip flushing if logging_provider has no force_flush method.
188+
"""
189+
if hasattr(self._logger_provider, "force_flush") and callable(
190+
self._logger_provider.force_flush # type: ignore[reportAttributeAccessIssue]
191+
):
192+
# This is done in a separate thread to avoid a potential deadlock, for
193+
# details see https://github.com/open-telemetry/opentelemetry-python/pull/4636.
194+
thread = threading.Thread(target=self._logger_provider.force_flush) # type: ignore[reportAttributeAccessIssue]
195+
thread.start()
196+
197+
198+
_STD_TO_OTEL = {
199+
10: SeverityNumber.DEBUG,
200+
11: SeverityNumber.DEBUG2,
201+
12: SeverityNumber.DEBUG3,
202+
13: SeverityNumber.DEBUG4,
203+
14: SeverityNumber.DEBUG4,
204+
15: SeverityNumber.DEBUG4,
205+
16: SeverityNumber.DEBUG4,
206+
17: SeverityNumber.DEBUG4,
207+
18: SeverityNumber.DEBUG4,
208+
19: SeverityNumber.DEBUG4,
209+
20: SeverityNumber.INFO,
210+
21: SeverityNumber.INFO2,
211+
22: SeverityNumber.INFO3,
212+
23: SeverityNumber.INFO4,
213+
24: SeverityNumber.INFO4,
214+
25: SeverityNumber.INFO4,
215+
26: SeverityNumber.INFO4,
216+
27: SeverityNumber.INFO4,
217+
28: SeverityNumber.INFO4,
218+
29: SeverityNumber.INFO4,
219+
30: SeverityNumber.WARN,
220+
31: SeverityNumber.WARN2,
221+
32: SeverityNumber.WARN3,
222+
33: SeverityNumber.WARN4,
223+
34: SeverityNumber.WARN4,
224+
35: SeverityNumber.WARN4,
225+
36: SeverityNumber.WARN4,
226+
37: SeverityNumber.WARN4,
227+
38: SeverityNumber.WARN4,
228+
39: SeverityNumber.WARN4,
229+
40: SeverityNumber.ERROR,
230+
41: SeverityNumber.ERROR2,
231+
42: SeverityNumber.ERROR3,
232+
43: SeverityNumber.ERROR4,
233+
44: SeverityNumber.ERROR4,
234+
45: SeverityNumber.ERROR4,
235+
46: SeverityNumber.ERROR4,
236+
47: SeverityNumber.ERROR4,
237+
48: SeverityNumber.ERROR4,
238+
49: SeverityNumber.ERROR4,
239+
50: SeverityNumber.FATAL,
240+
51: SeverityNumber.FATAL2,
241+
52: SeverityNumber.FATAL3,
242+
53: SeverityNumber.FATAL4,
243+
}
244+
245+
246+
def std_to_otel(levelno: int) -> SeverityNumber:
247+
"""
248+
Map python log levelno as defined in https://docs.python.org/3/library/logging.html#logging-levels
249+
to OTel log severity number as defined here: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/logs/data-model.md#field-severitynumber
250+
"""
251+
if levelno < 10:
252+
return SeverityNumber.UNSPECIFIED
253+
if levelno > 53:
254+
return SeverityNumber.FATAL4
255+
return _STD_TO_OTEL[levelno]

0 commit comments

Comments
 (0)