English | 中文
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; serve it from the built-in /viewer/ route or behind Caddy/nginx next to the read API.
Preparing the v0.1.0 tag. The capture path, read API, viewer (separate repo), and plugin system (Phase A observer + Phase B/C mutators) 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.
| 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.
# 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: ghcr.io/2nd1st/api-log:latest
ports:
- "7861:7861" # proxy listener (clients connect here)
- "7862:7862" # read API
environment:
APILOG_PROXY_UPSTREAM: http://gateway:7860
volumes:
- ./api-log-data:/datadocker compose up -dThree 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.
For sub2api / CLIProxyAPI / new-api operators running on a homelab box or a small VPS, install the binary directly.
Download a release binary (no Go toolchain required) — pick the archive for your OS + arch from the latest release. Linux / darwin / windows × amd64 / arm64 are pre-built (windows/arm64 deferred). Example for linux/amd64:
VERSION=v0.1.1
curl -L https://github.com/2nd1st/api-log/releases/download/${VERSION}/api-log_${VERSION}_linux_amd64.tar.gz | tar xz
./api-log -versionOr build from source (requires Go 1.22+):
go install github.com/2nd1st/api-log/cmd/api-log@latest
api-log -versionThe 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/.
Change the client base_url from the gateway port (:7860) to the api-log proxy listener (:7861). No other client changes.
# 1. Liveness — unauthenticated, k8s probe compatible
curl -s http://localhost:7862/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 from the read API
curl -s -H "Authorization: Bearer $TOKEN" \
'http://localhost:7862/api/traces?limit=5' | jq '.traces[] | {id, path, status, model}'The admin bearer is generated on first run and written to data/admin_token. Delete the file and restart to rotate.
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. |
Plugin pattern lists (plugins.path_filter.patterns, plugins.capture_filter.patterns) and the mutator plugin instances (text-replace, text-append) have no env override — the first pair is YAML-only and the mutators live in runtime_overrides.json, managed via PUT /api/config/plugins.
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.
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, 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.
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/retentionBoth 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.
{
"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.
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. - After-mutation capture for streams — when plugins mutate a streaming response (Phase B/C), api-log records the bytes the client actually received, not the upstream pre-mutation stream.
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.
The examples below are reference snippets, not benchmarked workloads. Run them against your own data.
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 -rnStreams that were cut short:
jq 'select(.disconnected==true or .resp.stream_done==false)
| {id, path, status, ts_start}' \
data/2026-05-27/*.jsonlRecent failing traces by model and path:
sqlite3 data/index.sqlite \
"SELECT ts_start, 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.
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.
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) /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.GET/PUT /api/config/plugins(+PUT /api/config/plugins/:id,DELETE /api/config/plugins,GET /api/plugins/types) — hot-reload oftext-replace/text-append/path-filterplugins. YAML remains the declarative truth; runtime overrides persist todata/runtime_overrides.json. Off by default.
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.
api-log ships hosted viewer at /viewer/ by default. On first request, the backend fetches dist.zip from a pinned release of api-log-viewer, verifies the asset's SHA-256 against a constant baked into the backend binary, and extracts the bundle to a cache under data/viewer-cache/. Subsequent requests serve from the cache.
A SHA-256 mismatch is fatal to the route — /viewer/ returns 503 and the binary logs the mismatch. The backend never serves an unverified asset.
Override knobs (env or YAML):
| Knob | Default | Effect |
|---|---|---|
APILOG_VIEWER_ENABLED |
true | Set false to skip hosting; /viewer/ returns 503 disabled. |
APILOG_VIEWER_REPO |
2nd1st/api-log-viewer |
Point at any GitHub repo. |
APILOG_VIEWER_VERSION |
pinned to the backend release | Tag to fetch. |
APILOG_VIEWER_SHA256 |
pinned to the backend release | Must be supplied when overriding REPO or VERSION; mismatch returns 503. |
APILOG_VIEWER_LOCAL_PATH |
(unset) | Absolute path to a dist/ directory. Skips fetch + verify entirely; useful for offline / air-gapped deployments and for local viewer development. |
APILOG_VIEWER_CACHE_DIR |
<data_dir>/viewer-cache |
Cache root. |
APILOG_VIEWER_PUBLIC_PATH |
/viewer |
URL prefix. |
The backend never auto-updates the cached viewer — fetch happens once per (repo, version, sha) tuple. To roll forward, bump the backend release (which bumps the pinned constants) or override APILOG_VIEWER_VERSION + APILOG_VIEWER_SHA256 explicitly.
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.
git clone https://github.com/2nd1st/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.shThe project ships 23 Go packages, race-clean tests, and CI lint via golangci-lint v2. CI runs on every push and PR; see .github/workflows/ci.yml.
- 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.1 plugins —
text-replace/text-append/path-filterwith hot-reload viaPUT /api/config/plugins. Off by default. - 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.
- 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
- sub2api — primary upstream gateway used to validate the capture path against live traffic; the path-filter pattern set and the client-identification taxonomy were tuned against its real-traffic shapes.
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.
MIT — © 2026 Leo Yun.
