Skip to content

Commit 06de482

Browse files
authored
feat: use local json logs for observability (#98)
* feat: use local json logs for observability - Replace Axiom OTLP export with local JSON file logging - Add local JSONFileSpanExporter for OpenTelemetry traces - Use BatchSpanProcessor to prevent blocking event loop - Write telemetry to blacki-telemetry.log - Write traces to blacki-traces.log - Update compose.yaml to use bind mounts for logs and state - Update gitignore to exclude local logs and state * fix: address pr review comments for observability - Add parent_id to trace spans for hierarchy - Use ISO 8601 timestamps for logs and spans - Include process and thread IDs in logs - Deduplicate directory path logic - Improve JSON serialization using default=str - Simplify OpenTelemetry resource creation
1 parent 0e90071 commit 06de482

5 files changed

Lines changed: 152 additions & 50 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,13 @@ pip-wheel-metadata/
8585
# Logs
8686
# ---------------------------------------------------------------------------
8787
*.log
88+
logs/
8889

8990
# ---------------------------------------------------------------------------
9091
# Local runtime state (Google ADK / agent sessions, etc.)
9192
# ---------------------------------------------------------------------------
9293
**/.adk/
94+
.adk_state/
9395

9496
# Local runtime data (Qdrant vector store, etc.)
9597
data/

compose.yaml

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,7 @@ services:
1818
extra_hosts:
1919
- "host.docker.internal:host-gateway"
2020
volumes:
21-
- agent_artifacts:/app/src/.adk
21+
- ./.adk_state:/app/src/.adk
22+
- ./logs:/app/logs
2223
restart: always
2324
command: python -m blacki.server
24-
25-
volumes:
26-
agent_artifacts:

src/blacki/server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
configure_otel_resource,
2626
initialize_environment,
2727
setup_logging,
28+
setup_tracing,
2829
validation,
2930
)
3031

@@ -39,6 +40,7 @@
3940
GoogleADKInstrumentor().instrument()
4041

4142
setup_logging(log_level=env.log_level)
43+
setup_tracing()
4244

4345
_telegram_bot = None
4446
_container: AppContainer | None = None

src/blacki/utils/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@
33
from . import validation
44
from .config import ServerEnv, initialize_environment
55
from .exceptions import ConfigurationError
6-
from .observability import configure_otel_resource, setup_logging
6+
from .observability import configure_otel_resource, setup_logging, setup_tracing
77

88
__all__ = [
99
"ConfigurationError",
1010
"ServerEnv",
1111
"configure_otel_resource",
1212
"initialize_environment",
1313
"setup_logging",
14+
"setup_tracing",
1415
"validation",
1516
]

src/blacki/utils/observability.py

Lines changed: 144 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,29 @@
44
OpenTelemetry environment variables for vendor-neutral operation.
55
"""
66

7+
import json
78
import logging
89
import os
910
import sys
1011
import 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
1617
from 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

2532
def 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+
4471
def 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

Comments
 (0)