66import re
77import sys
88import traceback
9- from datetime import timedelta
9+ from datetime import datetime , timedelta
1010from functools import lru_cache
1111from importlib .metadata import PackageNotFoundError , version
12- from typing import ClassVar , TextIO
13- from uuid import uuid4
12+ from typing import Any , ClassVar , TextIO
13+ from uuid import UUID , uuid4
1414
1515from opentelemetry .exporter .otlp .proto .http ._log_exporter import (
1616 DEFAULT_LOGS_EXPORT_PATH ,
1717 OTLPLogExporter ,
1818 _append_logs_path ,
1919)
20- from opentelemetry .sdk ._logs import LoggerProvider , LoggingHandler
20+ from opentelemetry .instrumentation .logging .handler import LoggingHandler
21+ from opentelemetry .sdk ._logs import LoggerProvider
2122from opentelemetry .sdk ._logs .export import BatchLogRecordProcessor
2223from opentelemetry .sdk .resources import (
2324 HOST_ARCH ,
3132 Resource ,
3233)
3334from opentelemetry .semconv .attributes import exception_attributes
35+ from opentelemetry .trace import get_current_span
3436from opentelemetry .util .types import _ExtendedAttributes
3537
3638# prefix for stdlib loggers
4446_OTEL_LOGS_ENDPOINT_ENV_VAR = "OTEL_LOGS_ENDPOINT"
4547_OTEL_EXPORT_INTERVAL_ENV_VAR = "OTEL_EXPORT_INTERVAL"
4648
49+ _WORKFLOW_LOG_ATTRIBUTES = "tilebox_structured_log_attributes"
50+
4751# process-unique identifier to distinguish different instances of the same service running on the same host
4852_instance_id = str (uuid4 ())
4953
@@ -87,10 +91,51 @@ def _root_logger() -> logging.Logger:
8791 return root_logger
8892
8993
94+ def _current_span_attributes () -> dict [str , str ]:
95+ span_context = get_current_span ().get_span_context ()
96+ if not span_context .is_valid :
97+ return {}
98+
99+ return {
100+ "trace_id" : f"{ span_context .trace_id :032x} " ,
101+ "span_id" : f"{ span_context .span_id :016x} " ,
102+ }
103+
104+
105+ def _sanitize_otel_attribute_value (
106+ value : Any ,
107+ ) -> str | bool | int | float | bytes | list [str | bool | int | float | bytes ]:
108+ if isinstance (value , str | bool | int | float | bytes ):
109+ return value
110+
111+ if isinstance (value , datetime ):
112+ return value .isoformat ()
113+
114+ if isinstance (value , tuple | list ):
115+ values = []
116+ for item in value :
117+ if isinstance (item , str | bool | int | float | bytes ):
118+ values .append (item )
119+ else :
120+ values .append (_sanitize_otel_attribute_value (item ))
121+ return values
122+
123+ return str (value )
124+
125+
126+ def _sanitize_otel_attributes (attributes : dict [str , Any ]) -> _ExtendedAttributes :
127+ return {str (key ): _sanitize_otel_attribute_value (value ) for key , value in attributes .items ()}
128+
129+
90130class OTELLoggingHandler (LoggingHandler ):
91- @staticmethod
92- def _get_attributes (record : logging .LogRecord ) -> _ExtendedAttributes :
93- attributes = {}
131+ def _get_attributes (self , record : logging .LogRecord ) -> _ExtendedAttributes :
132+ attributes : dict [str , Any ] = {}
133+ attributes .update (_current_span_attributes ())
134+
135+ workflow_attributes = getattr (record , _WORKFLOW_LOG_ATTRIBUTES , None )
136+ if isinstance (workflow_attributes , dict ):
137+ attributes .update (workflow_attributes )
138+
94139 # the default implementation returns attributes for the filepath, lineno and function of the log record
95140 # we don't want that by default, so we override it to return an empty dict
96141 if record .exc_info :
@@ -104,7 +149,87 @@ def _get_attributes(record: logging.LogRecord) -> _ExtendedAttributes:
104149 attributes [exception_attributes .EXCEPTION_STACKTRACE ] = "" .join (
105150 traceback .format_exception (* record .exc_info )
106151 )
107- return attributes
152+ return _sanitize_otel_attributes (attributes )
153+
154+
155+ class StructuredLogger :
156+ """A small structured logging wrapper for logs emitted during task execution."""
157+
158+ def __init__ (self , logger : logging .Logger , attributes : dict [str , Any ] | None = None ) -> None :
159+ self ._logger = logger
160+ self ._attributes = attributes or {}
161+
162+ def bind (self , ** attributes : Any ) -> "StructuredLogger" :
163+ """Return a new logger that includes the given attributes in every log record."""
164+ return StructuredLogger (self ._logger , self ._attributes | attributes )
165+
166+ def log (self , level : int , message : object , / , * args : Any , ** attributes : Any ) -> None :
167+ """Log a message with structured attributes."""
168+ self ._log (level , message , args , attributes , exc_info = False )
169+
170+ def debug (self , message : object , / , * args : Any , ** attributes : Any ) -> None :
171+ """Log a debug message with structured attributes."""
172+ self ._log (logging .DEBUG , message , args , attributes , exc_info = False )
173+
174+ def info (self , message : object , / , * args : Any , ** attributes : Any ) -> None :
175+ """Log an info message with structured attributes."""
176+ self ._log (logging .INFO , message , args , attributes , exc_info = False )
177+
178+ def warning (self , message : object , / , * args : Any , ** attributes : Any ) -> None :
179+ """Log a warning message with structured attributes."""
180+ self ._log (logging .WARNING , message , args , attributes , exc_info = False )
181+
182+ def error (self , message : object , / , * args : Any , ** attributes : Any ) -> None :
183+ """Log an error message with structured attributes."""
184+ self ._log (logging .ERROR , message , args , attributes , exc_info = False )
185+
186+ def exception (self , message : object , / , * args : Any , ** attributes : Any ) -> None :
187+ """Log an error message with structured attributes and the current exception information."""
188+ self ._log (logging .ERROR , message , args , attributes , exc_info = True )
189+
190+ def critical (self , message : object , / , * args : Any , ** attributes : Any ) -> None :
191+ """Log a critical message with structured attributes."""
192+ self ._log (logging .CRITICAL , message , args , attributes , exc_info = False )
193+
194+ def _log (
195+ self ,
196+ level : int ,
197+ message : object ,
198+ args : tuple [Any , ...],
199+ attributes : dict [str , Any ],
200+ * ,
201+ exc_info : bool ,
202+ ) -> None :
203+ if not self ._logger .isEnabledFor (level ):
204+ return
205+
206+ workflow_attributes = self ._attributes | attributes | _current_span_attributes ()
207+ self ._logger .log (
208+ level ,
209+ message ,
210+ * args ,
211+ exc_info = exc_info ,
212+ extra = {_WORKFLOW_LOG_ATTRIBUTES : workflow_attributes },
213+ stacklevel = 3 ,
214+ )
215+
216+
217+ @lru_cache (maxsize = 16 ) # reuse logger providers for the same credentials if possible
218+ def _create_tilebox_logger_provider (service : str | None , url : str , token : str | None ) -> LoggerProvider :
219+ provider = LoggerProvider (resource = _get_default_resource (service ))
220+ batch_exporter = _otel_log_exporter (
221+ endpoint = url ,
222+ headers = {"Authorization" : f"Bearer { token } " } if token is not None else None ,
223+ )
224+ provider .add_log_record_processor (batch_exporter )
225+ return provider
226+
227+
228+ def _create_tilebox_logger (client_id : UUID , scope : str ) -> logging .Logger :
229+ logger = logging .getLogger (f"{ _LOGGING_NAMESPACE } .clients.{ client_id } .{ scope } " )
230+ logger .setLevel (logging .DEBUG ) # always debug, so that other handlers can still filter by that level
231+ logger .propagate = True
232+ return logger
108233
109234
110235def _otel_log_exporter (
0 commit comments