npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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 project xtivix.
  • Ingest: http://localhost:2222, read API: http://localhost:8081 (bearer key required), worker metrics: http://127.0.0.1:8082/metrics.
  • npm run e2e runs the live end-to-end checks against the stack.
  • Already have Langfuse (or Langfuse Cloud)? Set LANGFUSE_HOST, LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY in deploy/.env and use npm run stack:up:external.
  • For production Langfuse on Kubernetes use the official Helm chart (langfuse/langfuse); point LANGFUSE_HOST at 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 options

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

Library 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 e2e plus a replay (POST /v1/admin/sinks/langfuse/replay).
  • First pull without backfill_from starts 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_scope limits 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, set UPDATE_GOLDEN=1 after 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 -- --local without 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-tags

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