3131"""
3232
3333import sys
34- import threading
3534import traceback
35+ from datetime import datetime , timezone
3636from time import time_ns
3737from typing import Any , Callable , Collection , Optional
3838
7777 "fatal" : 50 ,
7878}
7979
80+ # Map structlog level names to OTel canonical severity text where they differ
81+ _STRUCTLOG_TO_OTEL_SEVERITY_TEXT = {
82+ "warning" : "WARN" ,
83+ "critical" : "FATAL" ,
84+ "fatal" : "FATAL" ,
85+ }
86+
87+
88+ def _parse_structlog_timestamp (value : Any ) -> Optional [int ]:
89+ """
90+ Convert a structlog timestamp value to nanoseconds since epoch, or None.
91+
92+ structlog's TimeStamper emits either a float (UNIX seconds, the default)
93+ 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.
96+ """
97+ if value is None :
98+ return None
99+ if isinstance (value , (int , float )):
100+ return int (value * 1e9 )
101+ if isinstance (value , str ):
102+ try :
103+ dt = datetime .fromisoformat (value )
104+ if dt .tzinfo is None :
105+ dt = dt .replace (tzinfo = timezone .utc )
106+ return int (dt .timestamp () * 1e9 )
107+ except ValueError :
108+ return None
109+ return None
110+
80111
81112class StructlogHandler :
82113 """
@@ -185,20 +216,23 @@ def _translate(self, event_dict: dict) -> LogRecord:
185216 Returns:
186217 An OpenTelemetry LogRecord.
187218 """
188- # Use current time for both timestamp and observed_timestamp
189- # structlog's timestamps are unreliable/varied depending on configuration
190- timestamp = observed_timestamp = time_ns ()
219+ # observed_timestamp is when the SDK received the event (always now).
220+ # timestamp is when the event occurred; use the structlog "timestamp"
221+ # field if present and parseable (UNIX float or ISO 8601 string),
222+ # otherwise leave as None and let the SDK fill it in.
223+ observed_timestamp = time_ns ()
224+ timestamp = _parse_structlog_timestamp (event_dict .get ("timestamp" ))
191225
192226 # Get the log level and map to OTel severity
193227 level_str = event_dict .get ("level" , "info" )
194228 levelno = _STRUCTLOG_LEVEL_TO_LEVELNO .get (level_str .lower (), 20 )
195229 severity_number = std_to_otel (levelno )
196230
197- # Normalize severity text: "warning" -> "WARN", otherwise uppercase
198- if level_str . lower () == "warning" :
199- severity_text = "WARN"
200- else :
201- severity_text = level_str . upper ( )
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 ()
235+ )
202236
203237 # Get the message body
204238 body = event_dict .get ("event" )
@@ -222,15 +256,11 @@ def _translate(self, event_dict: dict) -> LogRecord:
222256 def flush (self ) -> None :
223257 """
224258 Flush the logger provider.
225-
226- This method flushes any pending logs. It runs in a separate thread
227- to avoid potential deadlocks.
228259 """
229260 if hasattr (self ._logger_provider , "force_flush" ) and callable (
230261 self ._logger_provider .force_flush
231262 ):
232- thread = threading .Thread (target = self ._logger_provider .force_flush )
233- thread .start ()
263+ self ._logger_provider .force_flush ()
234264
235265
236266class StructlogInstrumentor (BaseInstrumentor ):
@@ -248,7 +278,7 @@ class StructlogInstrumentor(BaseInstrumentor):
248278 >>> logger.info("hello", user="alice")
249279 """
250280
251- _processor = None
281+ _processor : Optional [ "StructlogHandler" ] = None
252282 _original_configure : Optional [Callable ] = None
253283
254284 def instrumentation_dependencies (self ) -> Collection [str ]:
@@ -303,21 +333,10 @@ def _instrument(self, **kwargs):
303333 # instrumentation, the handler is re-inserted into the new chain.
304334 StructlogInstrumentor ._original_configure = structlog .configure
305335
306- def _patched_configure (* args , * *kwargs ):
336+ def _patched_configure (** kwargs ):
307337 # If the user is supplying a processors list, ensure our handler
308338 # is included before passing it to the original configure.
309- # processors may be passed as the first positional arg or as a kwarg.
310- if args :
311- processors = list (args [0 ])
312- if not any (
313- isinstance (p , StructlogHandler ) for p in processors
314- ):
315- insert_position = max (len (processors ) - 1 , 0 )
316- processors .insert (
317- insert_position , StructlogInstrumentor ._processor
318- )
319- args = (processors ,) + args [1 :]
320- elif "processors" in kwargs :
339+ if "processors" in kwargs :
321340 processors = list (kwargs ["processors" ])
322341 if not any (
323342 isinstance (p , StructlogHandler ) for p in processors
@@ -329,7 +348,7 @@ def _patched_configure(*args, **kwargs):
329348 kwargs ["processors" ] = processors
330349 original = StructlogInstrumentor ._original_configure
331350 if original is not None :
332- return original (* args , * *kwargs )
351+ return original (** kwargs )
333352 return None
334353
335354 structlog .configure = _patched_configure
0 commit comments