Rewrite the README/ARCHITECTURE/RELEASING viewer sections around viewer.local_path, drop the viewer version+SHA bump procedure, and remove viewer-cache from the operations/retention/glossary docs. The example YAML and CHANGELOG now reflect the removed fetch knobs.
English | 中文
api-log: LLM proxy logging and API trace recorder
api-log is a transparent HTTP recording proxy for LLM gateway observability. It sits in front of sub2api, CLIProxyAPI (CPA), new-api, or any OpenAI-compatible gateway and captures OpenAI Chat, Anthropic Messages, Responses, and Gemini traffic. Each completed request/response is written as append-only JSONL with a SQLite index for local search, replay, and per-key analysis.
api-log 是一个面向 LLM 网关可观测性的透明 HTTP 录制 proxy。它部署在 sub2api、CLIProxyAPI(CPA)、new-api 等 OpenAI 兼容网关前面,原样转发流量,捕获 OpenAI Chat、Anthropic Messages、Responses、Gemini 等协议。每条完成的请求/响应 trace 以 append-only JSONL 落盘,并构建 SQLite 索引,用于本地检索、重放和按 key 排查问题。
The forwarding goroutine never inspects bodies. JSON unmarshalling, SSE event splitting, and session inference happen in a finalize step after the response has been delivered to the client. Token accounting, evaluation pipelines, and any semantic interpretation are downstream of this project's scope.
Companion UI: api-log-viewer — Svelte 5 SPA frontend for api-log traces; run the published git.o0kam1.dev/humanlesscode/api-log-viewer:latest image beside the backend (it serves the SPA and proxies the read API on one origin), point the backend's /viewer/ route at a pre-built dist/ directory, or host the static bundle behind Caddy/nginx next to the read API.
Status
Preparing the v0.1.0 tag. The capture path, read API, and viewer (separate repo) are shipped and running against live traffic. The HTTP read API contract is stable; pre-tag commits may still rebase.
See ARCHITECTURE.md for the on-disk format and read API contract, and ROADMAP.md for what's queued.
vs alternatives
| Tool | What it is | Why api-log is different |
|---|---|---|
| Helicone | Gateway / observability stack | api-log is only a transparent recorder; no routing, billing, auth, or hosted service. |
| Langfuse | App-level LLM tracing platform | api-log captures HTTP traffic at the gateway boundary without SDK instrumentation. |
| Phoenix | Evaluation / tracing / observability toolkit | api-log records raw gateway traces first; eval pipelines are downstream. |
| LangSmith | LangChain tracing/eval platform | api-log is framework-agnostic and stores JSONL + SQLite locally. |
| mitmproxy | General interactive proxy | api-log understands LLM JSON/SSE envelopes and writes structured traces. |
api-log is not a gateway. It does not authenticate, route, retry, rate-limit, cache, or rewrite. Those live in the upstream gateway.
Quick start
Docker Compose
# docker-compose.yml — added alongside your existing gateway
services:
gateway: # CPA / sub2api / new-api / your stack
# ... existing config ...
expose: ["7860"] # move 7860 from "ports" to "expose"
api-log:
image: git.o0kam1.dev/humanlesscode/api-log:latest
ports:
- "7861:7861" # proxy listener (clients connect here)
expose: ["7862"] # read API — reached by the viewer
environment:
APILOG_PROXY_UPSTREAM: http://gateway:7860
volumes:
- ./api-log-data:/data
viewer: # api-log-viewer: SPA + same-origin /api proxy
image: git.o0kam1.dev/humanlesscode/api-log-viewer:latest
ports:
- "8080:80"
environment:
APILOG_BACKEND: http://api-log:7862
Only the proxy listener and the viewer are published; the read API stays on
the compose network. Publish 7862:7862 as well if you would rather curl the
read API directly. api-log runs as UID 65532 (distroless nonroot) and a
bind mount does not inherit /data's ownership from the image, so create the
host directory with that UID before starting the stack:
mkdir -p api-log-data && sudo chown 65532:65532 api-log-data
docker compose up -d
Open http://localhost:8080 and paste the admin bearer token into the
viewer's auth modal. The backend prints it once on first start
(docker compose logs api-log) and writes it to ./api-log-data/admin_token.
Three reference stacks live under deploy/ — dev-stack/ (api-log + a mock LLM gateway, no real upstream needed), demo/ (api-log in front of sub2api), and bench/ (api-log alone, upstream URL via env). For a 5-minute try-it, run deploy/dev-stack/; that's what tests/integration/run.sh drives.
Native install
For sub2api / CLIProxyAPI / new-api operators running on a homelab box or a small VPS, install the binary directly.
Build from source (requires Go 1.22+):
go install git.o0kam1.dev/HumanlessCode/api-log/cmd/api-log@main
api-log -version
@main builds the current development branch. Use it until a tag carrying the Gitea module path is published; after that @latest resolves to the newest tagged release. Prebuilt archives, when a release ships them, are attached to the Gitea release.
The full setup — service user, env file, data directory, systemd unit with sensible hardening defaults — lives at deploy/systemd/. Caddy + nginx samples for putting TLS in front of the read API and the viewer SPA on the same origin live at deploy/reverse-proxy/.
Point clients at api-log
Change the client base_url from the gateway port (:7860) to the api-log proxy listener (:7861). No other client changes.
Verify a captured trace
# 1. Liveness — unauthenticated, k8s probe compatible (the viewer proxies /healthz)
curl -s http://localhost:8080/healthz | jq .
# 2. Send one request through the proxy (replace with a real client call)
curl -s http://localhost:7861/v1/messages \
-H "x-api-key: $UPSTREAM_KEY" \
-H "anthropic-version: 2023-06-01" \
-d '{"model":"claude-sonnet-4-6","max_tokens":32,"messages":[{"role":"user","content":"hi"}]}'
# 3. Read the auto-generated admin bearer
TOKEN=$(cat ./api-log-data/admin_token)
# 4. List recent traces — the viewer proxies /api/* to the backend
curl -s -H "Authorization: Bearer $TOKEN" \
'http://localhost:8080/api/traces?limit=5' | jq '.traces[] | {id, path, status, request_model}'
The admin bearer is generated on first run and written to
./api-log-data/admin_token in the compose layout above (or data/admin_token
for a native install). Delete the file and restart to rotate.
Configuration
api-log resolves config in three layers, highest priority last: built-in defaults, then the YAML file passed via -config, then APILOG_* environment variables. Env vars always win.
The full annotated reference — every YAML key, its default, and its env-var override — lives in api-log.example.yaml at the repo root. Copy that file, uncomment only the keys you want to change, and pass it with api-log -config /etc/api-log/api-log.yaml. The canonical struct is internal/config/config.go.
The env vars adopters most often override:
| Env var | One-line semantics |
|---|---|
APILOG_PROXY_LISTEN |
address:port for the recording proxy listener clients connect to. Default :7861. |
APILOG_PROXY_UPSTREAM |
URL of the upstream gateway api-log forwards to. Default http://localhost:7860. |
APILOG_STORAGE_DATA_DIR |
Root for JSONL files, the SQLite index, admin_token, and runtime_overrides.json. Default ./data. |
APILOG_LOGGING_LEVEL |
slog level (debug | info | warn | error). Default info. |
APILOG_API_EXPORT_BYTE_HARDCAP |
Byte ceiling on /api/export before ?bytes_all=1 is required. 0 (or unset) keeps the built-in 2 GiB default; positive overrides; negative disables the cap. |
How recording works
Traffic path
client(s) → api-log → CPA / sub2api / new-api / any OpenAI-compatible gateway → upstream
↓
data/<date>/<key_hash>.jsonl (append-only, source of truth)
data/index.sqlite (derived index, rebuildable)
The proxy listener accepts plain HTTP. Forwarding uses httputil.ReverseProxy with a custom Transport that tees request and response bodies into per-trace temp files. When the response finishes (success, client disconnect, or upstream error), the finalize step parses the bodies into the JSONL line shape, writes one line to today's JSONL file, and inserts the indexed columns into SQLite. See ARCHITECTURE § 7 for the full write path.
Storage model
Two layers, in strict order of authority:
data/<date>/<key_hash>.jsonl— one line per completed trace. Append-only daily file per client key. Each line carries the full HTTP transaction (request headers + body, response headers + body orevents[]for streams, timestamps, sizes, truncation flags).data/index.sqlite— derived columns the read API needs (status,request_model/response_model, token counts, session linkage,jsonl_path+jsonl_offset). Deletable; rebuilt from layer 1 in seconds. WAL-mode, conn pool of 10 (v0.1.1).
When the JSONL file and SQLite disagree, JSONL wins.
Retention (v0.1.1)
Off by default. Toggle via PUT /api/config/retention (persisted to runtime_overrides.json, survives restart):
curl -X PUT -H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"max_bytes":10000000000,"max_age_days":30}' \
http://localhost:7862/api/config/retention
Both knobs 0 keeps the engine running (inventory + /healthz.storage reporting) but never deletes. See docs/retention.md for thresholds, eviction order, and the lease semantics that keep retention from racing with the writer.
JSONL trace shape
{
"id": "01HX7K8MS...",
"ts_start": "2026-05-27T10:23:45.123Z",
"ts_end": "2026-05-27T10:23:46.357Z",
"client": "172.17.0.5:54321",
"method": "POST",
"path": "/v1/messages",
"upstream": "http://gateway:7860",
"status": 200,
"req": {
"headers": {"x-api-key": "sk-***", "anthropic-version": "2023-06-01"},
"body": {"model": "claude-sonnet-4-6", "messages": [...], "stream": true}
},
"resp": {
"headers": {"content-type": "text/event-stream", "x-request-id": "..."},
"events": [
{"event": "message_start", "data": {"message": {"id": "msg_...", ...}}, "t_delta_ms": 12},
{"event": "content_block_delta", "data": {"delta": {"text": "Hello"}, ...}, "t_delta_ms": 234},
{"event": "message_delta", "data": {"usage": {"output_tokens": 8}}, "t_delta_ms": 511},
{"event": "message_stop", "data": {}, "t_delta_ms": 514}
],
"stream_done": true
},
"disconnected": false,
"truncated_req": false,
"truncated_resp": false
}
Header values shown above are redacted for documentation; the on-disk JSONL contains the raw bytes the client sent. See Security below.
The full field reference lives in ARCHITECTURE § 3.
Protocol coverage
api-log forwards every HTTP request byte-faithfully. The finalize step parses bodies for a known set of LLM API shapes; unrecognized paths still record headers + raw body but skip the structured fields.
- OpenAI Chat Completions (
/v1/chat/completions) — requestmessages[], responsechoices[], both streamed (data: {...}\n\nchunks) and non-streamed. - OpenAI Responses (
/v1/responses) — requestinput[], responseoutput[], including SSE events such asresponse.output_item.addedfor tool-call extraction. - Anthropic Messages (
/v1/messages) — requestmessages[]+system, responsecontent[], SSE eventsmessage_start/content_block_delta/message_delta/message_stop. - SSE streams — each
event:/data:pair is split into one entry inresp.events[]witht_delta_msrecorded against the response start. Non-SSE responses land as parsed JSON inresp.body. - WebSocket (
Connection: Upgrade+Upgrade: websocket, any path) — the upgrade is tunneled transparently and the RFC 6455 frame stream from both directions is decoded intows.frames[](dir,opcode,fin,len,t_delta_ms, and a JSON/text/base64 payload), with awssummary carrying per-direction byte/frame counts, close code/reason, and the negotiated subprotocol. Handshake headers stay inreq/resp. See ARCHITECTURE.md § 3.5 and § 10.8.
Tool calls, reasoning blocks, and any other content these protocols carry live verbatim inside req.body and resp.body / resp.events[]. They are not promoted to top-level fields and api-log does not interpret them.
Query examples
The examples below are reference snippets, not benchmarked workloads. Run them against your own data.
jq over JSONL
Tool-call frequency across captured Responses-API traces:
zcat data/2026-*/*.jsonl{,.gz} 2>/dev/null \
| jq -r '.resp.events[]? | select(.event=="response.output_item.added")
| .data.item | select(.type=="function_call") | .name' \
| sort | uniq -c | sort -rn
Streams that were cut short:
jq 'select(.disconnected==true or .resp.stream_done==false)
| {id, path, status, ts_start}' \
data/2026-05-27/*.jsonl
sqlite3 over the index
Recent failing traces by request model and path:
sqlite3 data/index.sqlite \
"SELECT ts_start, request_model, path, status FROM traces
WHERE status >= 400
ORDER BY ts_start DESC LIMIT 20"
All traces in one session, in order:
sqlite3 data/index.sqlite \
"SELECT id, jsonl_path, jsonl_offset FROM traces
WHERE session_root_id='01HX7K...' ORDER BY ts_start"
The jsonl_path + jsonl_offset pair lets a consumer seek directly to the line in the JSONL file. AI agents doing batch analysis can bypass the read API and read JSONL files off disk.
Replay a recorded SSE stream
curl -N -H "Authorization: Bearer $TOKEN" \
"http://localhost:7862/api/traces/01HX7K8MS.../replay?speed=2"
/api/traces/:id/replay re-emits the recorded SSE frames at original per-chunk pacing (or speed × faster). speed=2 halves the delays; nodelay=1 dumps every event back-to-back. The replay is to the API caller; it never re-contacts the upstream LLM. See ARCHITECTURE § 6.4 for the full semantics, including how reparsed traces handle missing t_delta_ms.
Read API
The read API listens on a separate port (:7862 by default) from the proxy. Thirteen routes total; the full surface lives in ARCHITECTURE § 6. Highlights:
GET /healthz— unauthenticated; exposes in-memory drop / overflow counters so operators can detect capture degradation without grepping logs.GET /api/traces— list, SQLite-backed, supportssince/until/status(exact or2xx/4xx/5xxbucket) /request_model/response_model/path(trailing*for prefix) /key_hash/session_root_id/project/limit/ cursor pagination.GET /api/traces/:id— detail; returns{row, trace}so callers get both the SQLite row and the parsed JSONL line in one round trip.GET /api/traces/:id/replay— pacing-preserving SSE replay.GET /api/sessions— session summaries grouped bysession_root_id. Row-level aggregation only; not a tree walk.GET /api/export— streams a zip of matching JSONL lines + bundledagent/CLAUDE.mdfor offline / AI-assisted analysis.
All read endpoints except /healthz require Authorization: Bearer <data/admin_token> and respond with Cache-Control: no-store.
api-log ships no embedded HTML viewer. GET / returns a JSON pointer to the separate api-log-viewer project; the binary contains zero HTML.
Hosted viewer
The backend can serve a pre-built viewer bundle at /viewer/. It never downloads viewer assets: set viewer.local_path (or APILOG_VIEWER_LOCAL_PATH) to a dist/ directory that already contains index.html. Build api-log-viewer, copy its dist/ output into the deployment image or a mounted volume, or run the viewer image instead and skip this route.
If local_path is unset or has no index.html, /viewer/ returns 503 (viewer_unavailable) and the binary logs a warning; the rest of the API keeps serving.
| Knob | Default | Effect |
|---|---|---|
APILOG_VIEWER_ENABLED |
true | Set false to skip hosting; /viewer/ returns 503 disabled. |
APILOG_VIEWER_LOCAL_PATH |
(unset) | Absolute path to a pre-built dist/ directory. Required for /viewer/ to serve. |
APILOG_VIEWER_PUBLIC_PATH |
/viewer |
URL prefix. |
Viewer releases are independent of backend releases — replace the dist/ tree (and restart) to roll the viewer forward. The route is unauthenticated; the read API it calls remains bearer-gated.
Security
Bearer tokens land on disk unredacted. The JSONL files contain the raw Authorization / x-api-key headers exactly as the client sent them. api-log does not redact anything from the capture path. Treat the data/ directory the way you would treat ~/.ssh/ or a file holding production API keys:
- Mount
data/to a path with restrictive filesystem permissions (chmod 700or tighter). - Do not expose the proxy listener (
:7861) or the read API listener (:7862) to untrusted networks without a reverse proxy enforcing transport security and access control. - The auto-generated admin bearer at
data/admin_tokenis the only credential gating the read API. Rotate it by deleting the file and restarting. - Plain HTTP between containers on the same host is the primary supported topology. If you need TLS or cross-host routing, terminate it with whatever reverse proxy you already run; api-log itself listens HTTP only.
Redaction is a deliberate non-goal of the capture path. If you need redacted traces, run a sidecar over the JSONL files; the on-disk format is documented and stable.
See SECURITY.md for the threat model and disclosure process.
Development
git clone https://git.o0kam1.dev/HumanlessCode/api-log.git
cd api-log
# unit + integration tests (race detector on)
go test ./...
# lint (golangci-lint v2)
golangci-lint run
# build
go build -o bin/api-log ./cmd/api-log
# the dev-stack integration harness
./tests/integration/run.sh
The project ships 23 Go packages and race-clean tests. Run the same four checks locally before opening a PR — see CONTRIBUTING.md.
Roadmap
- v0 — capture path (parse + JSONL write + SQLite mirror), session inference, minimal read API.
- v0.1 viewer — api-log-viewer — multi-instance aggregation, session-tree visualization, tool-call rendering, SSE replay.
- v0.2 — optional per-gateway bridge adapters (separate projects) — join external data (CPA's Redis usage queue, new-api's MySQL log table) into api-log traces by
key_hash. The core proxy stays gateway-agnostic.
See ROADMAP.md for the full list including the v0.1.0-deferred section.
Acknowledgements
Design influence
- tcpdump / pcap — append-only capture with deferred interpretation
- CLIProxyAPI (CPA) — single-binary, single-config aesthetic
- Claude Code / Codex CLI — local JSONL session files as a usable format
- Langfuse — the LLM-observability surface, against which we deliberately differ on the capture-vs-instrument axis
Live-traffic iteration partner
- sub2api — primary upstream gateway used to validate the capture path against live traffic; the path-filter pattern set and the client-identification heuristic were tuned against its real-traffic shapes.
Development assistance
This codebase and its documentation were developed with Claude Opus 4.8 (Anthropic) as the primary pair-programmer for both code and prose, and GPT-5.5 via Codex CLI as a research and review assistant — adversarial pre-release review, README structural analysis against reference OSS projects, and fact-check cross-checks. The choices on what to keep, cut, or amend are the human author's; AI assistance is named here for transparency, not as authorship.
License
MIT — © 2026 Leo Yun.
