@telemetry-dev/otel 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/context.ts ADDED
@@ -0,0 +1,139 @@
1
+ import {
2
+ type Attributes,
3
+ type Context,
4
+ context as apiContext,
5
+ type ContextManager,
6
+ createContextKey,
7
+ ROOT_CONTEXT,
8
+ } from "@opentelemetry/api";
9
+ import type { AsyncLocalStorage } from "node:async_hooks";
10
+
11
+ import { jsonAttr } from "./attrs.ts";
12
+ import { diag } from "./debug.ts";
13
+
14
+ type AlsConstructor = new <T>() => AsyncLocalStorage<T>;
15
+ type RuntimeGlobal = typeof globalThis & {
16
+ AsyncLocalStorage?: AlsConstructor;
17
+ process?: {
18
+ getBuiltinModule?: (id: string) => { AsyncLocalStorage?: AlsConstructor } | undefined;
19
+ };
20
+ };
21
+
22
+ // Synchronous, no `node:` static import, no top-level await — the dist must stay
23
+ // require(esm)-compatible and runtime-neutral. Vercel Edge exposes AsyncLocalStorage as a global;
24
+ // Node >= 20.19 and Workers with nodejs_compat expose process.getBuiltinModule.
25
+ function loadAls(): AlsConstructor | undefined {
26
+ const g: RuntimeGlobal = globalThis;
27
+ if (g.AsyncLocalStorage) return g.AsyncLocalStorage;
28
+ try {
29
+ return g.process?.getBuiltinModule?.("node:async_hooks")?.AsyncLocalStorage;
30
+ } catch {}
31
+ return undefined;
32
+ }
33
+
34
+ const AlsCtor = loadAls();
35
+
36
+ export const als: AsyncLocalStorage<Context> | undefined = AlsCtor
37
+ ? new AlsCtor<Context>()
38
+ : undefined;
39
+
40
+ /**
41
+ * The active context: our AsyncLocalStorage when available, falling back to the OTel global
42
+ * context (which joins a host app's own OTel setup when one is registered).
43
+ */
44
+ export function activeContext(): Context {
45
+ return als?.getStore() ?? apiContext.active();
46
+ }
47
+
48
+ /** Run `fn` with `ctx` active in both our ALS and the global OTel context manager. */
49
+ export function withContext<T>(ctx: Context, fn: () => T): T {
50
+ const run = () => apiContext.with(ctx, fn);
51
+ return als ? als.run(ctx, run) : run();
52
+ }
53
+
54
+ export const PROPAGATED_KEY = createContextKey("telemetry.dev propagated attributes");
55
+
56
+ export interface PropagatedAttributes {
57
+ /** Stamped as user.id on every span and log record in scope. */
58
+ userId?: string;
59
+ /** Stamped as gen_ai.conversation.id on every span and log record in scope. */
60
+ sessionId?: string;
61
+ /** Stamped as td.metadata.<key> on every span and log record in scope. */
62
+ metadata?: Record<string, unknown>;
63
+ }
64
+
65
+ const RESERVED_METADATA_KEYS = new Set(["userId", "sessionId", "user_id", "session_id"]);
66
+
67
+ export function buildPropagatedAttributes(attrs: PropagatedAttributes): Attributes {
68
+ const out: Attributes = {};
69
+ if (attrs.userId !== undefined) out["user.id"] = attrs.userId;
70
+ if (attrs.sessionId !== undefined) out["gen_ai.conversation.id"] = attrs.sessionId;
71
+ if (attrs.metadata) {
72
+ for (const [key, value] of Object.entries(attrs.metadata)) {
73
+ if (RESERVED_METADATA_KEYS.has(key)) {
74
+ diag.debug(
75
+ `metadata key "${key}" is reserved; use the userId/sessionId fields of propagateAttributes`,
76
+ );
77
+ continue;
78
+ }
79
+ const attr = typeof value === "string" ? value : jsonAttr(value);
80
+ if (attr !== undefined) out[`td.metadata.${key}`] = attr;
81
+ }
82
+ }
83
+ return out;
84
+ }
85
+
86
+ export function propagatedFromContext(ctx: Context): Attributes | undefined {
87
+ return ctx.getValue(PROPAGATED_KEY) as Attributes | undefined;
88
+ }
89
+
90
+ /**
91
+ * Stamp correlation attributes (user, session/conversation, metadata) on every span and log
92
+ * record created inside `fn`. Inner scopes merge over outer ones per key. Works before init().
93
+ */
94
+ export function propagateAttributes<T>(attributes: PropagatedAttributes, fn: () => T): T {
95
+ const base = activeContext();
96
+ const merged = { ...propagatedFromContext(base), ...buildPropagatedAttributes(attributes) };
97
+ return withContext(base.setValue(PROPAGATED_KEY, merged), fn);
98
+ }
99
+
100
+ /** Minimal ContextManager over our AsyncLocalStorage, registered only for registerGlobal. */
101
+ export class AlsContextManager implements ContextManager {
102
+ constructor(private readonly storage: AsyncLocalStorage<Context>) {}
103
+
104
+ active(): Context {
105
+ return this.storage.getStore() ?? ROOT_CONTEXT;
106
+ }
107
+
108
+ with<A extends unknown[], F extends (...args: A) => ReturnType<F>>(
109
+ context: Context,
110
+ fn: F,
111
+ thisArg?: ThisParameterType<F>,
112
+ ...args: A
113
+ ): ReturnType<F> {
114
+ const cb = thisArg == null ? fn : fn.bind(thisArg);
115
+ return this.storage.run(context, cb as (...args: A) => ReturnType<F>, ...args);
116
+ }
117
+
118
+ bind<T>(context: Context, target: T): T {
119
+ if (typeof target === "function") {
120
+ const storage = this.storage;
121
+ const bound = function (this: unknown, ...args: unknown[]) {
122
+ return storage.run(context, () =>
123
+ (target as (...a: unknown[]) => unknown).apply(this, args),
124
+ );
125
+ };
126
+ return bound as T;
127
+ }
128
+ return target;
129
+ }
130
+
131
+ enable(): this {
132
+ return this;
133
+ }
134
+
135
+ disable(): this {
136
+ this.storage.disable();
137
+ return this;
138
+ }
139
+ }
package/src/debug.ts ADDED
@@ -0,0 +1,32 @@
1
+ import type { LogLevel, SdkLogLevel } from "./config.ts";
2
+
3
+ const ORDER: Record<SdkLogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3, silent: 4 };
4
+
5
+ let currentLevel: SdkLogLevel = "warn";
6
+
7
+ export function setLogLevel(level: SdkLogLevel): void {
8
+ currentLevel = level;
9
+ }
10
+
11
+ function emit(level: LogLevel, args: unknown[]): void {
12
+ if (ORDER[level] < ORDER[currentLevel]) return;
13
+ // eslint-disable-next-line no-console
14
+ console[level]("[telemetry.dev]", ...args);
15
+ }
16
+
17
+ export const diag = {
18
+ debug: (...args: unknown[]) => emit("debug", args),
19
+ info: (...args: unknown[]) => emit("info", args),
20
+ warn: (...args: unknown[]) => emit("warn", args),
21
+ error: (...args: unknown[]) => emit("error", args),
22
+ };
23
+
24
+ /** Fail-open guard: SDK internals report through onError + diagnostics, never into user code. */
25
+ export function reportError(onError: ((error: unknown) => void) | undefined, error: unknown): void {
26
+ try {
27
+ onError?.(error);
28
+ } catch {
29
+ // onError itself must never propagate
30
+ }
31
+ diag.error(error);
32
+ }
package/src/index.ts ADDED
@@ -0,0 +1,46 @@
1
+ export { jsonAttr, omitUndefined, SCOPE_NAME, SCOPE_VERSION } from "./attrs.ts";
2
+ export {
3
+ type BatchOptions,
4
+ DEFAULT_BASE_URL,
5
+ DEFAULT_BATCH,
6
+ type ExportMode,
7
+ type LogLevel,
8
+ resolveEnv,
9
+ type SdkLogLevel,
10
+ } from "./config.ts";
11
+ export {
12
+ activeContext,
13
+ als,
14
+ AlsContextManager,
15
+ buildPropagatedAttributes,
16
+ propagateAttributes,
17
+ type PropagatedAttributes,
18
+ PROPAGATED_KEY,
19
+ propagatedFromContext,
20
+ withContext,
21
+ } from "./context.ts";
22
+ export { diag, reportError, setLogLevel } from "./debug.ts";
23
+ export {
24
+ BATCHED_METRIC_INTERVAL_MS,
25
+ createMetricsPipeline,
26
+ DORMANT_INTERVAL_MS,
27
+ DURATION_BUCKETS,
28
+ type MetricsPipeline,
29
+ TOKEN_BUCKETS,
30
+ } from "./metrics.ts";
31
+ export {
32
+ createTelemetrySpanExporter,
33
+ TelemetrySpanProcessor,
34
+ type TelemetrySpanProcessorOptions,
35
+ } from "./otel.ts";
36
+ export { StampingSpanProcessor, type StampingProcessorOptions } from "./processor.ts";
37
+ export {
38
+ createLogExporter,
39
+ createMetricExporter,
40
+ createTraceExporter,
41
+ maybeGzip,
42
+ otlpHeaders,
43
+ type OtlpTarget,
44
+ postOtlp,
45
+ type Transport,
46
+ } from "./transport.ts";
package/src/metrics.ts ADDED
@@ -0,0 +1,91 @@
1
+ import type { Attributes } from "@opentelemetry/api";
2
+ import type { Resource } from "@opentelemetry/resources";
3
+ import {
4
+ MeterProvider,
5
+ PeriodicExportingMetricReader,
6
+ type PushMetricExporter,
7
+ } from "@opentelemetry/sdk-metrics";
8
+ import type { ReadableSpan } from "@opentelemetry/sdk-trace-base";
9
+
10
+ import { omitUndefined, SCOPE_NAME, SCOPE_VERSION } from "./attrs.ts";
11
+
12
+ // Histogram bucket boundaries from the OTel GenAI semantic-convention recommendations for
13
+ // gen_ai.client.operation.duration (seconds) and gen_ai.client.token.usage ({token}).
14
+ export const DURATION_BUCKETS = [
15
+ 0.01, 0.02, 0.04, 0.08, 0.16, 0.32, 0.64, 1.28, 2.56, 5.12, 10.24, 20.48, 40.96, 81.92,
16
+ ];
17
+ export const TOKEN_BUCKETS = [
18
+ 1, 4, 16, 64, 256, 1024, 4096, 16384, 65536, 262144, 1048576, 4194304, 16777216, 67108864,
19
+ ];
20
+
21
+ const DURATION_OPERATIONS = new Set(["chat", "invoke_agent", "embeddings", "execute_tool"]);
22
+ const TOKEN_OPERATIONS = new Set(["chat", "invoke_agent", "embeddings"]);
23
+
24
+ // Dormant interval for immediate mode, where flush()/shutdown() drive the only exports.
25
+ export const DORMANT_INTERVAL_MS = 2 ** 31 - 1;
26
+ export const BATCHED_METRIC_INTERVAL_MS = 60_000;
27
+
28
+ export interface MetricsPipeline {
29
+ record(span: ReadableSpan): void;
30
+ forceFlush(): Promise<void>;
31
+ shutdown(): Promise<void>;
32
+ }
33
+
34
+ function stringAttr(value: unknown): string | undefined {
35
+ return typeof value === "string" ? value : undefined;
36
+ }
37
+
38
+ export function createMetricsPipeline({
39
+ resource,
40
+ exporter,
41
+ exportIntervalMillis,
42
+ }: {
43
+ resource: Resource;
44
+ exporter: PushMetricExporter;
45
+ exportIntervalMillis: number;
46
+ }): MetricsPipeline {
47
+ const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis });
48
+ const meterProvider = new MeterProvider({ resource, readers: [reader] });
49
+ const meter = meterProvider.getMeter(SCOPE_NAME, SCOPE_VERSION);
50
+
51
+ const durationHistogram = meter.createHistogram("gen_ai.client.operation.duration", {
52
+ unit: "s",
53
+ advice: { explicitBucketBoundaries: DURATION_BUCKETS },
54
+ });
55
+ const tokenHistogram = meter.createHistogram("gen_ai.client.token.usage", {
56
+ unit: "{token}",
57
+ advice: { explicitBucketBoundaries: TOKEN_BUCKETS },
58
+ });
59
+
60
+ const record = (span: ReadableSpan): void => {
61
+ const operation = span.attributes["gen_ai.operation.name"];
62
+ if (typeof operation !== "string" || !DURATION_OPERATIONS.has(operation)) return;
63
+ const attrs: Attributes = omitUndefined({
64
+ "gen_ai.operation.name": operation,
65
+ "gen_ai.provider.name": stringAttr(span.attributes["gen_ai.provider.name"]),
66
+ "gen_ai.request.model": stringAttr(span.attributes["gen_ai.request.model"]),
67
+ "gen_ai.response.model": stringAttr(span.attributes["gen_ai.response.model"]),
68
+ });
69
+ const durationSec = span.duration[0] + span.duration[1] / 1e9;
70
+ const errorType = stringAttr(span.attributes["error.type"]);
71
+ durationHistogram.record(
72
+ durationSec,
73
+ errorType !== undefined ? { ...attrs, "error.type": errorType } : attrs,
74
+ );
75
+ if (!TOKEN_OPERATIONS.has(operation)) return;
76
+ const inputTokens = span.attributes["gen_ai.usage.input_tokens"];
77
+ if (typeof inputTokens === "number") {
78
+ tokenHistogram.record(inputTokens, { ...attrs, "gen_ai.token.type": "input" });
79
+ }
80
+ const outputTokens = span.attributes["gen_ai.usage.output_tokens"];
81
+ if (typeof outputTokens === "number") {
82
+ tokenHistogram.record(outputTokens, { ...attrs, "gen_ai.token.type": "output" });
83
+ }
84
+ };
85
+
86
+ return {
87
+ record,
88
+ forceFlush: () => reader.forceFlush(),
89
+ shutdown: () => meterProvider.shutdown(),
90
+ };
91
+ }
package/src/otel.ts ADDED
@@ -0,0 +1,166 @@
1
+ import type { Context } from "@opentelemetry/api";
2
+ import { ExportResultCode } from "@opentelemetry/core";
3
+ import { resourceFromAttributes } from "@opentelemetry/resources";
4
+ import type {
5
+ ReadableSpan,
6
+ Span,
7
+ SpanExporter,
8
+ SpanProcessor,
9
+ } from "@opentelemetry/sdk-trace-base";
10
+
11
+ import {
12
+ type BatchOptions,
13
+ DEFAULT_BASE_URL,
14
+ DEFAULT_BATCH,
15
+ type ExportMode,
16
+ resolveEnv,
17
+ } from "./config.ts";
18
+ import { diag } from "./debug.ts";
19
+ import {
20
+ BATCHED_METRIC_INTERVAL_MS,
21
+ createMetricsPipeline,
22
+ DORMANT_INTERVAL_MS,
23
+ type MetricsPipeline,
24
+ } from "./metrics.ts";
25
+ import { StampingSpanProcessor } from "./processor.ts";
26
+ import {
27
+ createMetricExporter,
28
+ createTraceExporter,
29
+ otlpHeaders,
30
+ type Transport,
31
+ } from "./transport.ts";
32
+
33
+ export interface TelemetrySpanProcessorOptions {
34
+ /** telemetry.dev ingest key. Falls back to `TELEMETRY_DEV_API_KEY`; absent ⇒ no-op processor. */
35
+ apiKey?: string;
36
+ /** Falls back to `TELEMETRY_DEV_BASE_URL`, then `https://ingest.telemetry.dev`. */
37
+ baseUrl?: string;
38
+ /** "batched" (default) or "immediate". */
39
+ exportMode?: ExportMode;
40
+ batch?: BatchOptions;
41
+ /** Default for BYO providers: export EVERY span (the user deliberately attached the processor). */
42
+ spanFilter?: (span: ReadableSpan) => boolean;
43
+ /** Record GenAI duration/token histograms for exported chat/agent/embedding/tool spans. Default true. */
44
+ metrics?: boolean;
45
+ /** service.name on the metrics resource. Falls back to `OTEL_SERVICE_NAME`. */
46
+ serviceName?: string;
47
+ /** deployment.environment.name on the metrics resource. Falls back to `TELEMETRY_DEV_ENVIRONMENT`. */
48
+ environment?: string;
49
+ fetch?: typeof fetch;
50
+ onError?: (error: unknown) => void;
51
+ /** Advanced/test seam: replaces the OTLP fetch exporter. */
52
+ spanExporter?: SpanExporter;
53
+ }
54
+
55
+ /**
56
+ * BYO-OpenTelemetry surface: add this processor to your own TracerProvider (NodeSDK,
57
+ * registerOTel, …) to ship its spans to telemetry.dev. Also stamps propagateAttributes
58
+ * correlation attributes onto every span it sees.
59
+ */
60
+ export class TelemetrySpanProcessor implements SpanProcessor {
61
+ private readonly inner?: StampingSpanProcessor;
62
+ private readonly metrics?: MetricsPipeline;
63
+
64
+ constructor(options: TelemetrySpanProcessorOptions = {}) {
65
+ const env = resolveEnv();
66
+ const apiKey = options.apiKey ?? env.TELEMETRY_DEV_API_KEY;
67
+ const baseUrl = (options.baseUrl ?? env.TELEMETRY_DEV_BASE_URL ?? DEFAULT_BASE_URL).replace(
68
+ /\/+$/,
69
+ "",
70
+ );
71
+ const transport: Transport = {
72
+ fetchImpl: options.fetch ?? globalThis.fetch,
73
+ onError: options.onError,
74
+ };
75
+
76
+ const exporter =
77
+ options.spanExporter ??
78
+ (apiKey
79
+ ? createTraceExporter(
80
+ { url: `${baseUrl}/v1/traces`, headers: otlpHeaders(apiKey) },
81
+ transport,
82
+ )
83
+ : undefined);
84
+ if (!exporter) {
85
+ diag.debug(
86
+ "no api key (apiKey option or TELEMETRY_DEV_API_KEY); TelemetrySpanProcessor is a no-op",
87
+ );
88
+ return;
89
+ }
90
+
91
+ const exportMode = options.exportMode ?? "batched";
92
+ if (options.metrics !== false && apiKey) {
93
+ const resource = resourceFromAttributes({
94
+ "service.name": options.serviceName ?? env.OTEL_SERVICE_NAME ?? "unknown_service",
95
+ "deployment.environment.name":
96
+ options.environment ?? env.TELEMETRY_DEV_ENVIRONMENT ?? "production",
97
+ });
98
+ this.metrics = createMetricsPipeline({
99
+ resource,
100
+ exporter: createMetricExporter(
101
+ { url: `${baseUrl}/v1/metrics`, headers: otlpHeaders(apiKey) },
102
+ transport,
103
+ ),
104
+ exportIntervalMillis:
105
+ exportMode === "batched" ? BATCHED_METRIC_INTERVAL_MS : DORMANT_INTERVAL_MS,
106
+ });
107
+ }
108
+
109
+ const metrics = this.metrics;
110
+ this.inner = new StampingSpanProcessor({
111
+ exporter,
112
+ exportMode,
113
+ batch: { ...DEFAULT_BATCH, ...options.batch },
114
+ spanFilter: options.spanFilter,
115
+ recordMetrics: metrics ? (span) => metrics.record(span) : undefined,
116
+ onError: options.onError,
117
+ });
118
+ }
119
+
120
+ onStart(span: Span, parentContext: Context): void {
121
+ this.inner?.onStart(span, parentContext);
122
+ }
123
+
124
+ onEnd(span: ReadableSpan): void {
125
+ this.inner?.onEnd(span);
126
+ }
127
+
128
+ forceFlush(): Promise<void> {
129
+ return Promise.all([this.inner?.forceFlush(), this.metrics?.forceFlush()]).then(
130
+ () => undefined,
131
+ );
132
+ }
133
+
134
+ shutdown(): Promise<void> {
135
+ return Promise.all([this.inner?.shutdown(), this.metrics?.shutdown()]).then(() => undefined);
136
+ }
137
+ }
138
+
139
+ /** Raw OTLP/protobuf fetch exporter for users wiring their own BatchSpanProcessor. */
140
+ export function createTelemetrySpanExporter(
141
+ options: {
142
+ apiKey?: string;
143
+ baseUrl?: string;
144
+ fetch?: typeof fetch;
145
+ onError?: (error: unknown) => void;
146
+ } = {},
147
+ ): SpanExporter {
148
+ const env = resolveEnv();
149
+ const apiKey = options.apiKey ?? env.TELEMETRY_DEV_API_KEY;
150
+ const baseUrl = (options.baseUrl ?? env.TELEMETRY_DEV_BASE_URL ?? DEFAULT_BASE_URL).replace(
151
+ /\/+$/,
152
+ "",
153
+ );
154
+ if (!apiKey) {
155
+ diag.debug("no api key (apiKey option or TELEMETRY_DEV_API_KEY); exporter is a no-op");
156
+ return {
157
+ export: (_spans, resultCallback) => resultCallback({ code: ExportResultCode.SUCCESS }),
158
+ forceFlush: () => Promise.resolve(),
159
+ shutdown: () => Promise.resolve(),
160
+ };
161
+ }
162
+ return createTraceExporter(
163
+ { url: `${baseUrl}/v1/traces`, headers: otlpHeaders(apiKey) },
164
+ { fetchImpl: options.fetch ?? globalThis.fetch, onError: options.onError },
165
+ );
166
+ }
@@ -0,0 +1,72 @@
1
+ import type { Attributes, Context } from "@opentelemetry/api";
2
+ import {
3
+ BatchSpanProcessor,
4
+ type ReadableSpan,
5
+ SimpleSpanProcessor,
6
+ type Span,
7
+ type SpanExporter,
8
+ type SpanProcessor,
9
+ } from "@opentelemetry/sdk-trace-base";
10
+
11
+ import type { BatchOptions, ExportMode } from "./config.ts";
12
+ import { activeContext, PROPAGATED_KEY, propagatedFromContext } from "./context.ts";
13
+ import { reportError } from "./debug.ts";
14
+
15
+ export interface StampingProcessorOptions {
16
+ exporter: SpanExporter;
17
+ exportMode: ExportMode;
18
+ batch: Required<BatchOptions>;
19
+ spanFilter?: (span: ReadableSpan) => boolean;
20
+ recordMetrics?: (span: ReadableSpan) => void;
21
+ onError?: (error: unknown) => void;
22
+ }
23
+
24
+ /**
25
+ * The vendor span processor: stamps propagated correlation attributes onto every span at start,
26
+ * then filters, records auto-metrics, and delegates to a Batch/SimpleSpanProcessor at end.
27
+ */
28
+ export class StampingSpanProcessor implements SpanProcessor {
29
+ private readonly inner: SpanProcessor;
30
+
31
+ constructor(private readonly options: StampingProcessorOptions) {
32
+ this.inner =
33
+ options.exportMode === "immediate"
34
+ ? new SimpleSpanProcessor(options.exporter)
35
+ : new BatchSpanProcessor(options.exporter, options.batch);
36
+ }
37
+
38
+ onStart(span: Span, parentContext: Context): void {
39
+ try {
40
+ const propagated =
41
+ (parentContext.getValue(PROPAGATED_KEY) as Attributes | undefined) ??
42
+ propagatedFromContext(activeContext());
43
+ if (propagated) span.setAttributes(propagated);
44
+ } catch (error) {
45
+ reportError(this.options.onError, error);
46
+ }
47
+ this.inner.onStart(span, parentContext);
48
+ }
49
+
50
+ onEnd(span: ReadableSpan): void {
51
+ try {
52
+ if (this.options.spanFilter && !this.options.spanFilter(span)) return;
53
+ } catch (error) {
54
+ // A throwing filter must not drop spans; fall through and export.
55
+ reportError(this.options.onError, error);
56
+ }
57
+ try {
58
+ this.options.recordMetrics?.(span);
59
+ } catch (error) {
60
+ reportError(this.options.onError, error);
61
+ }
62
+ this.inner.onEnd(span);
63
+ }
64
+
65
+ forceFlush(): Promise<void> {
66
+ return this.inner.forceFlush();
67
+ }
68
+
69
+ shutdown(): Promise<void> {
70
+ return this.inner.shutdown();
71
+ }
72
+ }