Skip to content

feat: add optional structured JSON logging - #91

Merged
cristiangirlea merged 4 commits into
mainfrom
feat/structured-logging
Oct 26, 2026
Merged

cristiangirlea merged 4 commits into
mainfrom
feat/structured-logging

Conversation

@cristiangirlea

@cristiangirlea cristiangirlea commented Oct 25, 2026 •

Copy link
Copy Markdown
Owner

Fixes #75.

What

Optional structured logging for operators, without giving up the reason access logs were off: nothing a client sends can reach a line.

  • BRIDGE_LOG=off|json. off, the default, writes nothing, as before; an empty value means off, and any other value stops the worker at start with a message naming BRIDGE_LOG.
  • With json, one JSON object per line on stderr: ts (UTC, milliseconds), level (info or warning), event, and only that event's keys:
    • request after every reply (401, 408, 413, 431 and the busy 503 included): method (GET, POST or other), route (the template, such as /v1/jobs/{id}, or other; never the raw path or a query string), status, duration_ms, owner (the token's name, null when unauthenticated);
    • job at each terminal status: job_id, task, owner, job_status, queue_ms, run_ms (null for a job that never started);
    • start (backend, concurrency, capacity, retention, max jobs per child, drain deadline, the number of tokens), drain (how long, jobs still running, whether a second signal cut it short) and stop (jobs cancelled at the stop).
  • worker/ai_bridge/log.py holds the schema. Its formatter reads only the fields log.event() passes, drops any key outside the event's schema and writes null for a value that fails its check (fixed sets, the task allowlist, the token-name and job-id patterns, numbers). A plain logging call never reaches the output.
  • Delivery is best effort and never holds a request: lines go through a 1024-line queue to a writer thread that swallows write errors; a full queue drops lines, a stop flushes for about 2 s at most, and a killed process loses what is queued. Documented as such.
  • Two fixes the logging made visible: the drain line is now always written before the stop line (a second signal could lose it), and BridgeServer.handle_error writes nothing, so a peer resetting a connection no longer prints a traceback and its address to stderr in either mode.

Files

  • worker/ai_bridge/log.py (new); worker/ai_bridge/server.py (request, busy, drain, start and stop lines, BRIDGE_LOG, the drain thread joined in server_close, handle_error); worker/ai_bridge/jobs.py (the job line, _started_at, close() returns the cancelled count).
  • tests/python/test_log.py (new), tests/python/test_server.py, tests/python/test_jobs.py.
  • docs/protocol.md (a Logging section with the schema), docs/security.md, README.md, CHANGELOG.md, docs/testing.md (the Python row and count), .github/workflows/model-smoke.yml (log.py on paths:, since jobs.py imports it).

Tests

Every line parses and holds exactly the keys the test writes out for its event (a literal key set, not the implementation's table). A run whose input, idempotency key, Authorization header, query string, wrong token and failing task's exception text all carry a sentinel never writes the sentinel, a token, Bearer or ?. off writes nothing; a bad BRIDGE_LOG stops the start. The route template for every route and other; the 401 line has no owner; one line each for 408, 413, 431 and the busy 503; a connection that sends nothing or half a header block writes none. Job timings agree with the metrics stamps, each job has exactly one line, and queue_ms and run_ms are null for a job that never started. A stream that raises or blocks never fails or holds a request, and a writer left with a full queue still ends once its stream is released. Through main(): start and stop lines, cancelled >= 1 at warning when a job is running at shutdown, and an interrupted drain's single drain line with interrupted: true before stop. A peer reset leaves stderr with JSON lines only in json and empty in off. The new tests were seen failing before the code, and the review-round tests were seen failing before their fixes (the stuck writer, the traceback on reset, the lost drain line); the request-line race fix holds over three full runs in a row.

Checks

The full command block of AGENTS.md, run as one command on this head (a8295ba: the slice commit 05ede27 merged with main at 2bd4b4b, which brought in the drain, images, load, quality and supply-chain slices) in a worktree, PHP 8.5:

  • --profile test config: ok
  • python-tests: Ran 395 tests, OK
  • php-tests: PASS: 148 PHP contract checks; php -l: no syntax errors
  • php-integration: PASS: 192 total PHP checks; integration: PASS (12 concurrent jobs, metrics, jobs scoped to their token)
  • mcp-tests: Ran 125 tests, OK; composer-install: ok
  • php-analysis: [OK] No errors; php-psr18-tests: PASS: 178
  • laravel-tests: 94 checks; symfony-tests: 96 checks; tls-check: 7 + 3 checks

Before the merge, python-tests also passed three times in a row on the slice alone (316 tests each). PHP 8.2, 8.3 and 8.4 are left to CI.

Review

Two independent read-only reviews: codex (another model family) and the delivery reviewer. They agreed on these findings, all fixed in this branch before the push:

  • a second signal could lose the drain line or write it after stop;
  • the default handle_error put a traceback and the client's address on stderr, contrary to the new docs;
  • the request-line tests raced log.close();
  • a full queue at close left the writer thread waiting forever;
  • a test class sat below the __main__ guard;
  • the docs didn't say that delivery is best effort.

The missing tests both reviews named are added (above). Security: nothing open; no client-controlled text reaches a line, and the one low-severity point (the traceback on a peer reset, older than this branch) is fixed here.

Not verified here: a container log driver reading the worker's stderr, since no compose service sets BRIDGE_LOG; model-smoke.yml runs on this pull request because log.py is on its paths:.

BRIDGE_LOG=json writes one JSON line on stderr per request, per finished
job and at start, drain and stop, from a fixed schema whose values are
checked against fixed sets: route templates, never the raw path, token
names, never tokens, and no input, result, header or exception text.
Lines go through a bounded queue to a writer thread, so a stuck or broken
stderr never holds a request; delivery is best effort. The default, off,
writes nothing, and the server no longer prints a traceback when a peer
resets a connection.
@cristiangirlea
cristiangirlea merged commit dd2cd9d into main Oct 26, 2026
14 checks passed
@cristiangirlea
cristiangirlea deleted the feat/structured-logging branch October 26, 2026 10:54
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.

feat: optional structured JSON logging that never records inputs or tokens

1 participant