Skip to content

2nd1st/api-log

English | 中文

api-log: LLM proxy logging and API trace recorder

api-log — tcpdump for LLM gateways

CI Release Go version License: MIT

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.

Status

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.

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: 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:/data
docker compose up -d

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.

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 -version

Or build from source (requires Go 1.22+):

go install github.com/2nd1st/api-log/cmd/api-log@latest
api-log -version

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
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.

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.

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.

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:

  1. 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 or events[] for streams, timestamps, sizes, truncation flags).
  2. 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.

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) — request messages[], response choices[], both streamed (data: {...}\n\n chunks) and non-streamed.
  • OpenAI Responses (/v1/responses) — request input[], response output[], including SSE events such as response.output_item.added for tool-call extraction.
  • Anthropic Messages (/v1/messages) — request messages[] + system, response content[], SSE events message_start / content_block_delta / message_delta / message_stop.
  • SSE streams — each event: / data: pair is split into one entry in resp.events[] with t_delta_ms recorded against the response start. Non-SSE responses land as parsed JSON in resp.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.

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 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.

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, supports since / until / status (exact or 2xx / 4xx / 5xx bucket) / 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 by session_root_id. Row-level aggregation only; not a tree walk.
  • GET /api/export — streams a zip of matching JSONL lines + bundled agent/CLAUDE.md for offline / AI-assisted analysis.
  • GET/PUT /api/config/plugins (+ PUT /api/config/plugins/:id, DELETE /api/config/plugins, GET /api/plugins/types) — hot-reload of text-replace / text-append / path-filter plugins. YAML remains the declarative truth; runtime overrides persist to data/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.

Bundled viewer

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.

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 700 or 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_token is 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://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.sh

The 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.

Roadmap

  • v0 — capture path (parse + JSONL write + SQLite mirror), session inference, minimal read API.
  • v0.1 viewerapi-log-viewer — multi-instance aggregation, session-tree visualization, tool-call rendering, SSE replay.
  • v0.1 pluginstext-replace / text-append / path-filter with hot-reload via PUT /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.

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 taxonomy 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.

About

Transparent LLM gateway trace recorder · sub2api / CLIProxyAPI / new-api 的 LLM 网关流量录制器

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors