Skip to content

feat: use local json logs for observability#98

Merged
QueryPlanner merged 2 commits into
mainfrom
feat/local-duckdb-logging
May 25, 2026
Merged

feat: use local json logs for observability#98
QueryPlanner merged 2 commits into
mainfrom
feat/local-duckdb-logging

Conversation

@QueryPlanner
Copy link
Copy Markdown
Owner

What

Replace Axiom OTLP export with local JSON file logging and trace export via DuckDB.

Why

To simplify the observability stack, lower reliance on external third-party services (Axiom), and prevent logs/traces from being lost during CI/CD redeployments. It enables utilizing the server disk and DuckDB for log analytics.

How

  • Removed OTLP endpoint dependency and Axiom integrations.
  • Configured standard logging.FileHandler to output JSON lines to ./logs/blacki-telemetry.log.
  • Created custom JSONFileSpanExporter using BatchSpanProcessor to capture OpenTelemetry traces and write to ./logs/blacki-traces.log asynchronously to prevent blocking the event loop.
  • Adjusted compose.yaml to use bind mounts for logs (./logs:/app/logs) and ADK state (./.adk_state:/app/src/.adk) to protect data from docker system prune during CI/CD.
  • Configured .gitignore to avoid checking in the local logs/ and .adk_state/ folders.

Tests

  • Tested locally via docker compose up -d.
  • Verified blacki-telemetry.log and blacki-traces.log are populated and formatted correctly.
  • Verified DuckDB parses heterogeneous log schemas and trace exports successfully using read_json_auto.
  • Verified mypy, ruff format, and ruff check complete successfully.

- 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
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request transitions the observability stack from OTLP exports to local JSON file logging and tracing, including updates to the Docker environment and .gitignore. Reviewers suggested several enhancements: adding parent_id to trace spans for hierarchy reconstruction, using ISO 8601 timestamps, and including process/thread IDs in logs. Additionally, feedback highlighted opportunities to deduplicate directory path logic, improve JSON serialization robustness using default=str, and simplify OpenTelemetry resource creation.

Comment on lines +111 to +116
span_data = {
"name": span.name,
"context": {
"trace_id": format(span.context.trace_id, "032x"),
"span_id": format(span.context.span_id, "016x"),
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The exported span data is missing the parent_span_id. Without this field, it is impossible to reconstruct the trace hierarchy (parent-child relationships) in DuckDB or other analysis tools.

Suggested change
span_data = {
"name": span.name,
"context": {
"trace_id": format(span.context.trace_id, "032x"),
"span_id": format(span.context.span_id, "016x"),
},
span_data = {
"name": span.name,
"context": {
"trace_id": format(span.context.trace_id, "032x"),
"span_id": format(span.context.span_id, "016x"),
},
"parent_id": format(span.parent.span_id, "016x") if span.parent else None,

Comment thread src/blacki/utils/observability.py Outdated
class JSONFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
log_record = {
"timestamp": self.formatTime(record, self.datefmt),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For machine-readable JSON logs, it is highly recommended to use ISO 8601 format for timestamps. The default formatTime behavior can be locale-dependent and inconsistent across environments.

Suggested change
"timestamp": self.formatTime(record, self.datefmt),
"timestamp": datetime.datetime.fromtimestamp(record.created, tz=datetime.timezone.utc).isoformat(),

Comment on lines +52 to +57
log_record = {
"timestamp": self.formatTime(record, self.datefmt),
"level": record.levelname,
"name": record.name,
"message": record.getMessage(),
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Consider adding process and thread IDs to the JSON log record. This is especially useful for debugging concurrency issues in a FastAPI application.

Suggested change
log_record = {
"timestamp": self.formatTime(record, self.datefmt),
"level": record.levelname,
"name": record.name,
"message": record.getMessage(),
}
log_record = {
"timestamp": self.formatTime(record, self.datefmt),
"level": record.levelname,
"name": record.name,
"message": record.getMessage(),
"process": record.process,
"thread": record.threadName,
}

Comment thread src/blacki/utils/observability.py Outdated

# Configure local JSON file handler
# We use ./logs locally and /app/logs inside Docker, so check both
log_dir = "/app/logs" if Path("/.dockerenv").exists() else "./logs"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic for determining the log directory is duplicated in setup_logging and setup_tracing. Consider extracting this into a helper function or a constant to improve maintainability.

Comment thread src/blacki/utils/observability.py Outdated
if span.events
else [],
}
f.write(json.dumps(span_data) + "\n")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using json.dumps without a default handler can raise a TypeError if any span attributes or events contain non-serializable types. It is safer to use default=str to ensure the exporter doesn't crash.

Suggested change
f.write(json.dumps(span_data) + "\n")
f.write(json.dumps(span_data, default=str) + "\n")

Comment thread src/blacki/utils/observability.py Outdated
Comment on lines +164 to +169
resource_attrs = {}
if "OTEL_RESOURCE_ATTRIBUTES" in os.environ:
for pair in os.environ["OTEL_RESOURCE_ATTRIBUTES"].split(","):
if "=" in pair:
key, value = pair.split("=", 1)
resource_attrs[key] = value
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Manual parsing of OTEL_RESOURCE_ATTRIBUTES is redundant here. The OpenTelemetry SDK's Resource.create() method automatically reads and merges attributes from the environment variable by default.

Suggested change
resource_attrs = {}
if "OTEL_RESOURCE_ATTRIBUTES" in os.environ:
for pair in os.environ["OTEL_RESOURCE_ATTRIBUTES"].split(","):
if "=" in pair:
key, value = pair.split("=", 1)
resource_attrs[key] = value
resource = Resource.create()

- 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
@QueryPlanner QueryPlanner merged commit 06de482 into main May 25, 2026
1 check passed
@QueryPlanner QueryPlanner deleted the feat/local-duckdb-logging branch May 25, 2026 03:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant