Skip to content
telemetry.dev
Esc
↑↓navigate↵open⌘Jpreview
On this page

TypeScript SDK

Add spans, logs, metrics, and trace context to TypeScript applications.

@telemetry-dev/sdk records spans, logs, and metrics from a TypeScript application and sends them to telemetry.dev over OTLP. It runs its own OpenTelemetry provider and registers it globally only if you ask it to.

If your application already sets up OpenTelemetry, add the telemetry.dev span processor to that setup instead. See OpenTelemetry.

Requirements

The SDK needs Node.js 20.19.0 or later. The package is ESM-only; CommonJS code can still load it with require() on Node.js 20.19.0 or later.

To nest spans automatically, the SDK needs AsyncLocalStorage. It finds it in these runtimes:

  • Node.js 20.19.0 or later.
  • Next.js on the Vercel Edge runtime, which exposes AsyncLocalStorage as a global.
  • Cloudflare Workers with Node.js compatibility. Compatibility dates of 2026-08-04 or later turn it on by default. Earlier dates need the nodejs_compat flag.
  • Any runtime where the host app has registered a global OpenTelemetry context manager.

In other runtimes, including Vercel Edge Functions that expose AsyncLocalStorage only through a module import, spans do not nest on their own. Pass parent when you create each span.

Install

npm install @telemetry-dev/sdk
pnpm add @telemetry-dev/sdk
yarn add @telemetry-dev/sdk
bun add @telemetry-dev/sdk

@opentelemetry/api is a peer dependency. Current npm and pnpm versions install it automatically.

Create a project at telemetry.dev and copy an API key from the project setup page. Keys look like td_live_....

TELEMETRY_DEV_API_KEY=td_live_...
OTEL_SERVICE_NAME=my-service
TELEMETRY_DEV_ENVIRONMENT=production

The SDK reads these variables from process.env. It does not load .env files, so load the file the way your app already does, for example with node --env-file=.env.

Send your first trace

Call init() once when the application starts. This example traces a small agent that calls a tool and a model:

import {
  init, log, observe, propagateAttributes, shutdown, startActiveSpan, startSpan,
} from "@telemetry-dev/sdk";

init({ onError: (error) => console.error("[telemetry.dev]", error) });

// Stand-in for a real model call, so the example runs without provider credentials.
async function callModel(question: string) {
  return {
    message: { role: "assistant", content: `Echo: ${question}` },
    usage: { inputTokens: 12, outputTokens: 8 },
  };
}

const searchWeb = observe(
  async function searchWeb(query: string) {
    return { hits: [`result for ${query}`] };
  },
  { type: "tool", toolName: "web-search" },
);

async function answer(question: string) {
  return startActiveSpan("answer", { type: "agent", agentName: "demo-agent" }, async () => {
    await searchWeb(question);

    const generation = startSpan("chat-completion", {
      type: "generation",
      model: "example-model",
      provider: "example",
      input: [{ role: "user", content: question }],
    });
    const response = await callModel(question);
    generation.end({ output: response.message, usage: response.usage, finishReason: "stop" });

    log("generation finished", { attributes: { promptChars: question.length } });
    return response.message.content;
  });
}

const reply = await propagateAttributes(
  { userId: "user_123", sessionId: "session_456" },
  () => answer("What is OTLP?"),
);
console.log(reply);

await shutdown(); // Sends pending data, then stops the SDK.

The run produces one trace. The answer agent span is the root. The searchWeb tool span and the chat-completion generation span are its children, and the log record is attached to the same trace. Every span and the log carry user.id and the session ID.

init() returns a TelemetryClient with an enabled property and flush() and shutdown() methods. The module-level functions, such as startSpan() and flush(), always use the most recently initialized client.

Configure init()

An option that you pass to init() takes precedence over its environment variable.

Option Type Environment fallback Default Purpose
apiKey string TELEMETRY_DEV_API_KEY None Authenticates exports. Without a key, the SDK does nothing.
baseUrl string TELEMETRY_DEV_BASE_URL https://ingest.telemetry.dev Sets the ingest URL. Trailing slashes are removed.
environment string TELEMETRY_DEV_ENVIRONMENT production Sets deployment.environment.name.
serviceName string OTEL_SERVICE_NAME unknown_service Sets service.name.
enabled boolean None true Set false to turn the SDK off.
sessionMode "explicit" | "process" None "explicit" "process" gives spans and logs that have no session ID one generated ID. See Process sessions.
registerGlobal boolean None false Registers the SDK’s provider as the global OpenTelemetry provider.
exportMode "batched" | "immediate" None "batched" Exports spans and logs in batches, or one at a time.
sampler OpenTelemetry Sampler OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARG Parent-based, always on Sets the trace sampling policy.
captureInput boolean None true Records span input by default.
captureOutput boolean None true Records span output by default.
mask (value: unknown, ctx: { key: string }) => unknown None None Changes captured content before it is recorded.
maxAttributeLength number None 65536 Caps each captured content attribute, in UTF-16 code units. Infinity turns the cap off.
batch BatchOptions None See the next table Changes the batch span processor limits.
spanFilter (span: ReadableSpan) => boolean None None Chooses which ended spans to export.
resourceAttributes Record<string, AttributeValue> None None Adds OpenTelemetry resource attributes.
logLevel "debug" | "info" | "warn" | "error" | "silent" None "warn" Sets the level of the SDK’s own console diagnostics.
fetch typeof fetch None globalThis.fetch Supplies the HTTP function.
waitUntil (promise: Promise<unknown>) => void None None Hands export work to a serverless runtime. See Flush and shut down.
onError (error: Error) => void None None Receives the SDK’s internal errors.

batch merges the values that you supply over these defaults:

Field Type Default
maxExportBatchSize number 64
scheduledDelayMillis number 1000
maxQueueSize number 2048
exportTimeoutMillis number 30000

An invalid sessionMode or maxAttributeLength does not throw. init() reports the error to onError and returns a disabled client.

Environment fallbacks need a global process.env. On runtimes without it, pass apiKey and any other settings to init() directly.

init() also takes an optional second argument with spanExporter, logRecordExporter, and metricExporter. Use these overrides in tests. A spanExporter override turns the client on without an API key.

Trace a function with observe()

observe(fn, options) returns a wrapped function that records one span per call. It works with synchronous and asynchronous functions:

const loadAccount = observe(
  async function loadAccount(accountId: string) {
    return database.accounts.get(accountId);
  },
  { name: "load-account", type: "tool", toolName: "database" },
);

For each call, the wrapper does the following:

  • Names the span from options.name, then the function name, then anonymous.
  • Records the arguments as input. A single argument is recorded as is; several arguments are recorded as an array.
  • Makes the span active, so SDK spans started inside the function become its children.
  • Records the return value, or the resolved value of a promise, as output.
  • Records a thrown or rejected error on the span, then rethrows it.

options accepts everything that startSpan() accepts. The wrapper looks up the client when the function runs, not when you wrap it. You can wrap functions at module load, before init() runs.

Create spans manually

Use startActiveSpan() to run a callback inside a new span. Spans started inside the callback become its children. The span ends when the callback returns or its promise settles. If the callback throws or rejects, the span records the error and the error is rethrown. The options argument is optional:

const result = await startActiveSpan(
  "run-agent",
  { type: "agent", agentName: "planner" },
  async () => runAgent(),
);

Use startSpan() when the span’s start and end happen in different places. It does not make the span active, and you must end the handle yourself:

const span = startSpan("model-call", {
  type: "generation",
  model: "gpt-4o",
  provider: "openai",
  input: messages,
});

try {
  const response = await callModel(messages);
  span.end({
    output: response.message,
    usage: response.usage,
    finishReason: "stop",
  });
} catch (error) {
  span.end({ error });
  throw error;
}

updateActiveSpan(fields) sets fields on the active span. It does nothing when no recording span is active.

Span handle

startSpan() returns a SpanHandle. startActiveSpan() passes one to its callback.

  • update(fields) sets fields and returns the same handle.
  • end(fields) sets final fields and ends the span. It also accepts endTime as a Date or a number of milliseconds.
  • recordOutputChunk() records the arrival of a streamed output chunk. See Measure streaming latency.
  • traceId, spanId, traceparent, and isRecording describe the span.
  • span and context give the raw OpenTelemetry span and a context with the span active.

Before init(), or when the SDK is off, startSpan() returns a handle that does nothing. Its traceparent is null and isRecording is false.

Span types

The type option sets gen_ai.operation.name and decides which attributes hold input and output. The default type is "span".

type gen_ai.operation.name Input attribute Output attribute
"span" function gen_ai.input.messages gen_ai.output.messages
"generation" chat gen_ai.input.messages gen_ai.output.messages
"tool" execute_tool gen_ai.tool.call.arguments gen_ai.tool.call.result
"agent" invoke_agent gen_ai.input.messages gen_ai.output.messages
"embedding" embeddings gen_ai.input.messages gen_ai.output.messages

Span fields

startSpan(), startActiveSpan(), update(), end(), and updateActiveSpan() accept these fields:

Field Type Exported as
input unknown The input attribute for the span type
output unknown The output attribute for the span type
systemInstructions unknown gen_ai.system_instructions
model string gen_ai.request.model
provider string gen_ai.provider.name
responseModel string gen_ai.response.model
responseId string gen_ai.response.id
usage TokenUsage gen_ai.usage.* token attributes
costUsd number gen_ai.usage.cost
finishReason string A one-item gen_ai.response.finish_reasons array
outputType string gen_ai.output.type
temperature number gen_ai.request.temperature
topP number gen_ai.request.top_p
topK number gen_ai.request.top_k
maxTokens number gen_ai.request.max_tokens
stopSequences string[] gen_ai.request.stop_sequences
seed number gen_ai.request.seed
frequencyPenalty number gen_ai.request.frequency_penalty
presencePenalty number gen_ai.request.presence_penalty
timeToFirstChunkMs number gen_ai.response.time_to_first_chunk, converted to seconds
toolName string gen_ai.tool.name
toolCallId string gen_ai.tool.call.id
toolDescription string gen_ai.tool.description
agentName string gen_ai.agent.name
agentId string gen_ai.agent.id
metadata Record<string, unknown> td.metadata.<key> on this span
attributes OpenTelemetry attributes Raw attributes, applied last
name string Renames the span
error unknown Error status, error.type, and an exception event

costUsd overrides the server’s cost calculation. Without it, the server estimates cost from token usage when it has pricing for the model. See Cost.

Non-string metadata values are stored as JSON strings. The SDK drops the metadata keys userId, sessionId, user_id, and session_id; set the user and session with propagateAttributes() instead.

startSpan() and startActiveSpan() also accept these options:

Option Type Default
type SpanType "span"
parent ParentRef The active context
startTime Date | number Now
kind OpenTelemetry SpanKind INTERNAL
links OpenTelemetry Link[] None
captureInput boolean The init() value
captureOutput boolean The init() value

Token usage

TokenUsage accepts these counts:

Field Attribute
inputTokens gen_ai.usage.input_tokens
outputTokens gen_ai.usage.output_tokens
totalTokens gen_ai.usage.total_tokens
cacheReadInputTokens gen_ai.usage.cache_read.input_tokens
cacheCreationInputTokens gen_ai.usage.cache_creation.input_tokens
reasoningOutputTokens gen_ai.usage.reasoning.output_tokens

Some providers price text, image, and audio tokens differently. For those, TokenUsage also accepts per-modality counts. Each maps to gen_ai.usage.<modality>.<kind>, for example imageInputTokens to gen_ai.usage.image.input_tokens:

Modality Input Output Cache read
Text textInputTokens textOutputTokens textCacheReadInputTokens
Image imageInputTokens imageOutputTokens imageCacheReadInputTokens
Audio audioInputTokens audioOutputTokens audioCacheReadInputTokens

Modality counts are parts of inputTokens and outputTokens, not additions to them. Send the totals too, and keep the sum of each kind’s modality counts at or below its total. See Cost for how the server prices modality counts.

Add user, session, and metadata

propagateAttributes() adds correlation attributes to every span and log created inside its callback:

await propagateAttributes(
  {
    userId: "user_123",
    sessionId: "session_456",
    metadata: { tenant: "acme" },
  },
  () => handleRequest(),
);
Field Attribute
userId user.id
sessionId gen_ai.conversation.id
metadata td.metadata.<key>

Calls can nest. The inner call’s values replace outer values that have the same key. propagateAttributes() works before init(), so you can set it up in middleware that loads first.

One trace per session

A root span with a session ID joins its session’s trace. The SDK derives the trace ID from the API key and the session ID, so every root span in the same session lands in one trace, even across separate requests and processes. A span with a real parent, such as a child span or a span continued from a traceparent, keeps that parent.

Root spans without a session ID each start their own trace.

Process sessions

A command-line tool that holds one conversation per process may have no session ID to pass. Set sessionMode: "process" to have the SDK generate one:

init({ sessionMode: "process" });

The SDK generates a new random session ID each time init() turns the client on. It uses that ID only for spans and logs that have no session ID of their own, from propagateAttributes() or a gen_ai.conversation.id attribute. The ID ends when the client shuts down or a later init() replaces it. It is not saved between processes.

Do not use process sessions in a long-running server; they would put unrelated requests in one session. Pass a session ID per conversation with propagateAttributes() instead. This option does not configure the separate AI SDK or TanStack AI integrations.

Send logs

log() records an OpenTelemetry log in the active trace context:

log("model request complete", {
  level: "info",
  eventName: "model.complete",
  attributes: { attempt: 1, cached: false },
  timestamp: new Date(),
});
Option Type Default
level "debug" | "info" | "warn" | "error" "info"
eventName string None
attributes Record<string, unknown> None
timestamp Date | number The time of the call

Number and boolean attributes are stored as is. Other attribute values, and the message, go through mask, serialization, and truncation, as described in Control captured content. Propagated user, session, and metadata attributes are added to every log and replace caller attributes with the same key.

Continue a trace across services

getTraceparent() returns the W3C traceparent value for the active span, or null when no span is active. Send it to the downstream service, for example as a traceparent header. On the receiving side, pass it as parent:

const span = startSpan("downstream-work", {
  parent: request.headers.get("traceparent") ?? undefined,
});

try {
  await doWork();
  span.end();
} catch (error) {
  span.end({ error });
  throw error;
}

parent accepts a traceparent string, a SpanHandle, an OpenTelemetry SpanContext, or an OpenTelemetry Context. If the string is not a valid traceparent, the SDK uses the active context instead.

To read or write the headers on a carrier object, use injectW3cContext(context, carrier) and extractW3cContext(carrier). They handle traceparent and tracestate, and also baggage when you pass { includeBaggage: true }.

Control captured content

By default the SDK records span input and output. Turn either off for all spans in init(), or for one span with the captureInput and captureOutput options of startSpan() and observe().

To redact content instead of dropping it, pass a mask function. This example keeps outputs but replaces every model input:

init({
  mask(value, { key }) {
    if (key === "gen_ai.input.messages") return "[redacted]";
    return value;
  },
  maxAttributeLength: 16_384,
});

The SDK prepares each piece of captured content in three steps:

  1. It passes the value to mask, if you set one.
  2. It serializes the result as JSON. Strings are kept as they are.
  3. It truncates the string to maxAttributeLength. A truncated value ends with ...[truncated], and the marker counts toward the limit.

mask receives the content below. key is the attribute that the result will be written to:

Content key
Span input gen_ai.input.messages, or gen_ai.tool.call.arguments on tool spans
Span output gen_ai.output.messages, or gen_ai.tool.call.result on tool spans
systemInstructions gen_ai.system_instructions
Log message log.message
Log attributes other than numbers and booleans The attribute name

If mask returns undefined, the SDK writes nothing for that attribute. If mask throws, the SDK drops the value and reports the error to onError.

maxAttributeLength also truncates string metadata and log attributes, with the same marker. Other string attributes are cut at the same length without a marker.

See Privacy for server-side capture and redaction controls.

Errors and fail-open behavior

To mark a span as failed, pass error to update() or end(). An observed function does this for you. The SDK sets the span status to ERROR, sets error.type to the error’s name, and records an exception event with the message and stack trace.

The SDK never throws its own errors into your code. Failures during initialization, export, masking, and filtering go to onError. If onError itself throws, the SDK ignores that error. flush() and shutdown() never reject.

Without an API key, or with enabled: false, every SDK function does nothing.

If spanFilter throws, the SDK exports the span anyway and reports the error to onError.

Share the provider with other instrumentation

By default the SDK’s OpenTelemetry provider is private to the SDK. Set registerGlobal: true when other OpenTelemetry instrumentation in the same process should send its spans through the SDK. The SDK then registers its tracer provider, an AsyncLocalStorage context manager, and the W3C trace context propagator globally.

If another global tracer provider is already registered, registration fails and the SDK logs a warning. In that case, attach TelemetrySpanProcessor to the existing provider instead. See OpenTelemetry.

With global registration, the SDK exports only its own spans by default: those from the @telemetry-dev/sdk instrumentation scope. Pass spanFilter to choose a different set:

init({
  registerGlobal: true,
  spanFilter: (span) =>
    span.instrumentationScope.name === "@telemetry-dev/sdk" ||
    span.instrumentationScope.name.startsWith("my-app"),
});

shutdown() removes the global registrations. Calling init() again shuts down the current client first, then replaces it.

Automatic metrics

The SDK turns ended spans into OpenTelemetry histograms and sends them to telemetry.dev:

Metric Unit Recorded for
gen_ai.client.operation.duration s Generation, agent, embedding, and tool spans
gen_ai.client.token.usage {token} Generation, agent, and embedding spans with input or output token counts
gen_ai.client.operation.time_to_first_chunk s Generation spans with timeToFirstChunkMs
gen_ai.client.operation.time_per_output_chunk s Generation spans with two or more recorded output chunks

Plain "span" spans record no metrics. Spans that spanFilter rejects record no metrics.

In batched mode, the SDK sends metrics every 60 seconds. In immediate mode, it sends them only when you call flush() or shutdown().

Measure streaming latency

For a streamed model response, record when output arrives. Set timeToFirstChunkMs on the generation span, and call recordOutputChunk() on its handle each time a non-empty chunk arrives:

const span = startSpan("chat-stream", { type: "generation", model: "gpt-4o" });
const startedAt = performance.now();
let first = true;

for await (const chunk of stream) {
  if (first) {
    span.update({ timeToFirstChunkMs: performance.now() - startedAt });
    first = false;
  }
  span.recordOutputChunk();
}

span.end({ finishReason: "stop" });

After the first chunk, each interval between chunks feeds the time_per_output_chunk histogram. The SDK times chunks when your loop receives them, so time your code spends between reads counts toward the interval. recordOutputChunk() uses performance.now() by default. If you pass your own timestamp, use the same clock for every chunk of the span.

Flush and shut down

flush() sends pending traces, logs, and metrics. shutdown() sends pending data, releases resources, and turns the client off. Calling either one again is safe.

Pick the pattern for your runtime:

  • Long-running server. Keep the default batched mode. Call shutdown() when the process stops cleanly.
  • Short script or CLI. Call await shutdown() before the process exits. Otherwise the last batch can be lost.
  • Serverless function. Use immediate export, and flush before the runtime freezes. In batched mode, spans wait for a timer that a frozen runtime may never run.
init({ exportMode: "immediate" });

// Handle the request.
await flush();

If the runtime can keep work alive after the response, such as waitUntil() on a Cloudflare Workers execution context, pass it as waitUntil. flush() and shutdown() then hand the export promise to the runtime and return without waiting:

init({
  exportMode: "immediate",
  waitUntil: (promise) => executionContext.waitUntil(promise),
});

Last updated on October 18, 2026

Was this page helpful?