Skip to content

Commit beb5f4e

Browse files
committed
0.2.8: structured JSON logging for production deployments
oikb daemon --log-format json (or LOG_FORMAT=json env var) outputs one JSON object per line with ts, level, logger, msg, and optional sync-specific fields. Compatible with Datadog, Splunk, ELK, CloudWatch, and Loki.
1 parent 2aa7211 commit beb5f4e

6 files changed

Lines changed: 84 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

7+
## [0.2.8] - 2025-05-21
8+
9+
### Added
10+
11+
- Structured JSON logging for the daemon — `oikb daemon --log-format json` or `LOG_FORMAT=json` env var. Outputs one JSON object per line with `ts`, `level`, `logger`, `msg`, and optional `source`/`kb_id`/`duration_ms` fields. Compatible with Datadog, Splunk, ELK, CloudWatch, and Loki.
12+
713
## [0.2.7] - 2025-05-21
814

915
### Added

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "oikb"
3-
version = "0.2.7"
3+
version = "0.2.8"
44
description = "Sync anything to Open WebUI Knowledge Bases"
55
readme = "README.md"
66
authors = [

src/oikb/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""oikb — Open WebUI Knowledge Base CLI."""
22

3-
__version__ = "0.2.7"
3+
__version__ = "0.2.8"

src/oikb/cli.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import os
56
import sys
67
from pathlib import Path
78

@@ -760,12 +761,15 @@ def validate(config_file: str | None):
760761
@click.option("--port", default=8080, type=int, help="HTTP port for healthcheck and API (default: 8080).")
761762
@click.option("--no-server", is_flag=True, help="Run scheduler only, no HTTP server.")
762763
@click.option("--config", "config_file", default=None, type=click.Path(), help="Path to .oikb.yaml (default: ./.oikb.yaml).")
763-
def daemon(port: int, no_server: bool, config_file: str | None):
764+
@click.option("--log-format", default=None, type=click.Choice(["text", "json"]), help="Log output format (default: text, env: LOG_FORMAT).")
765+
def daemon(port: int, no_server: bool, config_file: str | None, log_format: str | None):
764766
"""Run as a long-lived daemon with scheduled sync.
765767
766768
Reads .oikb.yaml and syncs each source on its configured interval.
767769
Exposes /health, /history, and /sync endpoints.
768770
"""
771+
log_format = log_format or os.environ.get("LOG_FORMAT", "text")
772+
769773
if config_file:
770774
import yaml
771775
with open(config_file) as f:
@@ -785,7 +789,7 @@ def daemon(port: int, no_server: bool, config_file: str | None):
785789
sys.exit(1)
786790

787791
from oikb.daemon import start_daemon
788-
start_daemon(entries=entries, port=port, no_server=no_server)
792+
start_daemon(entries=entries, port=port, no_server=no_server, log_format=log_format)
789793

790794

791795
# ── history ─────────────────────────────────────────────────────

src/oikb/daemon.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ async def verify_api_key(
4040
app = FastAPI(
4141
title="oikb",
4242
description="Sync engine for Open WebUI Knowledge Bases. Trigger syncs, check status, and query history.",
43-
version="0.2.7",
43+
version="0.2.8",
4444
)
4545

4646
# Runtime state populated by start_daemon().
@@ -305,9 +305,14 @@ def start_daemon(
305305
entries: list[dict],
306306
port: int = 8080,
307307
no_server: bool = False,
308+
log_format: str = "text",
308309
) -> None:
309310
"""Start the daemon with scheduler + optional HTTP server."""
310311
global _history, _entries
312+
313+
from oikb.logging import configure_logging
314+
configure_logging(log_format=log_format)
315+
311316
_entries = entries
312317
_history = SyncHistory()
313318
set_build_info(__version__)

src/oikb/logging.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Structured JSON log formatter for production deployments."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import logging
7+
import time
8+
from typing import Any
9+
10+
11+
class JsonFormatter(logging.Formatter):
12+
"""Emit log records as single-line JSON objects.
13+
14+
Output format (one JSON object per line):
15+
{"ts":"2025-05-21T06:00:01Z","level":"INFO","logger":"oikb.daemon","msg":"Synced ..."}
16+
17+
Compatible with Datadog, Splunk, ELK, CloudWatch, Loki, and any
18+
JSON-aware log aggregator.
19+
"""
20+
21+
def format(self, record: logging.LogRecord) -> str:
22+
entry: dict[str, Any] = {
23+
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(record.created)),
24+
"level": record.levelname,
25+
"logger": record.name,
26+
"msg": record.getMessage(),
27+
}
28+
29+
if record.exc_info and record.exc_info[1] is not None:
30+
entry["exception"] = self.formatException(record.exc_info)
31+
32+
# Merge any extra fields passed via `log.info("msg", extra={...})`.
33+
for key in ("source", "kb_id", "status", "duration_ms",
34+
"files_added", "files_modified", "files_deleted"):
35+
val = getattr(record, key, None)
36+
if val is not None:
37+
entry[key] = val
38+
39+
return json.dumps(entry, default=str, ensure_ascii=False)
40+
41+
42+
def configure_logging(log_format: str = "text", log_level: str = "INFO") -> None:
43+
"""Configure root logging for the daemon.
44+
45+
Args:
46+
log_format: "text" for human-readable, "json" for structured JSON lines.
47+
log_level: Standard Python log level name.
48+
"""
49+
root = logging.getLogger()
50+
root.setLevel(getattr(logging, log_level.upper(), logging.INFO))
51+
52+
# Remove any existing handlers (uvicorn may add its own).
53+
root.handlers.clear()
54+
55+
handler = logging.StreamHandler()
56+
57+
if log_format == "json":
58+
handler.setFormatter(JsonFormatter())
59+
else:
60+
handler.setFormatter(
61+
logging.Formatter("%(asctime)s %(levelname)-8s %(name)s %(message)s")
62+
)
63+
64+
root.addHandler(handler)

0 commit comments

Comments
 (0)