nitroping
v1.0.0
Published
TypeScript SDK for the NitroPing audit log — batching client, error capture, framework adapters, and GenAI cost tracing for eighteen providers.
Maintainers
Readme
nitroping
Audit logging and GenAI observability for TypeScript. One client, batched over HTTP, for NitroPing.
npm install nitroping # or pnpm / bun / yarnAudit events
import { NitroPing } from "nitroping"
const nitroping = new NitroPing({ apiKey: process.env.NITROPING_API_KEY! })
nitroping.track({
action: "invoice.updated",
actor_id: "usr_1",
actor_type: "user",
target_type: "invoice",
target_id: "inv_42",
severity: "info",
})track returns immediately and queues; see Delivery for when the
queue is flushed and what happens when it can't be. id, timestamp,
tenant_id and ip are assigned by the server — a client cannot claim them.
Framework adapters
Subpath exports, so nothing you don't import is bundled. Each tracks every
request as an http.request event with method, path, status and latency, and
opens a span that later calls nest under.
import { nitropingMiddleware } from "nitroping/hono"
app.use(
nitropingMiddleware({
apiKey: process.env.NITROPING_API_KEY!,
getActor: (c) => ({ id: c.get("user").id }),
skip: (c) => c.req.path === "/health",
}),
)import { nitropingErrorHandler, nitropingMiddleware } from "nitroping/express"
app.use(nitropingMiddleware({ apiKey }))
app.use(nitropingErrorHandler({ apiKey })) // after the routesimport { nitropingNitroPlugin } from "nitroping/nitro"
export default defineNitroPlugin((nitroApp) => {
nitropingNitroPlugin(nitroApp, { apiKey })
})An inbound x-request-id is reused rather than replaced, so a call already
traced upstream joins that trace instead of starting its own. The send is
fire-and-forget: a slow or failing ingest never blocks the real response.
Errors
import { captureError, withErrorCapture } from "nitroping"
await captureError({ apiKey }, err, { actor: { id: "usr_1" }, category: "billing" })
const charge = withErrorCapture({ apiKey }, async (id: string) => {
/* rethrows after capturing */
})Both produce an error.captured event with the same
error_type / error_message / stack metadata shape, which is what the
panel's Errors view and the MCP find_errors tool group on.
Schema
The Zod schema the server validates against ships too, for callers who want to check an event before sending it:
import { auditEventInputSchema } from "nitroping/schema"It is a subpath export on purpose — importing nitroping itself pulls in no
zod at runtime.
Integrations
Eighteen, in three shapes. Wrap returns the client you passed in, so it is one line at construction with no call-site changes. Hook is a callback you hand to a framework that already has one. OTLP needs no SDK at all.
| | Integration | Shape | | --- | --------------------- | ------ | | 1 | OpenAI | wrap | | 2 | Anthropic | wrap | | 3 | Google Gemini | wrap | | 4 | Amazon Bedrock | wrap | | 5 | OpenRouter | wrap | | 6 | LiteLLM | wrap | | 7 | Azure OpenAI | wrap | | 8 | Vercel AI SDK | hook | | 9 | TanStack AI | hook | | 10 | Eve | hook | | 11 | Mastra | hook | | 12 | Claude Code | OTLP | | 13 | OpenClaw | OTLP | | 14 | Hermes Agent | OTLP | | 15 | Pydantic AI | OTLP | | 16 | LangChain / LangGraph | OTLP | | 17 | CrewAI | OTLP | | 18 | Custom agents | manual |
Wrap
1. OpenAI
const openai = nitroping.observeOpenAI(new OpenAI())
await openai.chat.completions.create({ model: "gpt-4o", messages })Covers chat.completions.create, responses.create and embeddings.create.
Reads both the Chat Completions naming (prompt_tokens) and the Responses
naming (input_tokens); reading only one reports zero for the other. Cached and
reasoning tokens land in their own columns.
2. Anthropic
const anthropic = nitroping.observeAnthropic(new Anthropic())Cache writes are billed above the input rate, so they count as fresh input; cache reads are counted separately at the cache rate. Folding both into one bucket under-bills the write and over-bills the read.
3. Google Gemini
const genai = nitroping.observeGoogleGenAI(new GoogleGenAI({ apiKey }))Gemini's promptTokenCount already includes the cached share, so it is
subtracted out — otherwise a cache hit is billed twice, once at each rate.
thoughtsTokenCount maps to reasoning.
4. Amazon Bedrock
const bedrock = nitroping.observeBedrock(new BedrockRuntimeClient({ region }))Intercepts client.send and dispatches on the command: Converse* records as
a chat, InvokeAgent* as an agent. InvokeModel passes through untraced — its
body is a provider-specific blob with no common usage shape. Use Converse for
tracing. cacheWriteInputTokens counts as input, as on Anthropic direct.
5. OpenRouter
const openrouter = nitroping.observeOpenRouter(
new OpenAI({ baseURL: "https://openrouter.ai/api/v1", apiKey }),
)OpenRouter returns its own usage.cost, and a provider-reported cost always
beats an estimate — it reflects the actual route taken and your actual credits.
6. LiteLLM
const litellm = nitroping.observeLiteLLM(new OpenAI({ baseURL: proxyUrl, apiKey }))One span per call across every provider the proxy fronts, since LiteLLM normalises usage to the OpenAI shape.
7. Azure OpenAI
const azure = nitroping.observeOpenAI(new AzureOpenAI({ ... }), "azure")Same wire shape as OpenAI; the second argument sets the provider label so Azure spend is separable from direct OpenAI spend in the panel.
Hook
8. Vercel AI SDK
await streamText({ model, prompt, onFinish: nitroping.onFinish() })The only path that covers streaming: onFinish fires once the stream ends,
with usage already assembled. Uses totalUsage where present, which covers
every step of a multi-step tool loop rather than just the last one.
A multi-step run records as an agent rather than a chat, decided by the
step count on the event.
9. TanStack AI
onFinish: nitroping.onFinish()Same handler — TanStack AI passes a compatible finish event.
10. Eve
onFinish: nitroping.onFinish()Same handler again.
11. Mastra
onFinish: nitroping.onFinish({ provider: "mastra" })Mastra runs on the Vercel AI SDK underneath, so its finish event carries the
same usage shape. provider and model fill in what the event does not carry.
OTLP
Anything with an OpenTelemetry exporter reaches us with no SDK. Point it at the ingest endpoint:
export OTEL_EXPORTER_OTLP_ENDPOINT=https://ingest.nitroping.dev/v1/otlp
export OTEL_EXPORTER_OTLP_PROTOCOL=http/json
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer np_..."http/json, not protobuf. Most exporters default to protobuf and the
endpoint answers 415 with that instruction — decoding protobuf in a Worker
means a runtime dependency for a format the same exporters already emit as JSON.
Spans arrive on /v1/otlp/v1/traces, metrics on /v1/otlp/v1/metrics. Logs are
accepted and discarded, so an exporter configured to send all three does not
error.
12. Claude Code
export CLAUDE_CODE_ENABLE_TELEMETRY=1
export OTEL_METRICS_EXPORTER=otlp
export OTEL_LOGS_EXPORTER=otlpClaude Code reports tokens and cost as metrics, not spans, and puts its
identity on the OTLP resource rather than the span — both handled. Token metrics
route by their type attribute into the input / output / cache-read / reasoning
columns.
13. OpenClaw
14. Hermes Agent
15. Pydantic AI
Standard GenAI semantic-convention spans. Set the three variables above and they
appear as traces. Attribute names changed between convention releases, so both
the old (gen_ai.usage.prompt_tokens) and current
(gen_ai.usage.input_tokens) spellings are read.
16. LangChain / LangGraph
Via openinference-instrumentation-langchain or OpenLIT, then the same three
variables. A LangGraph run keeps its parent links, so the graph shows as one
tree in the panel.
17. CrewAI
Via OpenLIT with the same variables. Or, for control over what is recorded, use the manual agent API below.
Manual
18. Custom agents
For a hand-rolled loop, or any framework with no exporter:
const run = nitroping.startAgentRun({ name: "researcher", model: "gpt-4o" })
const results = await run.tool("web_search", () => search(query))
run.end()tool() returns whatever its callback returned, so it wraps a call without
restructuring it. Tool executions share the run's requestId and nest beneath
its span, so the panel shows the whole agent as one trace. A tool that throws is
recorded as failed and the error re-raised.
run.context carries { requestId, parentSpanId } for anything you record by
hand that should hang off the same run.
Cost
cost_usd is computed by the SDK from the provider's token counts against a
list-price table — the Worker never recomputes it, since only you know your
negotiated rate. A provider-reported cost always wins over an estimate.
Model matching is longest-prefix: gpt-4o-mini-2024-07-18 resolves to
gpt-4o-mini, not to gpt-4o which it also contains and which would bill it at
sixteen times the real rate.
An unknown model leaves cost unset rather than 0 — a gap in the spend chart reads as a gap, where a zero reads as a real, free call.
Negotiated rates:
new NitroPing({
apiKey,
pricing: { "gpt-4o": { input: 1.0, output: 4.0, cachedInput: 0.5 } },
})Prices are US dollars per million tokens.
Prompt content
Off by default — prompts are the field most likely to carry personal data.
new NitroPing({ apiKey, captureContent: true }) // truncated at 2000 charsDelivery
Events are batched and flushed every 5s, on reaching maxBatchSize (50), on a
browser tab going hidden, and on beforeExit/SIGINT/SIGTERM — so a script
or a serverless invocation that ends with a partial batch still sends it. The
flush timer is unref'd where the runtime allows, so a queued event can't hold
a CLI open.
When the endpoint is unreachable the batch goes back on the queue. So does a 5xx or a 429 — those are the endpoint's problem, not the batch's. A 4xx is not retried: a bad key or a payload the schema rejects will never be accepted, and retrying it only loses the events behind it.
The queue is bounded at maxQueueSize (10,000). Past that the oldest are
dropped — the newest events describe the failure in progress.
Loss is never silent:
new NitroPing({
apiKey,
maxQueueSize: 50_000,
onError: ({ reason, dropped, detail }) => {
// reason: "overflow" | "network" | "rejected"
logger.warn({ reason, dropped, detail }, "nitroping")
},
})await client.close() flushes what is queued and stops accepting more — call it
in a shutdown hook when you want the flush awaited rather than best-effort.
Streaming
Raw provider wrappers skip streamed calls: usage arrives in the final chunk, and
reading it would consume the caller's stream. The Vercel AI SDK's onFinish is
the exception — use it, or startAgentRun, when streaming matters.
