44OpenTelemetry environment variables for vendor-neutral operation.
55"""
66
7+ import json
78import logging
89import os
910import sys
1011import uuid
12+ from collections .abc import Sequence
13+ from datetime import UTC , datetime
14+ from pathlib import Path
1115
12- from opentelemetry ._logs import set_logger_provider
13- from opentelemetry .exporter .otlp .proto .http ._log_exporter import OTLPLogExporter
14- from opentelemetry .sdk ._logs import LoggerProvider , LoggingHandler
15- from opentelemetry .sdk ._logs .export import BatchLogRecordProcessor
16+ from opentelemetry import trace
1617from opentelemetry .sdk .resources import (
1718 SERVICE_INSTANCE_ID ,
1819 SERVICE_NAME ,
1920 SERVICE_NAMESPACE ,
2021 SERVICE_VERSION ,
2122 Resource ,
2223)
24+ from opentelemetry .sdk .trace import ReadableSpan , TracerProvider
25+ from opentelemetry .sdk .trace .export import (
26+ BatchSpanProcessor ,
27+ SpanExporter ,
28+ SpanExportResult ,
29+ )
2330
2431
2532def configure_otel_resource (agent_name : str ) -> None :
@@ -41,59 +48,151 @@ def configure_otel_resource(agent_name: str) -> None:
4148 )
4249
4350
51+ def get_log_dir () -> Path :
52+ """Get the appropriate log directory based on environment."""
53+ return Path ("/app/logs" ) if Path ("/.dockerenv" ).exists () else Path ("./logs" )
54+
55+
56+ class JSONFormatter (logging .Formatter ):
57+ def format (self , record : logging .LogRecord ) -> str :
58+ log_record = {
59+ "timestamp" : datetime .fromtimestamp (record .created , tz = UTC ).isoformat (),
60+ "level" : record .levelname ,
61+ "name" : record .name ,
62+ "message" : record .getMessage (),
63+ "process_id" : record .process ,
64+ "thread_id" : record .thread ,
65+ }
66+ if record .exc_info :
67+ log_record ["exception" ] = self .formatException (record .exc_info )
68+ return json .dumps (log_record , default = str )
69+
70+
4471def setup_logging (log_level : str ) -> None :
45- """Set up basic logging with optional OTLP log export.
72+ """Set up basic logging with local JSON file export.
4673
47- Configures Python logging to output to stdout. If OTEL_EXPORTER_OTLP_LOGS_ENDPOINT
48- is set, also exports logs via OTLP for correlation with traces.
74+ Configures Python logging to output to stdout and append to a local JSON file.
4975
5076 Args:
5177 log_level: Logging verbosity level as string
5278 """
5379 level = getattr (logging , log_level .upper (), logging .INFO )
5480
55- # Configure root logger with stdout handler
56- logging .basicConfig (
57- level = level ,
58- format = "%(asctime)s [%(levelname)s] %(name)s: %(message)s" ,
59- handlers = [logging .StreamHandler (sys .stdout )],
81+ # Configure stdout handler
82+ console_handler = logging .StreamHandler (sys .stdout )
83+ console_handler .setFormatter (
84+ logging .Formatter ("%(asctime)s [%(levelname)s] %(name)s: %(message)s" )
6085 )
6186
87+ handlers : list [logging .Handler ] = [console_handler ]
88+
89+ # Configure local JSON file handler
90+ log_dir = get_log_dir ()
91+ try :
92+ log_dir .mkdir (parents = True , exist_ok = True )
93+ file_handler = logging .FileHandler (log_dir / "blacki-telemetry.log" )
94+ file_handler .setFormatter (JSONFormatter ())
95+ handlers .append (file_handler )
96+ except OSError as e :
97+ print (f"⚠️ Failed to create log directory or file handler: { e } " )
98+ print (" Continuing with stdout logging only..." )
99+
100+ # Configure root logger
101+ logging .basicConfig (level = level , handlers = handlers , force = True )
102+
62103 # Set levels for some noisy libraries if needed
63104 logging .getLogger ("urllib3" ).setLevel (logging .WARNING )
64105
65- # Set up OTLP log export if endpoint is configured
66- logs_endpoint = os .getenv ("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT" )
67- if logs_endpoint :
106+
107+ class JSONFileSpanExporter (SpanExporter ):
108+ """Exports OpenTelemetry Spans to a local JSON Lines file."""
109+
110+ def __init__ (self , log_path : str ):
111+ self .log_path = log_path
112+
113+ def export (self , spans : Sequence [ReadableSpan ]) -> SpanExportResult :
68114 try :
69- print (f"📊 Configuring OTLP log export to: { logs_endpoint } " )
70-
71- # Create resource from OTEL_RESOURCE_ATTRIBUTES
72- resource_attrs = {}
73- if "OTEL_RESOURCE_ATTRIBUTES" in os .environ :
74- for pair in os .environ ["OTEL_RESOURCE_ATTRIBUTES" ].split ("," ):
75- if "=" in pair :
76- key , value = pair .split ("=" , 1 )
77- resource_attrs [key ] = value
78-
79- resource = Resource .create (resource_attrs )
80-
81- # Set up logger provider with OTLP exporter
82- provider = LoggerProvider (resource = resource )
83- provider .add_log_record_processor (
84- BatchLogRecordProcessor (OTLPLogExporter ())
85- )
86- set_logger_provider (provider )
87-
88- # Add OTLP handler to root logger
89- handler = LoggingHandler (level = level )
90- logging .getLogger ().addHandler (handler )
91-
92- print ("✅ OTLP log export configured" )
115+ with Path (self .log_path ).open ("a" ) as f :
116+ for span in spans :
117+ # Convert span attributes and events to serializable dicts
118+ span_data = {
119+ "name" : span .name ,
120+ "context" : {
121+ "trace_id" : format (span .context .trace_id , "032x" ),
122+ "span_id" : format (span .context .span_id , "016x" ),
123+ },
124+ "parent_id" : format (span .parent .span_id , "016x" )
125+ if span .parent
126+ else None ,
127+ "kind" : span .kind .name if span .kind else None ,
128+ "start_time" : datetime .fromtimestamp (
129+ span .start_time / 1e9 , tz = UTC
130+ ).isoformat ()
131+ if span .start_time
132+ else None ,
133+ "end_time" : datetime .fromtimestamp (
134+ span .end_time / 1e9 , tz = UTC
135+ ).isoformat ()
136+ if span .end_time
137+ else None ,
138+ "status" : {
139+ "status_code" : span .status .status_code .name
140+ if span .status
141+ else None ,
142+ "description" : span .status .description
143+ if span .status
144+ else None ,
145+ },
146+ "attributes" : dict (span .attributes ) if span .attributes else {},
147+ "events" : [
148+ {
149+ "name" : event .name ,
150+ "timestamp" : datetime .fromtimestamp (
151+ event .timestamp / 1e9 , tz = UTC
152+ ).isoformat ()
153+ if event .timestamp
154+ else None ,
155+ "attributes" : dict (event .attributes )
156+ if event .attributes
157+ else {},
158+ }
159+ for event in span .events
160+ ]
161+ if span .events
162+ else [],
163+ }
164+ f .write (json .dumps (span_data , default = str ) + "\n " )
165+ return SpanExportResult .SUCCESS
93166 except Exception as e :
94- print (f"⚠️ Failed to configure OTLP log export: { e } " )
95- print (" Continuing with stdout logging only..." )
96- logging .getLogger (__name__ ).warning (
97- "OTLP log export setup failed, falling back to stdout-only logging: %s" ,
98- e ,
99- )
167+ print (f"⚠️ Failed to write trace to { self .log_path } : { e } " )
168+ return SpanExportResult .FAILURE
169+
170+ def shutdown (self ) -> None :
171+ pass
172+
173+
174+ def setup_tracing () -> None :
175+ """Set up OpenTelemetry tracing with local JSON file export."""
176+ log_dir = get_log_dir ()
177+ log_path = log_dir / "blacki-traces.log"
178+
179+ try :
180+ log_dir .mkdir (parents = True , exist_ok = True )
181+ except OSError as e :
182+ print (f"⚠️ Failed to create trace directory: { e } " )
183+ return
184+
185+ # Resource automatically reads OTEL_RESOURCE_ATTRIBUTES from environment variables
186+ resource = Resource .create ()
187+
188+ # Set up tracer provider
189+ provider = TracerProvider (resource = resource )
190+
191+ # Add our custom JSON exporter
192+ exporter = JSONFileSpanExporter (str (log_path ))
193+ processor = BatchSpanProcessor (exporter )
194+ provider .add_span_processor (processor )
195+
196+ # Register global tracer provider
197+ trace .set_tracer_provider (provider )
198+ print (f"✅ Local trace export configured to: { log_path } " )
0 commit comments