@x12i/xtivix
v1.2.0
Published
Collector, normalizer, store and read API for provider-side AI logs (OpenRouter, Cloudflare AI Gateway, AWS Bedrock) with a Langfuse sink
Maintainers
Readme
xtivix
xtivix collects provider-side AI call logs from OpenRouter (push), Cloudflare AI Gateway (pull) and
AWS Bedrock (push through a Lambda forwarder), normalizes them into one canonical record, stores them in
MongoDB, forwards them to Langfuse as OpenTelemetry GenAI spans and serves them through an internal read API
and a developer workbench. Langfuse remains the place for dashboards and cost. The live store keeps at most
30 days and about 180,000–200,000 records; older overflow is sealed into a @x12i/pack-spool archive first.
OpenRouter Broadcast ──push──┐
Bedrock → S3 → Lambda ─push──┼─► ingest ─► inbox ─► processor ─► records ─► outbox ─► dispatcher ─► Langfuse
Cloudflare logs API ◄─pull───┘ (202 after (parse, validate, (MongoDB) (OTLP/HTTP)
Custom apps ──canonical push─┘ durable write) redact, merge) │
└──► read API (/v1/records, /v1/pairs)Quick start (Docker)
Requires Docker with Compose v2.
npm install
npm run gen:env # writes deploy/.env with random secrets and prints the Langfuse login + admin key
npm run stack:up # MongoDB replica set, xtivix (ingest/api/worker) and the bundled Langfuse stack
npm run seed # posts sample payloads for every provider- Workbench: http://127.0.0.1:4180 (loopback only, no sign-in). Open that address, not
localhost, so Windows does not try IPv6. - Langfuse UI: http://localhost:3000 (login printed by
gen:env). Traces appear under projectxtivix. - Ingest: http://localhost:2222, read API: http://localhost:8081 (bearer key required), worker metrics: http://127.0.0.1:8082/metrics.
npm run e2eruns the live end-to-end checks against the stack.- Already have Langfuse (or Langfuse Cloud)? Set
LANGFUSE_HOST,LANGFUSE_PUBLIC_KEYandLANGFUSE_SECRET_KEYindeploy/.envand usenpm run stack:up:external. - For production Langfuse on Kubernetes use the official Helm chart (
langfuse/langfuse); pointLANGFUSE_HOSTat it.
Message text is stored. LANGFUSE_INCLUDE_CONTENT=true in deploy/.env also forwards that text to Langfuse.
The replica set advertises container host names, so to connect from the host with mongosh or Compass use
mongodb://127.0.0.1:27017/?directConnection=true.
Install from npm
The server ships as @x12i/xtivix with an xtivix command
(Node.js 22+). It needs a MongoDB replica set and a config file. The package includes an annotated
xtivix.example.yaml to start from.
npm install -g @x12i/xtivix
cp "$(npm root -g)/@x12i/xtivix/xtivix.example.yaml" xtivix.yaml
xtivix --config=xtivix.yaml --role=all # xtivix --help lists all optionsThe building blocks are published separately, for writing your own adapters and sinks or reusing the schema:
| Package | What it is |
| --- | --- |
| @x12i/xtivix-schema | Canonical record JSON Schema, TypeScript types, validateRecord, metadata rules, model aliases |
| @x12i/xtivix-core | Adapter and Sink contracts plus normalization, redaction, merge and id helpers |
| @x12i/xtivix-conformance | Adapter conformance suite for vitest (vitest is a peer dependency) |
| @x12i/xtivix-adapter-openrouter, -cloudflare, -bedrock, -canonical | The built-in adapters |
| @x12i/xtivix-sink-langfuse | The Langfuse OTLP sink |
All packages are ESM only, include type declarations and are released together under one version.
Local development
Node.js 22+. The repo uses npm workspaces.
npm install
npm run typecheck
npm test # unit + acceptance (acceptance boots an embedded MongoDB replica set)
npm run build # builds every package: libraries to dist/ (JS + .d.ts), the server bundle, the forwarder
npm run e2e -- --local # built server + embedded MongoDB + mock Langfuse, using deploy/xtivix.yaml
npm run dev # runs the server from source; needs XTIVIX_CONFIG and a MongoDB replica set
npm run release:check # builds, packs every package and smoke-tests the tarballs in a clean projectLibrary package.json files export dist/ to consumers plus a custom xtivix-source condition that points at
src/index.ts. Inside the repo, TypeScript (customConditions), vitest, tsx (--conditions=xtivix-source) and
the tsup builds all enable that condition, so development and tests run against source without a build step.
On Windows the embedded MongoDB needs the Microsoft Visual C++ Redistributable (x64). Without it mongod
exits with code 3221225781.
Layout
| Path | What it is |
| --- | --- |
| packages/schema | Canonical record JSON Schema v1.0, generated types, metadata rules, model aliases |
| packages/core | Adapter and sink contracts, ids, merge, redaction, content helpers, OTLP helpers |
| packages/conformance | Shared adapter test suite: schema validity, purity, REC-6, golden files |
| packages/adapters/* | openrouter, cloudflare, bedrock, canonical |
| packages/sinks/langfuse | Langfuse OTLP sink |
| apps/server | Fastify server: ingest, processor, dispatcher, poller, housekeeping, read and admin API |
| apps/ui | Developer workbench, served by the API process on 127.0.0.1:4180 |
| apps/bedrock-forwarder | Lambda handler and Terraform example |
| deploy/ | Dockerfile, Compose files, xtivix.yaml, .env.example |
| tests/acceptance | AC-1 … AC-19 |
Running
One image, four roles: --role=ingest|api|worker|all (or XTIVIX_ROLE). Run ingest and API as separate
deployments so they scale and fail independently. Workers coordinate through leases in MongoDB, so any number
can run.
| Variable | Meaning |
| --- | --- |
| XTIVIX_CONFIG | Path to xtivix.yaml (default ./xtivix.yaml) |
| PORT, HOST | API listen address (default 2222, 0.0.0.0) |
| XTIVIX_UI_PORT, XTIVIX_UI_HOST | Workbench bind (default 4180, 127.0.0.1). The workbench serves records without a key, so keep the host on loopback. Docker publishes it as 127.0.0.1:4180. |
| XTIVIX_UI_ACCESS | Workbench address rules. On by default. Set to off in deploy/.env to serve every caller with no address check. |
| LOG_LEVEL | pino level (default info) |
| XTIVIX_MODEL_ALIASES | Model alias file (default: the one bundled with the package) |
MongoDB must be a replica set: records and their outbox rows are written in one transaction.
The workbench reads the TCP peer and ignores X-Forwarded-For. Loopback and every address on this machine are the station: they see every record and the Access page (Settings, or the Access item in the header). Any other address sees nothing until the station adds a rule for it. A rule lists API key names (metadata.api_key_name), org ids, and agent ids. A record is visible when it matches any value in any of those lists. Marking a rule “Can manage access” lets that address see every record and edit the rules. Callers should send api_key_name in the provider metadata object, next to xtivix.execution. Set XTIVIX_UI_ACCESS=off when Docker Desktop or an SSH tunnel hides the real client address. The keyed API on port 2222 is unchanged.
Configuration
See deploy/xtivix.yaml. The file holds no secrets: ${secret:NAME} reads the environment
or /run/secrets/NAME, and ${env:NAME:-default} reads the environment. Changes are picked up on save (or
SIGHUP) without a restart. An invalid file is rejected and the running config kept.
API keys are stored as hmac-sha256 hashes made with security.key_pepper. npm run gen:key -- <id> prints a
new key and the hash to paste in.
Connecting providers
OpenRouter. In Settings, Observability, Broadcast, add a Webhook destination (the OpenTelemetry Collector destination works the same way). OpenRouter sends OTLP/HTTP JSON, which is what the endpoint expects:
- URL:
https://<ingest>/v1/ingest/src_or_prod(method POST or PUT) - Headers:
{"x-xtivix-secret": "<OPENROUTER_CALLBACK_SECRET>"}, or{"Authorization": "Bearer <key with ingest:write>"}
"Test Connection" sends an empty application/json body. xtivix checks the credentials, answers 200 and stores nothing, so the destination saves only when the secret is right. The webhook is served on the ingest port and on the workbench port. Point a public tunnel at ingest (2222). The workbench port serves records with no key. Send user and session_id for user_id and session_id. Caller metadata is the object on the provider log: OpenRouter request body metadata, OpenAI Responses body metadata, Bedrock requestMetadata, and Cloudflare body metadata or the cf-aig-metadata header. xtivix opens that object with openMetadata from @x12i/provider-metadata. It does not decode m.0 itself and does not expect a fixed set of keys. The opened value is a string map: numbers and booleans arrive as "2" and "true", and nested objects and arrays are not in it. @x12i/ai-dispatcher 2.3.1 writes that record through @x12i/provider-metadata 1.2.0 and trims it when it does not fit. A trimmed record still opens. Keys the dispatcher dropped are absent. A role stamp arrives as role, roleLayer, roleDigest, and profileChoice. Repeating vendor keys also get their shared name from METADATA_NORMALIZATION_MAP: openrouter.api_key_name is stored as api_key_name too, and likewise provider_slug, provider_name, entity_id, user_id, finish_reason, the unit prices, source, and session_id. An explicit key on the record wins. Sibling fields next to that object are not the record. No packed record means the call had no dispatcher metadata; plain scalars such as user_id still stay. OpenRouter's own trace.metadata.openrouter* fields stay on provider_metadata, and their shared names (api_key_name, provider_slug, ...) are added to metadata, so access rules by API key name match OpenRouter calls. The activity-log App name is the X-Title header, not the record. OpenRouter documents no redelivery, so keep ingest
available: a webhook outage loses data (open question 5).
Cloudflare AI Gateway. Set CLOUDFLARE_ENABLED=true, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_GATEWAY_ID and an API token
that can read AI Gateway logs. The poller pulls every interval_seconds, re-reads overlap_seconds to catch late
logs, fetches bodies when store_content is on and retries failed bodies up to 5 times. pull.backfill_from queues
a one-off backfill. Metadata is body metadata, or the cf-aig-metadata header when the body has none. xtivix opens that object with openMetadata.
AWS Bedrock. Enable model invocation logging to S3, then deploy
apps/bedrock-forwarder/terraform (npm run build first). The Lambda reads each
new log object, inlines bodies Bedrock offloaded to S3 (over 100 KB, only from the log bucket and prefix) and posts
gzipped batches to /v1/ingest/src_bedrock_prod. Failed events are retried twice and then land in an SQS DLQ.
requestMetadata is that same object. xtivix opens it with openMetadata.
Custom apps. POST canonical records ({ "records": [...] }) to /v1/ingest/canonical?source=src_custom.
Every push answers 202 only after the payload is durably stored, with per-record counts:
{ "inbox_id", "accepted", "duplicate", "rejected", "rejections" }. Posting the same record again is safe.
Read API
Bearer API keys with scopes: records:read, records:read:content, records:read:raw, records:delete,
ingest:write, annotate:write, admin. Errors are application/problem+json with a stable code.
| Call | Notes |
| --- | --- |
| GET /v1/records | Filters: from, to, source, provider, model, model_prefix, status, tag (ingest tags), label (repeat = AND) with label_on=any\|pair\|request\|response, ai_audited=true\|false, human_audited=true\|false with review_on=any\|request\|response, metadata.<key> (repeat = OR, different keys = AND), request_hash, system_hash, q with q_scope=any\|request\|response, min_latency_ms, min_total_tokens. limit 1–200 (above is 400). order=desc\|asc. content=none\|preview\|full (default preview). fields=a,b. Keyset cursor. Pages stay under 10 MB. Responses that carry content send Cache-Control: no-store. Each record includes labels and review unless fields names a subset that leaves them out. |
| GET /v1/records/count, GET /v1/records/facets | Same filters as the list. Both scan at most the 10,000 newest matches and say when that cap applied. |
| GET /v1/metadata/keys, GET /v1/metadata/values | Catalog of metadata keys and the values seen for one key. |
| GET /v1/me | The calling key id and its scopes. |
| GET /v1/pairs | Same filters, paging and limit as GET /v1/records. Each item is one request/response pair. content defaults to full. |
| GET /v1/pairs/count | Same filters as the pair list. Scans at most the 10,000 newest matches. |
| GET /v1/pairs/{id} | One pair. content defaults to full. |
| GET /v1/records/{id} | content defaults to full; include=raw needs records:read:raw. Includes labels and review. |
| GET /v1/labels, POST /v1/labels | Catalog of label names. POST body is { "name" } and needs annotate:write. |
| PATCH /v1/labels/{id}, DELETE /v1/labels/{id} | Rename, or delete the name and every link that uses it. Needs annotate:write. |
| PUT /v1/records/{id}/labels | { "on": "pair"\|"request"\|"response", "labels": ["name"] } replaces that side. Names must already exist. request attaches to the call's skill (org_id, agent_id, skill) and shows on every call of that skill. Needs annotate:write. |
| PUT /v1/reviews | { "on": "request"\|"response", "record_id", "by": "ai"\|"human", "audited": true, "note": "optional" }. A missing review is neither AI nor human audited. Request review is the skill; pass org_id, agent_id and skill together to override the record. Response review stays on that pair. Needs annotate:write. |
| DELETE /v1/records/{id} | Removes the record, its dead letters and pending deliveries; queues a trace delete in Langfuse |
| POST /v1/records/delete | { "filter": { ... }, "dry_run": true }. At least one filter is required. Audited. |
| GET /v1/admin/sources, GET /v1/admin/sinks | State, cursors, jobs, queue depths |
| POST /v1/admin/sources/{id}/refetch | { "from", "to" }. Pull sources only. |
| POST /v1/admin/sinks/{id}/replay | { "filter": { ... } }. Same trace ids, so no duplicates. |
| GET /v1/admin/dead-letters | source, reason, code, status, include=payload |
| POST /v1/admin/dead-letters/{id}/replay, POST /v1/admin/dead-letters/replay | Single or bulk replay |
| POST /v1/admin/sources/{id}/rotate-secret | { "overlap_seconds": 86400 }. Returns the new secret once. |
| GET /v1/admin/archive | Live record count and pack-spool stats. |
| POST /v1/admin/archive/restore | { "seq": N } upserts that package back into MongoDB by record id. |
| GET /healthz, GET /readyz, GET /metrics | Liveness, readiness (MongoDB and migrations), Prometheus |
Message content goes only to keys with records:read:content. Other keys get records with
content_state: "absent" and content_omitted_reason: "scope", which also covers content=preview. That is
the stricter reading of API-5, API-6 and AC-13. Reads that return content are written to the audit log.
Request and response pairs
/v1/pairs is the API for other services. Each item is one stored call: the request that was sent and the response
that came back, plus the fields you need to filter and identify it. The canonical record, including the raw provider
payload, stays on /v1/records.
Use a bearer key with records:read. Message text is returned only to keys that also have records:read:content.
Without that scope the pair is still returned, with content_state: "absent" and content_omitted_reason: "scope".
Responses that include message text send Cache-Control: no-store.
content defaults to full. Pass content=preview for a short snippet or content=none when you only need
metadata. limit is 1–200 (default 50). order is desc (newest first) or asc. Pages use the same keyset
cursor as /v1/records: send next_cursor back as cursor with the same filters and order. A page stops early
if it would pass 10 MB. fields=request,response keeps id plus the named fields.
Filters are the same as the record list: from, to, source, provider, model, model_prefix, status,
tag, label (repeat for AND) with label_on, ai_audited, human_audited with review_on,
metadata.<key> (repeat a key for OR, use different keys for AND), request_hash, system_hash, q with
q_scope=any|request|response, min_latency_ms, min_total_tokens. label is the xtivix catalog.
tag is the ingest tag on the record. A request label or request review follows the skill, not one call.
# Newest matching pairs, with the full prompt and completion
curl -s -H "Authorization: Bearer $XTIVIX_KEY" \
"http://localhost:8081/v1/pairs?source=src_custom&status=ok&metadata.user_id=u_42&limit=50"
# Next page. Keep every filter and the same order.
curl -s -H "Authorization: Bearer $XTIVIX_KEY" \
"http://localhost:8081/v1/pairs?source=src_custom&status=ok&metadata.user_id=u_42&cursor=$NEXT_CURSOR"
# One pair. Encode the id: src_custom:req-7f3a9c becomes src_custom%3Areq-7f3a9c
curl -s -H "Authorization: Bearer $XTIVIX_KEY" \
"http://localhost:8081/v1/pairs/src_custom%3Areq-7f3a9c"
# How many pairs match
curl -s -H "Authorization: Bearer $XTIVIX_KEY" \
"http://localhost:8081/v1/pairs/count?metadata.app=support-bot&status=ok"A pair looks like this. text is the message's text parts joined together. parts is the full content: text,
tool calls, tool results and media references.
{
"id": "src_custom:req-7f3a9c",
"started_at": "2026-09-30T13:56:41.208Z",
"source": { "id": "src_custom", "provider": "custom" },
"provider_record_id": "req-7f3a9c",
"operation": "chat",
"model": { "raw": "gpt-4o-mini", "canonical": "openai/gpt-4o-mini" },
"status": "ok",
"metadata": { "user_id": "u_42", "session_id": "s_981" },
"request": {
"messages": [{ "role": "user", "text": "Where is my order 1182?", "parts": [{ "type": "text", "text": "Where is my order 1182?" }] }],
"params": { "temperature": 0.2, "max_tokens": 256 }
},
"response": {
"messages": [{ "role": "assistant", "text": "Order 1182 shipped yesterday.", "parts": [{ "type": "text", "text": "Order 1182 shipped yesterday." }] }],
"finish_reason": "stop"
},
"usage": { "input_tokens": 31, "output_tokens": 9, "total_tokens": 40 },
"latency_ms": 1703,
"content_state": "full",
"content_view": "full"
}The list body is { "items", "next_cursor", "has_more" }. Count returns { "count", "capped" }. capped: true
means the count stopped at 10,000 matches. GET /v1/pairs/{id} returns 404 when that id is not in the live store.
Decisions and deviations
- npm workspaces instead of pnpm (pnpm through corepack failed on the build machine).
- Duplicate spans in Langfuse (open question 4). Trace and span ids are derived from the record id, so a
redelivery targets the same trace. The mock in the acceptance tests assumes Langfuse updates in place. Confirm
this against a real Langfuse with
npm run e2eplus a replay (POST /v1/admin/sinks/langfuse/replay). - First pull without
backfill_fromstarts at "now minus overlap". History only comes in through the bounded backfill job. - Deleting from Langfuse is best effort (
DELETE /api/public/traces/{id}) and retried like any delivery. - The inbox keeps raw payloads for
inbox_grace_days(7) for replay, then prunes them. A record delete does not reach into inbox items that are still inside the grace period. - Text search (
q) matches whole words and quoted phrases in the stored message text.q_scopelimits the match to the request or the response. The query is not written to logs.
Tests
npm run test:unit: core, schema, conformance for every adapter (golden files, setUPDATE_GOLDEN=1after an intended change), the Langfuse sink and the Bedrock forwarder.npm run test:acceptance: AC-1 … AC-19 against the real server code, an embedded MongoDB replica set, and mock Langfuse and Cloudflare APIs. Includes a 10,000-record paging run (AC-11), a sink outage (AC-15) and a log and metrics scan for content (AC-18).npm run e2e: live checks against the Docker stack, or-- --localwithout Docker.
Releasing
Every package (except the private Bedrock forwarder) is published to npm under @x12i by
.github/workflows/release.yml when a v* tag is pushed. The workflow checks
that the tag matches the version, runs typecheck, tests and release:check, then runs release:publish, which
publishes in dependency order with provenance and skips versions already on npm (so a tag pushed after a local
npm run release:publish is harmless). Versions with a prerelease suffix (1.2.0-rc.0) go to the next
dist-tag, all others to latest.
One-time setup: add an npm automation token with publish rights on the @x12i scope as the NPM_TOKEN
repository secret.
npm run release:version -- minor # or patch | major | prerelease [--preid=rc] | 1.2.3
git commit -am "release v0.2.0"
git tag v0.2.0 && git push --follow-tagsThe packages use npm staged publishing, so a token alone cannot release: npm run release:stage uploads every
package to the stage queue (in dependency order) and a maintainer approves each version with 2FA on npmjs.com or
with npm stage approve <id>. Staged versions that are rejected or expire cannot be reused; bump and stage again.
release:version sets the same version on every workspace, pins the internal @x12i/xtivix-* dependencies to it
and refreshes package-lock.json. To rehearse, run the release workflow manually (Actions, then "Run workflow");
by default it runs npm publish --dry-run.
