axonpush
TypeScript SDK

Telemetry

OpenTelemetry-native GenAI tracing, reuse your own TracerProvider and ship standard gen_ai.* spans to axonpush over OTLP.

@axonpush/sdk/telemetry is the OpenTelemetry-native path. It reuses your application’s own TracerProvider, attaches a BatchSpanProcessor, and ships spans as real OTLP over HTTP to POST {base}/v1/traces. The spans follow the GenAI semantic conventions (gen_ai.operation.name, gen_ai.request.model, gen_ai.usage.*), so they are portable to any OTLP backend, not just axonpush, and there is no proprietary event format to translate.

Recommended over the legacy exporter

This is the recommended path for new code. The event-model OpenTelemetry span exporter (AxonPushSpanExporter) still works and is supported, but it converts each span into a proprietary app.span event through the events API. This module emits standard OTLP instead and is now the preferred way to trace GenAI calls.

Install

The OpenTelemetry SDK ships as optional peers, loaded lazily so the base SDK runs without them:

npm install @axonpush/sdk @opentelemetry/api @opentelemetry/sdk-trace-node @opentelemetry/exporter-trace-otlp-http @opentelemetry/resources

Quickstart

Set AXONPUSH_BASE_URL, AXONPUSH_API_KEY and AXONPUSH_CHANNEL_ID in the environment, then configure once at startup, wrap each model call in a GenAI span, and record usage when the response returns.

import {
  configureTelemetry,
  genaiSpan,
  recordGenaiResponse,
  recordGenaiContent,
} from "@axonpush/sdk/telemetry";

const handle = await configureTelemetry({
  serviceName: "my-agent",
  environment: "prod",
});
const tracer = handle.tracer();

const span = genaiSpan(tracer, {
  operation: "chat",
  requestModel: "gpt-4o",
  system: "openai",
  agentName: "research-agent",
});
try {
  const response = await callModel();

  recordGenaiResponse(span, {
    responseModel: "gpt-4o",
    inputTokens: 1200,
    outputTokens: 350,
    reasoningTokens: 64,
    cacheReadTokens: 900,
    cacheWriteTokens: 300,
  });
  recordGenaiContent(span, { prompt, completion: response.text });
} finally {
  span.end();
}

await handle.flush(); // serverless: call at the end of each invocation

configureTelemetry resolves baseUrl, apiKey and channelId from the option first, then the matching AXONPUSH_* variable. The exporter posts to {base}/v1/traces with X-API-Key for auth and X-Axonpush-Channel for routing. It returns { flush, shutdown, tracer }. Unlike Python’s context manager, you own the span lifecycle, call span.end(), typically in a finally.

Reuse the app’s TracerProvider

configureTelemetry never replaces a provider you already own:

  • Pass tracerProvider and it attaches to that.
  • If the global provider is already a real SDK provider, it attaches to it.
  • Otherwise it creates a NodeTracerProvider with a resource describing the service (service.name, deployment.environment.name, service.version) and registers it as the global provider.

So an app that already has OpenTelemetry keeps its own instrumentation and just gains the axonpush exporter. Attaching for the same (endpoint, channel) twice is a no-op.

Do not double-instrument a call. If a framework integration (LangChain, Mastra, the Vercel AI middleware, AxonPushSpanExporter, …) already produces a span or event for the same model call, do not also wrap it with genaiSpan, you would emit the operation twice. Pick one plane: native OTLP through this module, or the events plane through the integrations.

Spanning a GenAI call

genaiSpan starts a CLIENT span named "{operation} {requestModel}" (override with name). It sets gen_ai.operation.name and gen_ai.request.model, and when supplied gen_ai.system / gen_ai.provider.name from system, and gen_ai.agent.name from agentName.

recordGenaiResponse records the response and usage attributes, including the cache-token counts that map to the semconv gen_ai.usage.cache_read_input_tokens and gen_ai.usage.cache_write_input_tokens:

recordGenaiResponse(span, {
  responseModel: "gpt-4o",
  finishReasons: ["stop"],
  inputTokens: 1200,
  outputTokens: 350,
  reasoningTokens: 64,
  cacheReadTokens: 900,
  cacheWriteTokens: 300,
});

Content capture and redaction

Prompts and completions are off by default. recordGenaiContent emits them as span events (gen_ai.content.prompt / gen_ai.content.completion) rather than attributes, so large payloads do not inflate the span, and it is gated by the capture policy:

ModeBehaviour
metadata_only (default)Content is dropped; only models and token counts are kept.
redactedLong strings are truncated to short previews.
fullContent is kept verbatim.

Credential-shaped keys are always stripped regardless of mode, and any keys you name in redactKeys are removed too:

recordGenaiContent(span, {
  prompt,
  completion: response.text,
  contentCapture: "redacted",
  redactKeys: ["email", "ssn"],
});

Flush on shutdown

The BatchSpanProcessor exports in the background. Drain it on a graceful exit, and on serverless, where the container is frozen between invocations, call flush() at the end of each invocation. configureTelemetry logs a hint when it detects a serverless host, and flushAfterInvocation is re-exported to wrap a Lambda handler for you.

import { flushAfterInvocation } from "@axonpush/sdk/telemetry";

await handle.flush(2000); // resolves true if it drained in time
await handle.shutdown();  // flush and stop the processor; idempotent

export const handler = flushAfterInvocation(handle, async (event) => {
  // ...
});