@nightowlsdev/telemetry-core
TelemetryShared SwarmSpan-to-OpenTelemetry replay engine that the OTLP and Langfuse telemetry adapters are built on.
What it does
Takes the pre-recorded run/generation/tool SwarmSpans that @nightowlsdev/core's engine emits at the end of a swarm run and replays them through a real OpenTelemetry BasicTracerProvider. It derives a deterministic 32-hex trace id from the run id (UUIDs are dash-stripped; other ids are sha256-hashed), nests generation/tool children under the run span, maps generations to gen_ai.* semantic conventions (model, input/output token usage, cost_usd), replays the original epoch-ms timings, and awaits forceFlush() so nothing is lost in serverless invocations. You normally don't depend on it directly: backend adapters call replayerExporter({ exporter | processor }) and return the resulting TelemetryExporter from their own factory.
Install
pnpm add @nightowlsdev/telemetry-coreKey exports
- createSpanReplayer
- replayerExporter
- deriveTraceId
- deriveRootSpanId
- KIND_MAP
- toOtelAttributes
- SpanReplayer (type)
- SpanReplayerOpts (type)
Usage
import { replayerExporter } from "@nightowlsdev/telemetry-core";
// Backend adapters build on this, wrap an OTel exporter into a TelemetryExporter.
export function myTelemetry(exporter) {
return replayerExporter({ exporter });
}What it provides
telemetry-core is the sink seam the whole telemetry family is built on: the shared SwarmSpan-to-OpenTelemetry replay engine. When a swarm run finishes, core's engine hands it a batch of pre-recorded SwarmSpans (one run, one generation per model call, one tool per tool call) and it replays them through a real OpenTelemetry BasicTracerProvider — deriving a deterministic 32-hex trace id, nesting the children under the run span, and mapping generations to gen_ai.* semantic conventions. It also ships engine-independent helpers (aisdkTelemetry, withEmbeddingSpan) for instrumenting raw, non-swarm AI-SDK and embedding calls through the same pipeline.
When to use it
- You are building a new telemetry backend adapter — wrap your SpanProcessor (or SpanExporter) with replayerExporter and return the TelemetryExporter from your factory; the two shipped exporters are two-line shells over this.
- You make raw, non-swarm AI-SDK calls (a Trigger.dev enrichment pipeline, a one-shot generateText, an embedMany) and want them to land in the same Langfuse/OTel backend with gen_ai.* attributes + tokens + cost.
- You need the low-level pieces — deriveTraceId to correlate a run id to its trace, or toOtelAttributes to map a SwarmSpan's attributes yourself.
When not to
- You just want swarm runs to show up in a backend — install @nightowlsdev/telemetry-otel or @nightowlsdev/telemetry-langfuse instead; they bring telemetry-core transitively and give you a one-call factory. You only depend on this directly to build a custom exporter or instrument raw calls.
- You want business/product metrics — counts, gauges, period-over-period aggregates — rather than distributed traces. That is the separate metric series store, not a tracing plane.
- You need edge/browser telemetry — trace-id derivation uses node:crypto, so this is a Node module.
Alternatives
- @nightowlsdev/telemetry-otel / @nightowlsdev/telemetry-langfuseYou want spans in a real backend and don't need a custom sink. These prebuilt exporters already wrap telemetry-core; reach for telemetry-core directly only to build a third backend or to instrument raw AI-SDK calls.
- core's customTelemetry(fn)You want to receive the raw SwarmSpan[] and do something non-OTel with it (log them, write to your own table, forward to a bespoke API). customTelemetry wraps a plain (spans) => void|Promise<void> into a TelemetryExporter with no OpenTelemetry involved.
Strengths
- One replay engine, two-line adapters — a new backend is just a SpanProcessor away; telemetry-otel and telemetry-langfuse differ ONLY in the processor they hand in.
- Deterministic trace ids — deriveTraceId maps a run to the same 32-hex trace every time (UUIDs are dash-stripped; other ids sha256-hashed), and children nest under the run span.
- gen_ai.* semantic conventions out of the box — generation spans carry model, input/output token usage, and cost_usd, so any OTLP backend or Langfuse's default filter recognizes them.
- forceFlush is awaited on every batch, so spans aren't lost in a short-lived / serverless invocation.
- Engine-independent AI-SDK helpers — instrument raw generateText / embedMany calls with the SAME exporter the swarm uses, no parallel pipeline.
- @mastra-free: the built .d.ts has zero @mastra references (the engine wall) — it consumes only core's SwarmSpan / TelemetryExporter.
Limits & trade-offs
- It replays PRE-RECORDED spans after the run's batch is handed over — it is not a live/streaming tracer, so you see the trace when the run exports, not mid-generation (the timings themselves are faithful epoch-ms replays).
- Node-only: deriveTraceId / deriveRootSpanId use node:crypto, so this is not an edge/browser module.
- Not a metrics system — traces and spans only; counts, gauges, and aggregates are a different plane.
- You rarely depend on it directly for swarm telemetry — most value flows through the two exporter packages; a direct dependency is for custom exporters or raw-call instrumentation.
- The raw-call helpers emit through the GLOBAL OTel tracer, so their spans only export if the host has registered a provider carrying the same SpanProcessor — the swarm replayer's own provider is not global.
How it works
createSpanReplayer builds one long-lived BasicTracerProvider around a single SpanProcessor — either the SpanProcessor you pass (telemetry-langfuse) or a SimpleSpanProcessor wrapping the SpanExporter you pass (telemetry-otel). replay(spans) groups spans by their source run id, derives a 32-hex trace id, forces it via a synthetic parent SpanContext, opens the run span first and parents the generation/tool children under it (trace.setSpan), replays the original epoch-ms start/end times, maps generation attributes to gen_ai.*, then awaits provider.forceFlush(). replayerExporter wraps that replayer as the TelemetryExporter core's engine calls at run end. The aisdkTelemetry / withEmbeddingSpan helpers sit outside that flow: they emit standard gen_ai.* spans through the active OTel tracer so a raw (non-swarm) call lands via whatever provider the host has registered globally.
Examples
Build a backend adapter
Wrap any SpanProcessor as a TelemetryExporter — this is exactly what telemetry-otel / telemetry-langfuse do.
import { replayerExporter } from "@nightowlsdev/telemetry-core";
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
export function myTelemetry(url: string) {
const processor = new BatchSpanProcessor(new OTLPTraceExporter({ url }));
return replayerExporter({ processor, serviceName: "nightowls" });
}Instrument a raw, non-swarm AI-SDK call
aisdkTelemetry returns experimental_telemetry settings — the call lands with gen_ai.* + tokens + cost via the host's provider.
import { aisdkTelemetry } from "@nightowlsdev/telemetry-core";
import { generateText } from "ai";
await generateText({
model,
prompt: "Summarize this ticket.",
experimental_telemetry: aisdkTelemetry({
functionId: "summarize",
metadata: { tenantId, jobId },
}),
});Wrap an embedding call in a gen_ai.* span
The AI SDK's experimental_telemetry callbacks don't fire for embeddings, so withEmbeddingSpan records model + value count (+ tokens) itself.
import { withEmbeddingSpan } from "@nightowlsdev/telemetry-core";
import { embedMany } from "ai";
const res = await withEmbeddingSpan(
{ functionId: "embedding", model: "text-embedding-3-small", count: values.length },
() => embedMany({ model, values }),
);Doing the parts it doesn't support
- Routing raw-call spans to the same backend as the swarmaisdkTelemetry / withEmbeddingSpan emit through the GLOBAL OTel tracer, so register a provider carrying your exporter's SpanProcessor once at startup (e.g. @langfuse/otel's NodeSDK setup, or the OpenTelemetry Node SDK). The swarm replayer builds its own provider and does not register it globally.
- A non-OpenTelemetry sinkSkip telemetry-core entirely and use core's customTelemetry(fn) — it hands you the raw SwarmSpan[] so you can log them, persist them, or forward them to a bespoke API with no OTel dependency.
- Sending to two backends at onceCompose exporters with core's compositeTelemetry([...]) (or pass defineSwarm a bare telemetry array). Each exporter is isolated with Promise.allSettled, so a throwing backend never blocks the others or the run.
Related
- telemetry-otel — The OTLP/HTTP exporter built on this — ship swarm traces to Datadog, Honeycomb, Grafana, or any OTLP backend.
- telemetry-langfuse — The Langfuse v5 exporter built on this — LLM-native generations with model, tokens, and cost.
- core — Emits the SwarmSpan batch and defines TelemetryExporter, compositeTelemetry, and customTelemetry.
- runner-background — Where raw non-swarm AI-SDK enrichment calls typically live (Trigger.dev tasks) — the reason the aisdkTelemetry helper exists.