3232
3333import sys
3434import traceback
35- from datetime import datetime , timezone
35+ from datetime import datetime
3636from time import time_ns
3737from typing import Any , Callable , Collection , Optional
3838
4141from 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