Skip to content

Commit d3ac0a4

Browse files
comments
1 parent 4b05d86 commit d3ac0a4

3 files changed

Lines changed: 237 additions & 83 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,7 @@ No significant changes.
436436
([#3912](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3912))
437437

438438
### Added
439+
439440
- `opentelemetry-instrumentation-botocore`: Add support for AWS Secrets Manager semantic convention attribute
440441
([#3765](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3765))
441442
- `opentelemetry-instrumentation-dbapi`: Add support for `commenter_options` in `trace_integration` function to control SQLCommenter behavior

instrumentation/opentelemetry-instrumentation-structlog/src/opentelemetry/instrumentation/structlog/__init__.py

Lines changed: 87 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232

3333
import sys
3434
import traceback
35-
from datetime import datetime, timezone
35+
from datetime import datetime
3636
from time import time_ns
3737
from typing import Any, Callable, Collection, Optional
3838

@@ -41,6 +41,7 @@
4141
from opentelemetry._logs import (
4242
LogRecord,
4343
NoOpLogger,
44+
SeverityNumber,
4445
get_logger,
4546
get_logger_provider,
4647
)
@@ -91,29 +92,33 @@ def _parse_structlog_timestamp(value: Any) -> Optional[int]:
9192
9293
structlog's TimeStamper emits either a float (UNIX seconds, the default)
9394
or a string (ISO 8601 when fmt="iso", or a strftime pattern otherwise).
94-
We handle float and ISO 8601; anything else returns None so the SDK can
95-
fill in the observed time.
95+
We handle float and timezone-aware ISO 8601; anything else returns None so
96+
the SDK can fill in the observed time.
9697
"""
9798
if value is None:
9899
return None
99100
if isinstance(value, (int, float)):
100101
return int(value * 1e9)
101102
if isinstance(value, str):
103+
timestamp = value
104+
if timestamp.endswith("Z"):
105+
timestamp = timestamp[:-1] + "+00:00"
102106
try:
103-
dt = datetime.fromisoformat(value)
107+
dt = datetime.fromisoformat(timestamp)
104108
if dt.tzinfo is None:
105-
dt = dt.replace(tzinfo=timezone.utc)
109+
return None
106110
return int(dt.timestamp() * 1e9)
107111
except ValueError:
108112
return None
109113
return None
110114

111115

112-
class StructlogHandler:
116+
class StructlogProcessor:
113117
"""
114-
A structlog handler that translates structlog events into OpenTelemetry LogRecords.
118+
A structlog processor that translates structlog events into OpenTelemetry
119+
LogRecords.
115120
116-
This handler should be added to the structlog processor chain to emit logs
121+
This processor should be added to the structlog processor chain to emit logs
117122
to OpenTelemetry. It translates structlog's event dictionary format into the
118123
OpenTelemetry Logs data model.
119124
@@ -122,29 +127,32 @@ class StructlogHandler:
122127
"""
123128

124129
def __init__(self, logger_provider=None):
125-
"""Initialize the handler with an optional logger provider."""
130+
"""Initialize the processor with an optional logger provider."""
126131
self._logger_provider = logger_provider or get_logger_provider()
127132

128-
def __call__(self, logger, name: str, event_dict: dict) -> dict:
133+
def __call__(self, logger, method_name: str, event_dict: dict) -> dict:
129134
"""
130135
Process a structlog event and emit it as an OpenTelemetry log.
131136
132-
This method implements the structlog handler interface. It receives
137+
This method implements the structlog processor interface. It receives
133138
the event dictionary, translates it to an OTel LogRecord, and emits it.
134139
135140
Args:
136-
logger: The structlog logger instance (unused).
137-
name: The logger name.
141+
logger: The wrapped structlog logger.
142+
method_name: The logger method name.
138143
event_dict: The structlog event dictionary.
139144
140145
Returns:
141146
The unmodified event_dict (passthrough for other processors).
142147
"""
143-
otel_logger = get_logger(name, logger_provider=self._logger_provider)
148+
logger_name = getattr(logger, "name", __name__)
149+
otel_logger = get_logger(
150+
logger_name, logger_provider=self._logger_provider
151+
)
144152

145153
# Skip emission if we have a no-op logger
146154
if not isinstance(otel_logger, NoOpLogger):
147-
log_record = self._translate(event_dict)
155+
log_record = self._translate(event_dict, method_name)
148156
otel_logger.emit(log_record)
149157

150158
return event_dict
@@ -173,6 +181,8 @@ def _get_attributes(event_dict: dict) -> dict[str, Any]:
173181
# Handle exception information
174182
exc_info = event_dict.get("exc_info")
175183

184+
# Match True explicitly because exception tuples and exception instances
185+
# are also truthy and are handled separately below.
176186
if exc_info is True:
177187
# exc_info=True means "get current exception"
178188
exc_info = sys.exc_info()
@@ -206,7 +216,9 @@ def _get_attributes(event_dict: dict) -> dict[str, Any]:
206216

207217
return attributes
208218

209-
def _translate(self, event_dict: dict) -> LogRecord:
219+
def _translate(
220+
self, event_dict: dict, method_name: Optional[str] = None
221+
) -> LogRecord:
210222
"""
211223
Translate a structlog event dictionary into an OpenTelemetry LogRecord.
212224
@@ -223,17 +235,33 @@ def _translate(self, event_dict: dict) -> LogRecord:
223235
observed_timestamp = time_ns()
224236
timestamp = _parse_structlog_timestamp(event_dict.get("timestamp"))
225237

226-
# Get the log level and map to OTel severity
227-
level_str = event_dict.get("level", "info")
228-
levelno = _STRUCTLOG_LEVEL_TO_LEVELNO.get(level_str.lower(), 20)
229-
severity_number = std_to_otel(levelno)
230-
231-
# Normalize severity text to OTel canonical names where structlog
232-
# level names differ: "warning" -> "WARN", "critical"/"fatal" -> "FATAL"
233-
severity_text = _STRUCTLOG_TO_OTEL_SEVERITY_TEXT.get(
234-
level_str.lower(), level_str.upper()
238+
# Get the log level and map to OTel severity. structlog passes the
239+
# logger method name to processors, so use it as a fallback when no
240+
# prior processor added a level to the event dict.
241+
level_str = event_dict.get("level")
242+
if not isinstance(level_str, str) or not level_str:
243+
level_str = method_name
244+
245+
level_name = level_str.lower() if isinstance(level_str, str) else None
246+
levelno = (
247+
_STRUCTLOG_LEVEL_TO_LEVELNO.get(level_name)
248+
if level_name is not None
249+
else None
235250
)
236251

252+
if levelno is None:
253+
severity_number = SeverityNumber.UNSPECIFIED
254+
severity_text = (
255+
level_str.upper() if isinstance(level_str, str) else None
256+
)
257+
else:
258+
severity_number = std_to_otel(levelno)
259+
# Normalize severity text to OTel canonical names where structlog
260+
# level names differ: "warning" -> "WARN", "critical"/"fatal" -> "FATAL"
261+
severity_text = _STRUCTLOG_TO_OTEL_SEVERITY_TEXT.get(
262+
level_name, level_str.upper()
263+
)
264+
237265
# Get the message body
238266
body = event_dict.get("event")
239267

@@ -267,7 +295,7 @@ class StructlogInstrumentor(BaseInstrumentor):
267295
"""
268296
An instrumentor for the structlog logging library.
269297
270-
This instrumentor adds a StructlogHandler to the structlog processor
298+
This instrumentor adds a StructlogProcessor to the structlog processor
271299
chain, enabling automatic emission of structlog events as OpenTelemetry logs.
272300
273301
Example:
@@ -278,20 +306,20 @@ class StructlogInstrumentor(BaseInstrumentor):
278306
>>> logger.info("hello", user="alice")
279307
"""
280308

281-
_processor: Optional["StructlogHandler"] = None
282-
_original_configure: Optional[Callable] = None
309+
_processor: Optional["StructlogProcessor"] = None
310+
_original_configure: Callable[..., None] = structlog.configure
283311

284312
def instrumentation_dependencies(self) -> Collection[str]:
285313
"""Return the required instrumentation dependencies."""
286314
return _instruments
287315

288316
def _instrument(self, **kwargs):
289317
"""
290-
Add the StructlogHandler to structlog's processor chain.
318+
Add the StructlogProcessor to structlog's processor chain.
291319
292-
The handler is inserted before the last processor in the current chain.
320+
The processor is inserted before the last processor in the current chain.
293321
This assumes the last processor is a renderer (e.g. ConsoleRenderer,
294-
JSONRenderer). The handler must run before rendering so it receives the
322+
JSONRenderer). The processor must run before rendering so it receives the
295323
raw event dict rather than a formatted string.
296324
297325
If your chain does not end with a renderer, or has post-processing steps
@@ -300,7 +328,7 @@ def _instrument(self, **kwargs):
300328
301329
structlog.configure(processors=[
302330
structlog.stdlib.add_log_level,
303-
StructlogHandler(logger_provider=provider),
331+
StructlogProcessor(logger_provider=provider),
304332
structlog.dev.ConsoleRenderer(),
305333
])
306334
@@ -309,7 +337,7 @@ def _instrument(self, **kwargs):
309337
"""
310338
# Create the OTel processor
311339
logger_provider = kwargs.get("logger_provider")
312-
processor = StructlogHandler(logger_provider=logger_provider)
340+
processor = StructlogProcessor(logger_provider=logger_provider)
313341

314342
# Get current structlog configuration
315343
config = structlog.get_config()
@@ -330,54 +358,51 @@ def _instrument(self, **kwargs):
330358
StructlogInstrumentor._processor = processor
331359

332360
# Wrap structlog.configure so that if user code calls it after
333-
# instrumentation, the handler is re-inserted into the new chain.
361+
# instrumentation, the processor is re-inserted into the new chain.
334362
StructlogInstrumentor._original_configure = structlog.configure
335363

336-
def _patched_configure(**kwargs):
337-
# If the user is supplying a processors list, ensure our handler
364+
def ensure_processor(processors):
365+
processors = list(processors)
366+
if not any(isinstance(p, StructlogProcessor) for p in processors):
367+
insert_position = max(len(processors) - 1, 0)
368+
processors.insert(
369+
insert_position, StructlogInstrumentor._processor
370+
)
371+
return processors
372+
373+
def patched_configure(*args, **kwargs):
374+
# If the user is supplying a processors list, ensure our processor
338375
# is included before passing it to the original configure.
339-
if "processors" in kwargs:
340-
processors = list(kwargs["processors"])
341-
if not any(
342-
isinstance(p, StructlogHandler) for p in processors
343-
):
344-
insert_position = max(len(processors) - 1, 0)
345-
processors.insert(
346-
insert_position, StructlogInstrumentor._processor
347-
)
348-
kwargs["processors"] = processors
349-
original = StructlogInstrumentor._original_configure
350-
if original is not None:
351-
return original(**kwargs)
352-
return None
376+
if args and "processors" not in kwargs:
377+
processors = args[0]
378+
if processors is not None:
379+
args = (ensure_processor(processors), *args[1:])
380+
elif kwargs.get("processors") is not None:
381+
kwargs["processors"] = ensure_processor(kwargs["processors"])
382+
return StructlogInstrumentor._original_configure(*args, **kwargs)
353383

354-
structlog.configure = _patched_configure
384+
structlog.configure = patched_configure
355385

356386
def _uninstrument(self, **kwargs):
357387
"""
358-
Remove the StructlogHandler from structlog's processor chain.
388+
Remove the StructlogProcessor from structlog's processor chain.
359389
"""
360-
if StructlogInstrumentor._processor is None:
361-
return
362-
363390
# Get current structlog configuration
364391
config = structlog.get_config()
365392
current_processors = list(config.get("processors", []))
366393

367-
# Remove all StructlogHandler instances
394+
# Remove all StructlogProcessor instances
368395
new_processors = [
369396
p
370397
for p in current_processors
371-
if not isinstance(p, StructlogHandler)
398+
if not isinstance(p, StructlogProcessor)
372399
]
373400

374401
# Restore the original structlog.configure before reconfiguring so
375-
# the patched version does not re-insert the handler.
376-
if StructlogInstrumentor._original_configure is not None:
377-
structlog.configure = StructlogInstrumentor._original_configure
378-
StructlogInstrumentor._original_configure = None
402+
# the patched version does not re-insert the processor.
403+
structlog.configure = StructlogInstrumentor._original_configure
379404

380-
# Reconfigure structlog without the handler
405+
# Reconfigure structlog without the processor
381406
structlog.configure(processors=new_processors)
382407

383408
# Clear reference

0 commit comments

Comments
 (0)