# NitroPing > Hosted audit logging for TypeScript services. One call records an event; the > row is immutable and queryable. The same client also traces GenAI calls with > token counts and cost, so model spend lands in the audit trail rather than in > a second tool. Built by productdevbook — https://github.com/productdevbook - Ingest + control-plane API: https://ingest.nitroping.dev - API reference (Scalar): https://ingest.nitroping.dev/docs - OpenAPI document: https://ingest.nitroping.dev/openapi.json - Panel: https://panel.nitroping.dev - Package: `nitroping` on npm — https://www.npmjs.com/package/nitroping - SDK source and issues: https://github.com/productdevbook/nitroping-sdk A missing integration, a snippet that no longer matches its SDK, or a provider whose token naming is read wrong belongs there as an issue. This file is the whole integration. Read it and you can add NitroPing to a codebase without opening anything else. ## 1. Install bun add nitroping # npm i nitroping / pnpm add nitroping One package. Subpath exports: `nitroping/hono`, `nitroping/express`, `nitroping/nitro`, `nitroping/genai`, `nitroping/schema`. The `@nitroping/*` npm scope is unpublished and 404s. The package name is `nitroping`, with no scope. ## 2. Get a key Sign in at https://panel.nitroping.dev with GitHub, then Settings → API keys. The value is shown once at creation and only its SHA-256 is stored, so a lost key is replaced, never recovered. Put it in the environment as `NITROPING_KEY`. Never commit it, and never send it to the browser — the SDK is server-side. ## 3. Record an event import { NitroPing } from "nitroping" export const audit = new NitroPing({ apiKey: process.env.NITROPING_KEY! }) audit.track({ actor_id: user.id, actor_type: "user", action: "invoice.void", target_type: "invoice", target_id: invoice.id, severity: "warning", metadata: { amount_cents: 42900, reason: "duplicate" }, }) `track()` returns `void` immediately — it queues. Events are batched in the background and flushed on process exit, so a serverless invocation does not drop its last batch. A slow endpoint never slows the request being audited. Construct the client once per process and export it. Do not create one per request: each instance owns its own queue and timer. ## 4. The event shape Required: actor_id string who did it action string what they did, 1–128 chars, dot-namespaced by convention: "invoice.void", "user.login" Defaulted if omitted: actor_type "user" | "system" | "api_key" | "service" (default "user") severity "info" | "warning" | "critical" (default "info") Optional: actor_display_name string, ≤256 target_type string, ≤128 target_id string, ≤256 category string, ≤64 request_id string, ≤128 correlate events from one request parent_span_id string, ≤128 another event's id, same request_id duration_ms number, 0–86400000 user_agent string, ≤512 metadata object, free-form JSON GenAI columns (the wrappers fill these; you can also set them by hand): gen_ai_provider string, ≤64 gen_ai_model string, ≤128 gen_ai_operation "chat" | "completion" | "embedding" | "agent" | "tool" | "workflow" input_tokens integer ≥ 0 output_tokens integer ≥ 0 cached_input_tokens integer ≥ 0 reasoning_tokens integer ≥ 0 cost_usd number An event that fails validation is rejected individually; the rest of the batch is still written, and the response names the index and the reason. ## 5. Four fields you do not set id UUIDv7 assigned on write. Time-ordered, so a row's position in the log is a fact about when it happened. tenant_id Taken from the API key. A client that posts its own is overwritten. There is no way to write into another log. ip Read from the connection, never from the body. Send the header `X-NitroPing-No-IP` and the row is written without it. timestamp Normalised to UTC milliseconds in one place. `id` and `timestamp` are *defaults*, not locks: send your own and they are kept, provided `id` parses as a UUID and `timestamp` as an ISO-8601 instant. `POST /v1/events` answers with counts and no ids, so a batch that links spans through `parent_span_id` has to use ids the caller chose itself. `tenant_id` is never accepted. Do not put it in a payload. ## 6. Middleware — every request, no call sites import { nitropingMiddleware } from "nitroping/hono" app.use(nitropingMiddleware({ apiKey: process.env.NITROPING_KEY!, getActor: (c) => c.get("user"), skip: (c) => c.req.path.startsWith("/health"), })) Also exported from `nitroping/express` and `nitroping/nitro`. Each request becomes an `http.request` event with method, path, status and latency. A thrown exception is captured as the request's error *and* as a separate `error.captured` event with the stack, then rethrown — your own error handling is untouched. ## 7. GenAI — one line at construction const openai = audit.observeOpenAI(new OpenAI()) The wrapper returns the client it was given, so no call site changes. Every call then writes a row with `input_tokens`, `output_tokens`, `cached_input_tokens`, `reasoning_tokens` and `cost_usd`. observeOpenAI observeAnthropic observeGoogleGenAI observeBedrock observeOpenRouter observeLiteLLM For the Vercel AI SDK, TanStack AI and Eve, hook the `onFinish` handler: const result = streamText({ model, messages, onFinish: audit.onFinish() }) Cached input and reasoning tokens are priced separately, because billing prices them separately. Pass `pricing` to the constructor when your contract is not list price. Prompt and completion text is **not** sent unless you set `captureContent: true`. It is the field most likely to carry personal data — leave it off unless you have decided otherwise deliberately. Agent runs nest: `audit.startAgentRun(...)` and `audit.startSpan(...)`, closed with `.end()`, so tool calls hang off the run that made them. ## 8. Client options new NitroPing({ apiKey, // required endpoint, // default https://ingest.nitroping.dev flushIntervalMs, maxBatchSize, maxQueueSize, // oldest dropped past this, never unbounded captureIp: false, // sends X-NitroPing-No-IP captureContent: false, pricing, onError: ({ reason, dropped, detail }) => { /* "overflow" | "network" | "rejected" */ }, }) Set `onError`. Without it, loss is silent — for an audit log that is the one failure that must not be quiet. ## 9. No SDK: raw HTTP POST https://ingest.nitroping.dev/v1/events Authorization: Bearer $NITROPING_KEY Content-Type: application/json Body is one event object or an array of 1–500. { "accepted": 2, "rejected": 0 } Rate limit: 60 requests per 60-second sliding window, per key. Every 200 carries `X-RateLimit-Limit` and `X-RateLimit-Remaining`, so back off before being refused rather than after. A 429 carries `Retry-After`. ## 10. No SDK: OpenTelemetry OTEL_EXPORTER_OTLP_ENDPOINT=https://ingest.nitroping.dev/v1/otlp OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer $NITROPING_KEY" Traces and metrics are converted to audit events on arrival. Log records are accepted and discarded, so a tool exporting all three does not error. A third variable is sometimes needed and usually is not: OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf This endpoint speaks OTLP over HTTP — `http/protobuf` and `http/json` are both read, identically — and does not speak gRPC at all. So name the protocol only when the exporter would otherwise pick gRPC, or has no default and picks nothing. If it already defaults to either HTTP protocol the line changes nothing. Two known cases. Claude Code has no default, so it needs the line, along with `CLAUDE_CODE_ENABLE_TELEMETRY=1`, `OTEL_METRICS_EXPORTER=otlp` and `OTEL_LOGS_EXPORTER=otlp`; its spans arrive only with `CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1` and `OTEL_TRACES_EXPORTER=otlp`. oh-my-pi does not need it: its runtime enables http/protobuf and nothing else, and naming another protocol disables that signal rather than switching it. The endpoint stops at `/v1/otlp` deliberately: an OTLP exporter appends `/v1/traces` and `/v1/metrics` to a common endpoint itself, so the doubled path on the wire is expected. ## 11. Reading it back The panel at https://panel.nitroping.dev filters, groups and follows a session. There is also an MCP server, `nitroping-mcp` (not yet published to npm; build it from `packages/mcp` in the monorepo meanwhile: `bun install && bun run build`, then point an MCP client at `dist/index.mjs`). No API key to paste in by hand — the first run opens a device-code login against the panel and caches the key it gets. Once published, running it is just: ``` bunx nitroping-mcp ``` Mostly read tools — `project_health`, `error_groups`, `find_errors`, `endpoint_health`, `actor_summary`, `list_events`, and more — plus two that write: `resolve_error` and `list_error_states`, which set and read a resolved/ignored/open status on an error group. That status is separate Postgres-backed state, not a change to the audit events themselves, which stay immutable. ## 12. Rules for an agent doing this integration - Read `NITROPING_KEY` from the environment. Never inline a key, never commit one, never ship one to a browser bundle. - One client per process, exported from a module. Not one per request. - Audit the decisions, not the plumbing: state changes, permission changes, money, deletion, sign-in. A row per debug log is noise that costs money. - `action` is dot-namespaced and past-tense-ish: `invoice.void`, `member.removed`, `api_key.created`. Keep the vocabulary stable — it is what someone filters on a year from now. - Reach for the middleware before hand-writing `http.request` events. - Do not put secrets, tokens, passwords or full request bodies in `metadata`. - Leave `captureContent` off unless the human explicitly asks for prompt text. - `severity: "critical"` is for things a person should be woken for. Do not spend it.