All notable changes to this project will be documented here. Format: Keep a Changelog. Versioning: Semantic Versioning. Wire-format contracts (JSONL line schema, on-disk layout, HTTP read API) follow the append-only / new-format-key migration discipline documented in PHILOSOPHY.md § principle 6.
- Exported
storage.DefaultMaxBytes(5 GiB),storage.DefaultMaxAgeDays(30),storage.DefaultWarnAtPercent(80) constants documenting the retention defaults a fresh deployment carries. api-log packagesubcommand: build an offline zip of recorded traces — same shape asPOST /api/export(JSONL + media +agent/CLAUDE.md+jq-cheatsheet.md+README.md) — without requiring the proxy / API listeners to be running. Flags:-config,-out(file path or-for stdout),-from/-to(RFC3339 or YYYY-MM-DD),-path/-path-prefix,-model,-key-prefix,-limit. Reads the same SQLite + JSONL the proxy writes; safe to run alongside a live proxy (WAL mode handles concurrent readers). Intended for two jobs: handing a window of traces to an offline analysis agent, and piping cold-storage offloads through stdout into the operator's sink of choice (S3 / R2 / NAS).
- Fresh deployments now ship with retention ON by default: 5 GiB
byte cap, 30-day age cap, warn at 80% (storage.Default* constants
above). v0.1.0–v0.1.2 deployments with a persisted
runtime_overrides.jsonkeep their existing values — the override block in main.go runsUpdateConfigAFTER construction and wins. Operators who want retention OFF can PUT both knobs to 0 via/api/config/retention. Motivation: the prior "engine running, never delete" default was surprising for OSS adopters on small VPS / bounded PVC; left to itself the data dir grew unbounded. - Day-cross gzip rotation now runs at level 6 (default) instead
of the prior level-1 BestSpeed. Rotation is a background goroutine
off the writer hot path, so the modest extra CPU cost buys 20–30%
smaller files on JSONL (highly redundant text). The choice is now
a single named constant (
writer.rotateCompressionLevel) so future tuning has one place to look.
/api/exportbyte-cap pre-flight (commitff0c42f): complementary safety net to the v0.1.1 row cap. Default 2 GiB;?bytes_all=1bypass is independent of?all=1(each cap names what it lets through). Measured in source JSONL bytes (sum ofos.Statfile sizes for matched groups; one syscall per group, not per row). Over-cap returns 413 withlimit:"bytes"discriminator before any zip bytes hit the wire. Audit-driven: sub2api's 9 GB / ~6.9 GB+ zip didn't trip the 50000-row cap because media-heavy rows are bytes-per-row dominant, not row-dominant.capture_filterplugin (commitd64863c): pre-write path drop, sister to the existing observer-classpath_filter. Wherepath_filterdrops at finalize (after bytes ran through the capture sinks),capture_filteris checked in the proxy handler BEFOREstartTrace— a matched path skips JSONL + SQLite + media- body tee entirely; the request still forwards upstream unchanged.
Audit-driven (v0.1.1 deploy workflow showed sub2api stores 86%
control-plane polling that's not LLM-observability material).
Opt-in via
plugins.capture_filter.patterns; existingplugins.path_filterdeployments are not silently re-interpreted. Pattern syntax mirrorspath_filter. Countertraces_dropped_capture_filtersurfaces drop activity on/healthz.counters. Live-verified on sub2gpt with/api/v1/auth/*+/api/v1/subscriptions/*patterns.
- body tee entirely; the request still forwards upstream unchanged.
Audit-driven (v0.1.1 deploy workflow showed sub2api stores 86%
control-plane polling that's not LLM-observability material).
Opt-in via
curlUA in client taxonomy (commitf60c66d): rule 11 inparser/client.go, placed aftergo-http-client. Generalises smoke-test + healthprobe traffic across every adopter. Justified by a Workflow audit (we0wvgjhv) that sampled 80 of 197 null client_kind rows on sub2gpt and found 7 cleancurl/8.7.1UAs.
pathfilter.Pattern+pathfilter.Compile+pathfilter.MatchAnyare now exported so sibling plugins (initiallycapturefilter) reuse one validated parse + match implementation. Behavior ofpathfilter.Plugin.Initis unchanged.
- B1 / B2 — storage coordinator + lease arbitration (commit
ce31ac2): newinternal/storage/package owns file identity (FileID), refcount leases, on-disk inventory, status, and eviction. Writer acquires a per-(date, keyhash)bucket lease BEFORE opening the JSONL — eviction'sdeleteIfIdlerefuses while any lease is held. Foundation for runtime-toggleable retention without TOCTOU. Adds idempotentidx_jsonl_pathindex; switches SQLite DSN to embedjournal_mode=WAL+busy_timeout(5000)+foreign_keys(on)so every conn the pool hands out has the right pragmas (was only the first); bumpsMaxOpenConns8 → 10 to absorb the storage monitor's reconcile sweep.media.Extract(t, bucket)now takes the writer-chosen bucket FileID so media co-locates with its JSONL across UTC midnight rotation. - B3 — streaming
/api/exportwith 413 pre-flight (commite73f5c2): two-phase pipeline — Phase 1 borrows a single SQLite conn via the newStreamMatchingcursor and walks rows building per-file groups + slim media refs; Phase 2 builds the zip after the cursor + conn release, withRegisterCompressorswapping Deflate level 6 → level 1 (~3× wall-clock for ~5% size on 100k+ row exports). Pre-flightCountMatchinggates oversized exports at a 50000-row default cap and returns 413 with a JSON pointer to the bypass flag BEFORE any zip bytes hit the wire;?all=1disables. Newproject=filter wired through;r.Context()threads through the cursor + zip loop for clean mid-export cancel. - B4 —
/healthz.storage+/api/config/retentionGET+PUT (commit8b1287c): operator-facing surface for the storage coordinator. PUT validates against the same rulesstorage.Newenforces, applies in-memory FIRST viacoord.UpdateConfig, then persists toruntime_overrides.jsonso the next process start picks it up without env / yaml plumbing. Both knobs zero == disable retention without restart./healthzgains a top-levelstoragekey carryingDataDirBytes / MaxBytes / MaxAgeDays / UsagePct / State / EngineRunning / EvictionCapHitplus conditionalLastEvictionTs/LastEvictedBytes. - B5 — writer idle-close (commit
8a95408): after N min (default 10m) without an append, the writer releases the bucket lease and closes the OS handle so retention's byte-cap can touch today's quiet buckets and long-tail keys don't pin a process- lifetime fd each. Idle-close does NOT gzip — that's strictly a date-cross event.SetIdleTimeoutis the public knob; tests pass tiny values without exposing a constructor param.
exporter.WriteZipsignature:(ctx, w, store, dataDir, filters, coord). Removedlimit int— pre-flightCountMatchingis the gate now; the cursor walks unbounded. Existing test callsites passcontext.Background().writer.Newsignature gainscoord *storage.Coordinatoras the 8th argument beforeclock.nilkeeps the v0.1.0 behavior (no lease arbitration); production callers wire a non-nil coord.media.Extract(t)→media.Extract(t, bucket storage.FileID). Caller passes the writer-chosen bucket; fixes a UTC-midnight co-location bug where a trace's TsStart could resolve a different bucket than the writer just wrote the JSONL to.internal/api.DepsgainsStorageCoord *storage.Coordinator. Nil-safe —/healthzomits the storage block,/api/config/ retentionreturns 503, andexporter.WriteZipfalls back to lease-less reads.counters.CountersgainsAddEvictedTraces/AddEvictedBytes- matching
Snapshot.EvictedTraces/EvictedBytes.
- matching
UpdateConfigsynthesizes a baselineStatuswhen called before the first monitor tick so PUT-then-GET sees the new thresholds instead ofpending.EngineRunningstays false until the monitor goroutine actually starts.
- Pluggable viewer releases endpoint (commit
20b2275): newAPILOG_VIEWER_RELEASES_API_BASE+APILOG_VIEWER_RELEASES_AUTH_TOKENconfig / env knobs route the dist.zip fetch at non-GitHub stores (Gitea, Forgejo, GHE). Useful for staging viewer changes against an internal artifact mirror before cutting a public release; tested live on sub2gpt pulling fromgitea.homelab.lan(Gitea release shape is GitHub-compatible). Default empty keeps v0.1.0 behavior.
exporter.summarizeFilters(internal/exporter/exporter.go) now emits a line for theProjectfilter in the in-zipREADME.md. The filter was wired through SQLite correctly since v0.1.1's streaming rewrite; only the human-readable summary line was missing. Caught by the v0.1.1 pre-tag deploy audit (commitae5a97d).
- W4.1 Phase 2 — client_project extraction (2026-05-31, commit
3c6503d): newinternal/parser/project.goexportsExtractProjectContext(systemPrompt string) *ProjectInfomirroring the viewer'spromptSource.extractProjectContext(3 matchers in priority order: AGENTS.md injection ref + heading / CLAUDE.md injection ref + heading / first-heading-at-start). Writer calls it once at finalize on the extracted system prompt; populatesRow.ClientProject. SQLite gains a nullable TEXT columnclient_projectvia idempotent ALTER TABLE; INSERT / select / exporter follow in lockstep (28 → 30 columns, the second column being W4.2'sclient_version). New/api/traces?project=filter joins the table for the viewer to scope a list to one project. rowJSON usesomitemptyon ClientProject — the absence is the common case and the JSON stays tight when extraction yields nothing. - W4.2 — Plugin hot-reload (2026-05-31, commit
e2212cf):PUT/PATCH/DELETEon/api/config/pluginsswap the live Registry atomically viaatomic.Pointer[instanceSnapshot]— the operator no longer needs to restartapi-logafter editing plugin config. Reload is all-or-nothing (rollback on init error); startup tolerates a single bad instance (collect-and-continue) — sharedbuildInstanceshelper, different policies. Per-Iterate-call snapshot granularity is documented as accepted behavior: a Reload landing between a request's BEFORE and AFTER chain means BEFORE runs on the old config and AFTER on the new. Old instances are NOT Close()d on swap (a new instance might reference the same external resource as an old one; Close runs only on graceful shutdown). Rollback uses the pre-write LoadOverrides snapshot, not the handler's in-memory copy (SaveOverride does its own RMW). Tests: TestRegistry_AtomicReload exercises tight reader-loop + Reload under-race; PUT / PATCH / DELETE integration tests verify live-registry mutation + rollback path. - Plugin Phase B + C — contract + PHILOSOPHY amendments (2026-05-30,
commit
39690f1):docs/specs/plugin-b-c-spec.mdfrozen as the BUILD contract for the interfere-class hook surface (BEFORE / AFTER) layered on top of the Phase A.1 Observer scaffold. This commit is docs-only (no Go file touched) — it ships the PHILOSOPHY amendments the interfere-class surface requires before any code lands: PHILOSOPHY §2 gains a paragraph allowing operator-opt-in BEFORE/AFTER interference bounded by the two hook points (verbatim per spec §6.1); PHILOSOPHY §6 gains a paragraph documenting that plugin mutations are recorded post-mutation only — no pre/post diff retained (verbatim per spec §6.2); PHILOSOPHY "no" listNo configurable header / body redaction filtersrewritten to "in the capture path itself" with the plugin-path carve-out (verbatim per spec §6.4). ROADMAP gains §11 Plugin Phase B + C and §5 cross-links. ARCHITECTURE §6.7 amended for the upcoming/api/config/pluginsfamily (second write-side endpoint family in the read API, pattern-matching the §6.8/api/config/mediaprecedent); ARCHITECTURE §13.4 added documenting the v1 carve-out: AFTER plugins may mutateContent/Reasoningonly, and streamingtool_callargument mutation is deferred to Phase D per spec §10.6 (not cut — a futureToolCallMutatoroptional interface ships via Go's stdlibio.WriterTo/io.ReaderFromevolution pattern when a real adopter use case surfaces). Go code lands in subsequent commits per spec §9 work packages W1–W6. - R5a — Client identification (2026-05-30, commit
de44b28): newinternal/parser/client.goexportsExtractClient(h trace.Headers) ClientInfowith a 10-rule deterministic header-based classifier (claude-code-desktop, claude-cli, anthropic-sdk-py/ts, openai-sdk-py/ts, codex-cli, opencode-cli, browser, go-http-client). Writer calls it once at finalize; populates Row.ClientKind + Row.ClientVersion. SQLite gains two nullable TEXT columns via idempotent ALTER TABLE. No counters per-kind, no list-filter API expansion in this commit (restraint perfeedback_design_discipline). Unblocks the T5 Landing "active clients" distribution panel. - Phase H (2026-05-30, commit
492f1ad):total_bytescumulative counter on/healthz— bumped at JSONL append time so the read API can answer "how much have we recorded so far" without walking the data dir. (Token cumulative counters shipped subsequently in T3, commit49e55bb, below.) - Phase H (2026-05-30, commit
96cf41a):clientAddrOf()extracts the real client IP fromX-Forwarded-For(leftmost = original client, RFC 7239) →X-Real-IP→r.RemoteAddrchain. Effective on traces where XFF actually reaches the backend; in the current homelab topology the placeholder evaluates to127.0.0.1because external HTTP is forwarded into the Caddy LXC via host loopback (incus port-forwarder / docker stack) — recovering the real IP needs PROXY protocol on the listener, separate infra task. - Phase I (2026-05-29, commit
6294be2):internal/exporterpackage +GET /api/exportendpoint streaming a zip of matching JSONL lines bundled withagent/CLAUDE.md+agent/jq-cheatsheet.md+README.md. NewStore.AllMatching(filters, hardCap)walks rows chronologically (ts_start ASC, id ASC) past the List() page limit — extractedbuildListCondshelper so the filter pipeline is single-sourced.Deps.DataDiradded. - Phase J (2026-05-30, commit
35acd7c): Plugin Phase A scaffold —internal/plugin/package with thePlugininterface,ObserveBeforeRecord(shouldRecord gate) +ObserveAfterRecord(side-effect hook) discriminants, andRegistry. First concrete plugininternal/plugin/builtin/pathfilter/matchestrace.Pathagainst glob patterns fromconfig.Plugins.PathFilter.Patternsand drops matched traces. Scaffold-only —cmd/api-log/main.godoes NOT construct the Registry yet; wiring is Phase A.1 (deliberate separation perproject_gate_positionmemory + PHILOSOPHY §2 carve-out).Config.Plugins.PathFilterblock + env binding added. Tests: table tests for the Registry'sIterateBeforeRecordordering invariant + pathfilter's glob behavior. - Phase J (2026-05-30, commit
35acd7c): export 5000-row cap removed end-to-end.internal/exporter/exporter.godropped theHardCap = 5000const;WriteZipforwards the caller'slimitstraight toAllMatching.Store.AllMatchingemitsLIMIT ?conditionally —hardCap=0/ negative means unlimited.internal/api/export.goparseExportFilterslifts the ceiling check onlimit(only rejectsn < 1). - T3.1 — Anthropic Messages SSE coverage (2026-05-30, follow-up to
T3):
extractMessagesnow handles BOTH on-disk shapes. Body shape unchanged. New streaming shape walksresp.eventsin a single O(N) pass — firstmessage_start.data.messageyields model + input_tokens- cache_read_input_tokens + cache_creation_input_tokens; the most
recent
message_delta.datayields stop_reason + cumulative output_tokens;message_stopcarries no usage and is skipped. Triggered by empirical observation on sub2api real traffic immediately after T3 shipped: 47 traces of/v1/messages?beta=trueall showed NULL tokens because the dominant Anthropic traffic is streaming. PHILOSOPHY §1 compliant — every field path is a named Anthropic Messages SSE field; the extractor just dispatches on event name to find each named field. Tests: 4 new SSE test cases (happy-path with cache split + cumulative output_tokens, multi-delta last-wins, first-start-wins defensive, mid-stream-cut graceful degrade). Gemini:streamGenerateContentSSE remains out-of-scope until a Gemini operator surfaces with real samples.
- cache_read_input_tokens + cache_creation_input_tokens; the most
recent
- T3 — usage extraction (2026-05-30, commit
49e55bb): newinternal/parser/usage.goexportsExtractUsage(trace.Trace) UsageInfowith per-protocol field paths for Chat / Messages / Responses / Gemini; pointer fields throughout (nil != zero). Writer calls it once perappendOneat finalize; result populates Row.Model + Row.FinishReason- 5 token totals + Anthropic cache split, and bumps 5 new cumulative
atomic counters (
total_prompt_tokens,total_completion_tokens,total_cached_tokens,total_cache_creation_tokens,total_reasoning_tokens) surfaced on/healthz. SQLite gains 3 nullable INTEGER columns via idempotent ALTER TABLE:cached_tokens,cache_creation_tokens,reasoning_tokens. INSERT extended from 25 to 28 columns;selectColumns+scanRowextended in lockstep. Closes the gap where Row.Model / Row.PromptTokens / Row.CompletionTokens were declared but never populated — Dashboard TOP MODEL rendered—permanently and Overview MODEL BEHAVIOR carried no token data despite the upstream returning usage on every trace. Documented out-of-scope: Anthropic Messages SSE + Gemini:streamGenerateContentstreaming bodies (contract names body paths only; event-path coverage would need a PHILOSOPHY §1 amendment).
- 5 token totals + Anthropic cache split, and bumps 5 new cumulative
atomic counters (
- T2 — Plugin Phase A.1 wiring (2026-05-30, commit
4500a7d):cmd/api-log/main.goconstructsplugin.NewRegistry()alongsidemediaExt; pathfilter registered + Init'd only whencfg.Plugins.PathFilter.Patternsis non-empty (startup INFO log prints active patterns — operator visibility per PHILOSOPHY §2);IterateBeforeRecordruns in finalize block immediately beforekeyHashcompute +writer.TrySend;shouldRecord=falseskips both TrySend and SQLite, cleans tmp files viatmpDir.RemoveTraceFiles(traceID). Plugin errors log WARN but do not by themselves drop the trace (PHILOSOPHY §3 fail-open).pluginReg.Close()runs inline afterstopWriter()in shutdown. Supersedes ROADMAP §4 "capture-time skip_paths" knob — the plugin carve-out makes the §2 boundary explicit. Known forward-looking follow-ups (non-blocking): nodrop_plugin_errorcounter on /healthz; nodefer recover()around plugin calls; no example yaml stanza indeploy/dev-stack/. - Phase K (2026-05-30, commit
67142f9): media extraction end-to-end. Newinternal/media/package walks parsed JSON bodies per protocol (chat / messages / responses / gemini); extracted images / audio / video / files write to<DataDir>/<YYYY-MM-DD>/<keyhash[:8]>/media/<trace_id>/<idx>.<ext>.body_b64explicitly NOT touched (operator clarification: unparseable JSON fallback is not an attachment). Newinternal/runtime/package persists operator-toggleable runtime overrides at<DataDir>/runtime_overrides.json(load order: hardcoded default → YAML → env → file →PUT /api/config/media). NewConfig.Media.SaveAttachments(defaulttrue).internal/writer/writer.gogains optionalmediaExt+mediaEnabledparams onwriter.New; finalize callsmediaExt.Extractwhen the atomic flag readstrueafter the JSONL write succeeds. SQLite gainsmedia_count INTEGER DEFAULT 0column (idempotent ALTER TABLE catching "duplicate column" specifically).Counters.total_media_filescumulative atomic surfaced on/healthz. New read API:GET /api/media/{trace_id}/{idx}streams the file with Content-Type from extension;GET /api/config/mediareturns{save_attachments, source};PUT /api/config/mediaflips the atomic + atomic-renames the override file.internal/exporterbundles each row's media directory atmedia/<trace_id>/<filename>inside the zip.agent/CLAUDE.md+agent/jq-cheatsheet.mdtemplates updated to mention the media/ layout. - Initial design documents (PHILOSOPHY, ARCHITECTURE, README) — six review rounds.
- Verification experiments: X-Forwarded-For Go test, session-inference Python harness with synthetic + real-API scenarios.
- Project scaffold: Go module, package layout, CI workflow, hygiene files.
- M1: forwarding proxy + body capture pipeline. Bytes flow through to upstream; tmp files capture both directions; X-Forwarded-For suppressed via
Header[name] = nil. - M2: finalize parser + JSONL writer. Every completed trace lands at
data/<UTC-date>/<key_hash[:8]>.jsonlmatching the ARCHITECTURE § 3 schema. SSE parser handles all three shapes (data-only Chat / event-named Responses / event-named Anthropic) with[DONE]/response.completed/message_stopterminator recognition. JSON unmarshal for non-streaming bodies;body_b64 + parse_errorfallback for non-JSON / parse failures. Content-Encoding decompression forgzip(stdlib);br/zstdgraceful fallback. Daily rotation with background gzip workers. Writer goroutine + lossyTrySendchannel. - M3: SQLite mirror + session inference. New
internal/sessionbuilds per-protocol session prefixes (Chat / Anthropic with__apilog_system__virtual turn / Responses withinputnormalization). Newinternal/store/sqliteuses modernc.org/sqlite (pure Go, no cgo) with WAL + NORMAL pragmas. Writer goroutine wraps JSONL append + SQLite INSERT + session inference IN-clause + UPDATE in a single transaction per trace (one fsync).sync.Poolfor capture chunks reduces GC pressure under sustained load (sized for the 2000-user-team throughput target). - M4: read API on a second listener. New
internal/apimounts four endpoints:GET /healthz(status + uptime + atomic counters frominternal/counters),GET /api/traces(SQLite-backed list withsince/until/status/model/key_hash/session_root_idfilters, opaque base64(ts_start_ms, id)cursor pagination, default limit 100 cap 500),GET /api/traces/:id(SQLite row + JSONL seek byjsonl_offset— handles.jsonland.jsonl.gztransparently since gzip preserves uncompressed offsets),GET /(JSON pointer to the separate viewer project). Newinternal/adminmanages thedata/admin_tokenfile (256-bit hex, 0600 perms, regenerates if deleted) with constant-time bearer comparison./api/traces/:id/replayreturns 501 placeholder pending M5. - M5: per-event
t_delta_mscapture +/replayendpoint. Drainer records(file_offset, wire_arrival_time)per chunk; SSE parser tracks each event's first-byte offset; finalize zips the two viacapture.LookupChunkTime(binary search). Encoded SSE (Content-Encoding != identity) skips timing capture — events gett_delta_ms: nullper the §3 sentinel./replayendpoint reconstructs SSE frames from the parsedevents[]and emits at original pacing usingsleep = (t_delta_ms gap) / speed; supports?speed=N(0..100],?nodelay=1. Null t_delta_ms in a gap means immediate emit. Returns 400 not_streaming for non-SSE traces. - M6: production hardening. Stream-idle watchdog (
internal/proxy.StreamWatchdog) cancels the request context if no response byte arrives forstream_idle_seconds(default 600s); fired traces are markeddisconnected: true.capture.Sink.OnBytecallback pulses the watchdog without coupling proxy code to the watchdog itself. Drainer-join timeout now marks the affected directiontruncated_*: true(instead of just logging) so the JSONL line records the loss andtruncated_*_totalcounters increment. Graceful shutdown ordering fixed: proxy.Shutdown → api.Shutdown → stopWriter (drains channel + waits gzip workers) → store.Close (explicit, no defer LIFO games) so the writer never sees a closed *sql.DB during its final flush. Chaos test verifies 500 concurrent producers against a wedged writer never block (max TrySend = 100ms on any goroutine). - M7: shippable artifact + demo. Multi-stage
Dockerfileproduces a static distroless image; container runs as nonroot UID 65532 and pre-creates/dataso the mount inherits ownership. Newtools/mockupis a tiny LLM gateway that speaks all three protocols (non-streaming Chat, streaming Chat / Anthropic Messages / OpenAI Responses) — used to drive integration tests without an upstream key.deploy/dev-stack/docker-compose.ymlbrings up api-log + mockup against a bind-mounted data dir;deploy/demo/docker-compose.ymlis the operator-facing layout (api-log fronting sub2api).tests/integration/run.shexercises 16 assertions through the dev-stack: forwarding, JSONL persistence, SQLite list/cursor/detail,/healthzcounters, cross-key isolation, session inference fires across two chat completions of the same key, replay pacing honorsspeed/nodelay, and the read API rejects unauth / wrong-token requests. GH Actions: areleasejob builds + pushes the image toghcr.ioonv*tags after the unit + integration suites pass.