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
AsyncLocalStorageas 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_compatflag. - 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/sdkpnpm add @telemetry-dev/sdkyarn add @telemetry-dev/sdkbun 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, thenanonymous. - 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 acceptsendTimeas aDateor a number of milliseconds.recordOutputChunk()records the arrival of a streamed output chunk. See Measure streaming latency.traceId,spanId,traceparent, andisRecordingdescribe the span.spanandcontextgive 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:
- It passes the value to
mask, if you set one. - It serializes the result as JSON. Strings are kept as they are.
- 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),
});