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

@hytel-sb/switchboard-tracer

v1.2.0

Published

Never-throw, never-block tracing for AI calls. Records runs into Switchboard AI Observability.

Readme

@hytel-sb/switchboard-tracer

Records your AI calls into Switchboard AI Observability — the question, the answer, how long it took, tokens, cost, and errors.

Two promises hold everywhere in this package:

  • It never throws into your code. If Switchboard is down or your key is wrong, you get one warning in the console and your AI call behaves exactly as it did before.
  • It never makes your AI call wait. Nothing on the instrumented path touches the network. Traces are queued and delivered in the background.

Zero dependencies. Node 18+. Works on Vercel Edge and Cloudflare Workers.


Install

npm i @hytel-sb/switchboard-tracer

Set one environment variable wherever your server runs:

SWITCHBOARD_TRACER_KEY=elk_your_full_project_key

That is the whole configuration — traces go to https://app.switchboardpm.ai without you naming it. Get the key from AI Observability → Projects → + New Project; it is shown once and cannot be recovered, and the shortened elk_20c1e9… value in the project list is a label, not a key.

Self-hosting Switchboard, or pointing a staging app at a staging instance? Set SWITCHBOARD_TRACER_URL to that address. It is the address of Switchboard, not of your own app.

With no key set, everything in this package is a silent no-op. That is deliberate — importing it in a test or a build should cost nothing and say nothing.

Server-side only. The package refuses to run in a browser and tells you to rotate the key if it finds one there. A secret in browser code is readable by every visitor.


One line

Wrap the client where you create it. Nothing at your call sites changes.

import OpenAI from 'openai';
import { observe } from '@hytel-sb/switchboard-tracer';

const openai = observe(new OpenAI({ apiKey: process.env.OPENAI_API_KEY }));

const r = await openai.chat.completions.create({
  model: 'gpt-5.2',
  messages: [{ role: 'user', content: question }],
});

Recorded automatically: model, inputs, outputs, tool calls, token usage, latency, cost and errors.

Same line for the others:

const anthropic = observe(new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }));
const genai = observe(new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY }));
const ai = observe(genkit({ plugins: [googleAI()] })); // wrap the genkit() instance

Because responses are matched by shape rather than by client class, anything OpenAI-compatible works too: Azure OpenAI, OpenRouter, Together, Groq, Fireworks, DeepSeek, xAI.

Naming. Runs are named after the method by default. For something you can filter on:

observe(client, { name: 'support-chat', tags: ['chat'] });
observe(client, { name: (method, args) => `chat:${args[0].model}` }); // per call

Per-call control

traced() wraps one call and returns exactly what your function returns:

import { traced } from '@hytel-sb/switchboard-tracer';

const answer = await traced({ name: 'summarise-thread', inputs: { thread } }, async (run) => {
  const r = await openai.chat.completions.create({ model: 'gpt-5.2', messages });
  return r; // return the raw response and token counts are read for free
});

startRun() is the manual form, for work that does not fit a single function call:

import { startRun } from '@hytel-sb/switchboard-tracer';

const run = startRun({ name: 'chatbot-turn', runType: 'chain', model, inputs: { message } });
try {
  const result = await doTheWork();
  run.end({ outputs: { answer: result }, tokenUsage: { input, output } });
} catch (err) {
  run.fail(err);
  throw err;
}

Every method on run is synchronous and returns nothing — there is no promise to await, which is why tracing cannot delay your response. Calling end() twice is a no-op.

Nest steps with run.child({ name: 'retrieval', runType: 'retriever' }).

Models billed per image

Image models are not billed by token and report no token counts at all, so tokenUsage cannot price them. Report the unit count instead and the server applies the model's per-image rate:

run.end({ outputs: { answer: null }, unitUsage: { outputImages: 2 } });

observe() fills this in for you, across providers:

| Call | How images are counted | | ------------------------------------------------------------- | -------------------------------------- | | Genkit generate() / generateStream() | usage.outputImages, else media parts | | OpenAI images.generate() / .edit() / .createVariation() | data.length | | Google models.generateImages() | generatedImages.length | | Google models.generateContent() | inline image/* parts | | An SDK with no adapter | recognised by shape |

That last row matters: detection is structural, not an allowlist, so a provider nobody has written an adapter for still yields a count. An image charge that goes uncounted is invisible, not merely unpriced.

Payloads are never carried — base64 image data is dropped and only the count, content type and size are recorded.

A blank cost is never "free". Every provider bills every call, so a run with usage but no cost records why: no_pricing_entry (no row for that model id) or no_matching_rate (a row exists but nothing in it covers what the run reported). The dashboard shows these as unpriced rather than a dash, because a gap you can see is a gap someone fixes.


Serverless

A serverless function can freeze the moment it returns, before a background send lands. Await the flush:

import { flush } from '@hytel-sb/switchboard-tracer';

export async function POST(req: Request) {
  const res = await handle(req);
  await flush(2000); // resolves; never rejects
  return res;
}

Long-lived servers and CLIs get a best-effort flush on exit automatically. That does not cover Lambda or Vercel, which freeze instead of exiting.


Streaming

Streams are passed through untouched while token counts are read as the chunks go by. For OpenAI you must ask for them:

const stream = await openai.chat.completions.create({
  model: 'gpt-5.2',
  messages,
  stream: true,
  stream_options: { include_usage: true }, // without this there are no token counts to read
});

If counts are unavailable the run is still recorded, with token usage left empty rather than zeroed — zeros would show up as real numbers in your cost dashboard.

Abandoning a stream early (break out of the loop) still closes the run, marked incomplete.

Genkit's generateStream() returns { stream, response } rather than the stream itself, and is traced with no extra work:

const { stream, response } = ai.generateStream({ model: textModel(), prompt, tools });
for await (const chunk of stream) {
  /* your own streaming */
}
const final = await response;

The run is read from the response promise, not from the chunks. That means your iterator is handed back untouched — never wrapped, teed, or partially drained — and the run is recorded even if you only await response and ignore stream entirely. Outputs carry streamed: true so a streamed turn stays distinguishable from a blocking one.


Configuration

Every option is optional. Explicit options beat environment variables, which beat defaults.

import { configure } from '@hytel-sb/switchboard-tracer';

configure({
  apiKey,
  baseUrl,
  sampleRate: 0.1, // decided once per root run; children inherit
  mode: 'auto', // 'single' | 'two-phase' | 'auto'
  redact: (body) => ({ ...body, inputs: null }),
  onError: (e) => myLogger.warn(e),
});

| Variable | Meaning | | -------------------------------- | ---------------------------------------------------- | | SWITCHBOARD_TRACER_KEY | Required. Absent means silent no-op | | SWITCHBOARD_TRACER_URL | Optional. Defaults to https://app.switchboardpm.ai | | SWITCHBOARD_TRACER_ENABLED | false forces tracing off | | SWITCHBOARD_TRACER_SAMPLE_RATE | 01 | | SWITCHBOARD_TRACER_MODE | single, two-phase or auto | | SWITCHBOARD_TRACER_DEBUG | 1 logs every request |

Modes. auto (the default) sends a finished run as one request, and switches to the two-request form if the call is still running after ten seconds — so slow and streamed calls show up live without any configuration. single never announces early; two-phase always does.

createTracer(config) builds an isolated instance if you need to send to more than one project.


Unsupported SDKs

If observe() is handed a client it does not recognise it says so, once, naming what it looked for — rather than silently recording nothing. The same goes for a single method it does not know sitting beside one it does: reading client.embedMany when only embed is registered warns once, so a gap in coverage announces itself instead of showing up later as an empty dashboard.

You then have two options: use traced() around the call, or teach it the shape:

import { registerProvider } from '@hytel-sb/switchboard-tracer';

registerProvider({
  id: 'mistral',
  methods: { 'chat.complete': 'llm' },
  readResponse: (r) => ({
    outputs: { answer: r.choices?.[0]?.message?.content ?? null },
    tokenUsage: r.usage && { input: r.usage.prompt_tokens, output: r.usage.completion_tokens },
  }),
});

API

| | | | ----------------------------- | ---------------------------------------------------------------- | | observe(client, options?) | Wrap a provider client. Returns it unchanged when tracing is off | | traced(options, fn) | Record one call; returns whatever fn returns | | startRun(input) | Open a run manually. Synchronous | | configure(config) | Set options at runtime | | createTracer(config?) | An isolated instance | | registerProvider(adapter) | Teach it a new SDK shape | | flush(ms?), shutdown(ms?) | Deliver what is queued. Resolve, never reject | | isEnabled(), stats() | Diagnostics | | clip(value, maxChars?) | Truncate a value for use in inputs |

Limits

Inputs, outputs and extras are truncated at 90 000 characters; the API rejects anything over 100 000. Tags are capped at 50, names at 256 characters, and metadata values are coerced to strings so { userId: 42 } produces a trace instead of a rejection.