tracklyng
v0.4.0
Published
LLM cost tracking & observability for Node/TypeScript apps.
Maintainers
Readme
tracklyng
LLM cost tracking & observability for Node/TypeScript apps. Captures LLM requests (model, tokens, prompt, completion) via OpenTelemetry GenAI instrumentation and reports them to Tracklyng — with zero changes to your LLM call sites.
Install
pnpm add tracklyngSet TRACKLYNG_API_KEY in your environment.
Usage
Wrap the provider client class once, and construct from the returned class. Every call on instances of it is captured — including under Next.js + Turbopack.
import { GoogleGenAI } from "@google/genai";
import { instrument } from "tracklyng";
const GenAI = instrument(GoogleGenAI);
const ai = new GenAI({ apiKey: process.env.GEMINI_API_KEY });
// captured: model, tokens, prompt, completion — call sites unchanged
const res = await ai.models.generateContent({ model: "gemini-2.5-flash", contents: "..." });A common pattern is to wrap in your client factory / a shared module:
// lib/genai.ts
import { GoogleGenAI } from "@google/genai";
import { instrument } from "tracklyng";
export const GenAI = instrument(GoogleGenAI);instrument() initializes tracklyng automatically from TRACKLYNG_API_KEY on first use —
no instrumentation.ts or next.config changes required. If no key is set, it's a no-op
and returns the class unchanged.
Other providers
Same pattern — wrap the client class and construct from the result:
import OpenAI from "openai";
import { instrument } from "tracklyng";
const Client = instrument(OpenAI);
const openai = new Client({ apiKey: process.env.OPENAI_API_KEY });Supported: OpenAI (incl. Azure OpenAI), Anthropic, AWS Bedrock, Vertex AI, Google Gemini
(@google/genai 1.x). Each provider SDK is an optional peer dependency — install whichever
you use.
Passing the key in code
If you'd rather not use the env var, call init({ apiKey }) once at startup before the
first instrumented call:
import { init } from "tracklyng";
init({ apiKey: "tlk_live_..." });Edge / Deno (Supabase Edge Functions)
instrument() needs the Node runtime and a provider SDK class to wrap. In Deno edge
functions that call a provider via raw fetch (e.g. Supabase Edge Functions), use the
zero-dependency tracklyng/edge entry instead — it builds a record and POSTs it to the
ingest endpoint with global fetch, so it runs anywhere (Deno, Node 18+, Workers, browsers):
import { trackResponse } from "npm:tracklyng/edge";
const startedAt = Date.now();
const resp = await fetch(geminiUrl, { method: "POST", /* ... */ });
const json = await resp.json();
await trackResponse(json, {
apiKey: Deno.env.get("TRACKLYNG_API_KEY"),
prompt, // omit, or captureContent:false, to skip content
startedAt, endedAt: Date.now(),
attributes: { function_name: "extract-nf" }, // freeform tags for the dashboard
});trackResponse auto-detects the provider from the response shape and extracts model,
token usage, and completion for you — you don't name the provider. It recognizes OpenAI
(both chat.completions and the Responses API), Anthropic Messages, Google Gemini
generateContent, and AWS Bedrock (the Converse API and Titan/Llama InvokeModel
bodies). Pass provider: "openai" | "anthropic" | "gemini" | "bedrock" | "vertex" to skip
detection for an ambiguous shape (e.g. a streaming chunk or an API variant); if the shape is
unrecognized and no provider is given, a generic unattributed record is still sent rather
than throwing. The shape-detector is also exported on its own as detectProvider(resp).
Some hosted shapes are byte-identical to the model they serve: Claude-on-Bedrock
(InvokeModel) looks exactly like native Anthropic, and Gemini-on-Vertex looks exactly like
native Gemini. Telemetry still extracts correctly either way. To attribute the hosting
platform (gen_ai.system = "bedrock" / "vertex") for those, either use the explicit
entry points below, or rely on the model-id heuristic — trackResponse auto-upgrades the
label when the model id reveals the platform (e.g. anthropic.claude-…-v1:0 → bedrock,
claude-…@20240620 → vertex). Native model ids are never reattributed.
trackResponse treats resp as a trusted first-party LLM API response — the model,
token counts, and completion text are extracted and forwarded to the ingest endpoint as-is.
Don't pass a response proxied verbatim from an untrusted source without your own handling.
Key handling matches init() and the Python SDK: a missing key is a no-op, a malformed
key throws TracklyngConfigError (a clear startup config error, surfaced on the first call —
not silent telemetry loss). Delivery is best-effort: a network/ingest failure logs and
resolves { ok: false } without throwing, so it can't break your function. If you already know
the provider at the call site, the named helpers trackGeminiResponse, trackOpenAIResponse,
trackAnthropicResponse, trackBedrockResponse, and trackVertexResponse are still available
(the last two also fix the gen_ai.system label to the platform), plus the provider-agnostic
core track({ model, inputTokens, outputTokens, prompt, completion, ... }) for any other provider.
Pass captureContent: false to record token counts/metadata only.
Captured data
Model, token usage, and prompt + completion content (matching the Python SDK). Only LLM spans are exported — framework/HTTP spans are dropped.
Content capture is ON by default. Prompt and completion text is sent to the Tracklyng ingest endpoint. If your prompts may contain PII, credentials, or confidential data and you only want token counts/metadata, turn it off:
init({ apiKey, captureContent: false }); // process-wide default (plain Node) register({ captureContent: false }); // process-wide default (tracklyng/next) instrument(OpenAI, { captureContent: false }); // override THIS provider only
init/registerset the process-wide default; the option oninstrument(Class, …)overrides it for that one provider and does not affect the others. Set defaults before your first instrumented call.For a privacy-critical "off", use the
init/registerdefault — not a per-class override. A provider class is patched only once, so if the same class was already wrapped (e.g.instrument(OpenAI)ran earlier in another module), a laterinstrument(OpenAI, { captureContent: false })cannot re-patch it: it logstracklyng.instrument_capture_conflictat error level and keeps the earlier setting. Settinginit({ captureContent: false })once at startup makes every provider resolve to off regardless of wrap order, so there's no conflict to lose.
Each span batch also carries best-effort host metadata — hostname, PID, OS/runtime, and
the host's primary non-internal IPv4 address (host.ip) — matching the Python SDK. In
cloud/container environments host.ip is typically a private VPC address.
License
Apache-2.0
