@gnldev/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/dist/otlp.js ADDED
@@ -0,0 +1,218 @@
1
+ // @gnldev/otel/otlp — ZERO-dependency OTLP/HTTP JSON exporter. DOES NOT USE the OTel SDK (~8KB ethos):
2
+ // converts the journal (via toTraceSpans) by hand into an OTLP/HTTP JSON body and POSTs it with `fetch`.
3
+ // DIFFERENT from exportRun in index.ts: here there is no @opentelemetry/sdk-trace-base, just node:crypto +
4
+ // global fetch. The user sends to THEIR OWN collector/backend (Langfuse/Datadog/Jaeger/Honeycomb…) —
5
+ // NEVER to our server (see README's "no-telemetry" principle).
6
+ import { createHash } from 'node:crypto';
7
+ import { toTraceSpans } from '@gnldev/durable';
8
+ /** runId → 16-byte (32-hex) deterministic trace id — the same run yields the SAME trace on every export (idempotent). */
9
+ export function otlpTraceId(runId) {
10
+ return createHash('sha256').update(`gnl-otlp-trace:${runId}`).digest('hex').slice(0, 32);
11
+ }
12
+ /** (runId, seq|'root') → 8-byte (16-hex) deterministic span id. */
13
+ export function otlpSpanId(runId, seq) {
14
+ return createHash('sha256').update(`gnl-otlp-span:${runId}:${seq}`).digest('hex').slice(0, 16);
15
+ }
16
+ // OTLP Span.kind (proto enum SpanKind) — only the ones we use.
17
+ const SPAN_KIND_INTERNAL = 1;
18
+ const SPAN_KIND_CLIENT = 3;
19
+ function attrValue(v) {
20
+ if (v === undefined || v === null)
21
+ return undefined;
22
+ if (typeof v === 'string')
23
+ return { stringValue: v };
24
+ if (typeof v === 'boolean')
25
+ return { boolValue: v };
26
+ if (typeof v === 'number')
27
+ return Number.isInteger(v) ? { intValue: String(v) } : { doubleValue: v };
28
+ return { stringValue: String(v) };
29
+ }
30
+ /** Package-internal: shared with metrics.ts so the two bodies encode attributes identically.
31
+ * NOT re-exported from index.ts — this is not part of the package's public surface. */
32
+ export function toKv(attrs) {
33
+ const out = [];
34
+ for (const [key, v] of Object.entries(attrs)) {
35
+ const value = attrValue(v);
36
+ if (value)
37
+ out.push({ key, value });
38
+ }
39
+ return out;
40
+ }
41
+ /** ms → nanosecond string (BigInt: no precision loss). */
42
+ /** Package-internal, shared with metrics.ts: ms → the uint64-as-string nanoseconds OTLP/JSON wants. */
43
+ export function nano(ms) {
44
+ return (BigInt(Math.round(ms)) * 1000000n).toString();
45
+ }
46
+ /** Maps TraceSpan.attributes to the subset conforming to OTLP/gen_ai semconv + adds gnl.* identity fields. */
47
+ function spanAttributes(span) {
48
+ const src = span.attributes ?? {};
49
+ const out = {
50
+ 'gnl.run.id': span.runId,
51
+ 'gnl.kind': span.kind,
52
+ };
53
+ if (src['gen_ai.usage.input_tokens'] != null)
54
+ out['gen_ai.usage.input_tokens'] = src['gen_ai.usage.input_tokens'];
55
+ if (src['gen_ai.usage.output_tokens'] != null)
56
+ out['gen_ai.usage.output_tokens'] = src['gen_ai.usage.output_tokens'];
57
+ // The journal only knows the responding model (gen_ai.response.model) — we carry it over to semconv's
58
+ // request.model field (request/response model is usually the same; no harm if there's no alias).
59
+ if (src['gen_ai.response.model'] != null)
60
+ out['gen_ai.request.model'] = src['gen_ai.response.model'];
61
+ if (src['gen_ai.response.finish_reason'] != null)
62
+ out['gen_ai.response.finish_reason'] = src['gen_ai.response.finish_reason'];
63
+ if (src['tool.status'] != null)
64
+ out['gnl.tool.status'] = src['tool.status'];
65
+ return out;
66
+ }
67
+ /**
68
+ * PURE function (NO network, NO OTel SDK): converts `toTraceSpans` output into an OTLP/HTTP JSON body.
69
+ * 1 root span (`agent.run`) + 1 child span per journal entry; trace/span ids are derived deterministically
70
+ * from runId+seq → converting the same run twice yields the SAME ids (idempotent, replay-consistent).
71
+ */
72
+ export function toOtlpJson(spans, opts) {
73
+ const { runId } = opts;
74
+ const traceId = otlpTraceId(runId);
75
+ const rootSpanId = otlpSpanId(runId, 'root');
76
+ const now = opts.now ?? Date.now();
77
+ const withTs = spans.filter((s) => s.ts != null);
78
+ const firstTs = withTs[0]?.ts ?? now;
79
+ const lastTs = withTs[withTs.length - 1]?.ts ?? firstTs;
80
+ const otlpSpans = [
81
+ {
82
+ traceId,
83
+ spanId: rootSpanId,
84
+ name: 'agent.run',
85
+ kind: SPAN_KIND_INTERNAL,
86
+ startTimeUnixNano: nano(firstTs),
87
+ endTimeUnixNano: nano(Math.max(lastTs, firstTs)),
88
+ attributes: toKv({ 'gnl.run.id': runId }),
89
+ },
90
+ ];
91
+ for (let i = 0; i < spans.length; i++) {
92
+ const s = spans[i];
93
+ const start = s.ts ?? firstTs;
94
+ const end = spans[i + 1]?.ts ?? lastTs;
95
+ otlpSpans.push({
96
+ traceId,
97
+ spanId: otlpSpanId(runId, s.seq),
98
+ parentSpanId: rootSpanId,
99
+ name: s.name,
100
+ kind: s.kind === 'model' ? SPAN_KIND_CLIENT : SPAN_KIND_INTERNAL,
101
+ startTimeUnixNano: nano(start),
102
+ endTimeUnixNano: nano(Math.max(end, start)),
103
+ attributes: toKv(spanAttributes(s)),
104
+ });
105
+ }
106
+ const resourceAttrs = { ...(opts.resourceAttributes ?? {}) };
107
+ if (opts.serviceName)
108
+ resourceAttrs['service.name'] = opts.serviceName;
109
+ return {
110
+ resourceSpans: [
111
+ {
112
+ resource: { attributes: toKv(resourceAttrs) },
113
+ scopeSpans: [{ scope: { name: '@gnldev/otel' }, spans: otlpSpans }],
114
+ },
115
+ ],
116
+ };
117
+ }
118
+ // P2 opt-in retry/backoff for the POST above. known limitation: a 5xx or network
119
+ // blip on the fetch call silently loses the export (no OTel SDK behind this to retry/batch for us —
120
+ // that's the whole point of the zero-dep ~8KB path). Hand-rolled (no new dependency): a plain loop +
121
+ // `setTimeout`. ZERO behavior change when `retry` is not given — exactly one fetch call, same as before.
122
+ const DEFAULT_RETRY_ATTEMPTS = 3;
123
+ const MAX_RETRY_AFTER_MS = 30_000;
124
+ function defaultRetryOn(result) {
125
+ if (result instanceof Error)
126
+ return true; // network error (DNS/connection/timeout) — worth a retry
127
+ return result === 408 || result === 429 || (result >= 500 && result < 600);
128
+ }
129
+ function defaultBackoffMs(attempt) {
130
+ return 500 * 2 ** attempt;
131
+ }
132
+ function sleep(ms) {
133
+ return new Promise((resolve) => setTimeout(resolve, ms));
134
+ }
135
+ /** Retry-After (RFC 9110): either delta-seconds or an HTTP-date. Capped at MAX_RETRY_AFTER_MS so a
136
+ * misbehaving/huge value from an untrusted-ish collector can't stall the caller for a long time. */
137
+ function retryAfterMs(res) {
138
+ const header = res.headers?.get?.('retry-after');
139
+ if (!header)
140
+ return undefined;
141
+ const asSeconds = Number(header);
142
+ if (!Number.isNaN(asSeconds))
143
+ return Math.max(0, Math.min(asSeconds * 1000, MAX_RETRY_AFTER_MS));
144
+ const asDate = Date.parse(header);
145
+ if (!Number.isNaN(asDate))
146
+ return Math.max(0, Math.min(asDate - Date.now(), MAX_RETRY_AFTER_MS));
147
+ return undefined;
148
+ }
149
+ function delayFor(retry, attempt, res) {
150
+ if (res?.status === 429) {
151
+ const fromHeader = retryAfterMs(res);
152
+ if (fromHeader !== undefined)
153
+ return fromHeader;
154
+ }
155
+ const backoff = retry.backoffMs ?? defaultBackoffMs;
156
+ return typeof backoff === 'function' ? backoff(attempt) : backoff;
157
+ }
158
+ /** Runs `doFetch` up to `retry.attempts` times, retrying on `retry.retryOn`-eligible failures. Returns
159
+ * the last response (even if not ok — SAME "don't throw on 4xx/5xx" contract as the no-retry path) or
160
+ * rethrows the last network error once attempts are exhausted (SAME as the no-retry path, which never
161
+ * caught fetch's own throw either). */
162
+ async function fetchWithRetry(doFetch, retry) {
163
+ const attempts = retry.attempts ?? DEFAULT_RETRY_ATTEMPTS;
164
+ const retryOn = retry.retryOn ?? defaultRetryOn;
165
+ for (let i = 0; i < attempts; i++) {
166
+ const isLast = i === attempts - 1;
167
+ try {
168
+ const res = await doFetch();
169
+ if (res.ok || isLast || !retryOn(res.status))
170
+ return res;
171
+ await sleep(delayFor(retry, i, res));
172
+ }
173
+ catch (err) {
174
+ if (isLast || !retryOn(err))
175
+ throw err;
176
+ await sleep(delayFor(retry, i));
177
+ }
178
+ }
179
+ /* c8 ignore next */
180
+ throw new Error('unreachable'); // attempts >= 1 guaranteed by callers; loop always returns/throws above
181
+ }
182
+ /**
183
+ * Converts a run from the journal into OTLP/HTTP JSON and POSTs it to the given endpoint. DOES NOT USE
184
+ * the OTel SDK — only `fetch` (Node ≥18 global). The user sends to THEIR OWN backend; for tests use
185
+ * `toOtlpJson` without the network, or mock global `fetch` (see test/otlp.test.ts).
186
+ */
187
+ export async function exportRunToOtlp(reader, runId, opts) {
188
+ const spans = await toTraceSpans(reader, runId);
189
+ const payload = toOtlpJson(spans, {
190
+ runId,
191
+ serviceName: opts.serviceName,
192
+ resourceAttributes: opts.resourceAttributes,
193
+ now: opts.now,
194
+ });
195
+ const doFetch = () => fetch(opts.endpoint, {
196
+ method: 'POST',
197
+ headers: { 'content-type': 'application/json', ...(opts.headers ?? {}) },
198
+ body: JSON.stringify(payload),
199
+ // Do NOT follow redirects. The default is 'follow', and what travels here is a run's full trace —
200
+ // prompts, tool arguments, model output — under provider credentials. Runtimes strip
201
+ // `Authorization` across origins but not the headers these presets actually use (`x-api-key`,
202
+ // `x-honeycomb-team`, `x-bt-parent`), so a redirect from a mistyped or compromised endpoint hands
203
+ // both the key and the payload to whoever answers. A telemetry POST gains nothing from a
204
+ // redirect; failing loudly is the better trade.
205
+ redirect: 'error',
206
+ // Per ATTEMPT, not per export: see `timeoutMs` — a signal shared across retries would abort the
207
+ // second attempt before it started, turning the bound into a cap on the whole backoff instead.
208
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 30_000),
209
+ });
210
+ const res = opts.retry ? await fetchWithRetry(doFetch, opts.retry) : await doFetch();
211
+ return {
212
+ traceId: otlpTraceId(runId),
213
+ spans: payload.resourceSpans[0].scopeSpans[0].spans.length,
214
+ ok: res.ok,
215
+ status: res.status,
216
+ };
217
+ }
218
+ //# sourceMappingURL=otlp.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"otlp.js","sourceRoot":"","sources":["../src/otlp.ts"],"names":[],"mappings":"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","sourcesContent":["// @gnldev/otel/otlp — ZERO-dependency OTLP/HTTP JSON exporter. DOES NOT USE the OTel SDK (~8KB ethos):\n// converts the journal (via toTraceSpans) by hand into an OTLP/HTTP JSON body and POSTs it with `fetch`.\n// DIFFERENT from exportRun in index.ts: here there is no @opentelemetry/sdk-trace-base, just node:crypto +\n// global fetch. The user sends to THEIR OWN collector/backend (Langfuse/Datadog/Jaeger/Honeycomb…) —\n// NEVER to our server (see README's \"no-telemetry\" principle).\nimport { createHash } from 'node:crypto';\nimport { toTraceSpans } from '@gnldev/durable';\nimport type { JournalReader, TraceSpan } from '@gnldev/durable';\n\n/** runId → 16-byte (32-hex) deterministic trace id — the same run yields the SAME trace on every export (idempotent). */\nexport function otlpTraceId(runId: string): string {\n return createHash('sha256').update(`gnl-otlp-trace:${runId}`).digest('hex').slice(0, 32);\n}\n\n/** (runId, seq|'root') → 8-byte (16-hex) deterministic span id. */\nexport function otlpSpanId(runId: string, seq: number | 'root'): string {\n return createHash('sha256').update(`gnl-otlp-span:${runId}:${seq}`).digest('hex').slice(0, 16);\n}\n\n// OTLP Span.kind (proto enum SpanKind) — only the ones we use.\nconst SPAN_KIND_INTERNAL = 1;\nconst SPAN_KIND_CLIENT = 3;\n\nexport type OtlpAttributeValue =\n | { stringValue: string }\n | { intValue: string }\n | { doubleValue: number }\n | { boolValue: boolean };\n\nexport interface OtlpKeyValue {\n key: string;\n value: OtlpAttributeValue;\n}\n\nexport interface OtlpSpan {\n traceId: string;\n spanId: string;\n parentSpanId?: string;\n name: string;\n kind: number;\n /** In OTLP/JSON, uint64 fields are STRINGS to avoid precision loss (protobuf JSON mapping). */\n startTimeUnixNano: string;\n endTimeUnixNano: string;\n attributes: OtlpKeyValue[];\n}\n\nexport interface OtlpPayload {\n resourceSpans: Array<{\n resource: { attributes: OtlpKeyValue[] };\n scopeSpans: Array<{\n scope: { name: string };\n spans: OtlpSpan[];\n }>;\n }>;\n}\n\nexport interface ToOtlpJsonOptions {\n runId: string;\n /** resource attribute service.name. */\n serviceName?: string;\n /** Additional resource attributes (deployment.environment, etc.). */\n resourceAttributes?: Record<string, string | number | boolean>;\n /** Timestamp (ms) used when journal entries have no ts at all. Can be injected for determinism in tests; default Date.now(). */\n now?: number;\n}\n\nfunction attrValue(v: unknown): OtlpAttributeValue | undefined {\n if (v === undefined || v === null) return undefined;\n if (typeof v === 'string') return { stringValue: v };\n if (typeof v === 'boolean') return { boolValue: v };\n if (typeof v === 'number') return Number.isInteger(v) ? { intValue: String(v) } : { doubleValue: v };\n return { stringValue: String(v) };\n}\n\n/** Package-internal: shared with metrics.ts so the two bodies encode attributes identically.\n * NOT re-exported from index.ts — this is not part of the package's public surface. */\nexport function toKv(attrs: Record<string, unknown>): OtlpKeyValue[] {\n const out: OtlpKeyValue[] = [];\n for (const [key, v] of Object.entries(attrs)) {\n const value = attrValue(v);\n if (value) out.push({ key, value });\n }\n return out;\n}\n\n/** ms → nanosecond string (BigInt: no precision loss). */\n/** Package-internal, shared with metrics.ts: ms → the uint64-as-string nanoseconds OTLP/JSON wants. */\nexport function nano(ms: number): string {\n return (BigInt(Math.round(ms)) * 1_000_000n).toString();\n}\n\n/** Maps TraceSpan.attributes to the subset conforming to OTLP/gen_ai semconv + adds gnl.* identity fields. */\nfunction spanAttributes(span: TraceSpan): Record<string, unknown> {\n const src = span.attributes ?? {};\n const out: Record<string, unknown> = {\n 'gnl.run.id': span.runId,\n 'gnl.kind': span.kind,\n };\n if (src['gen_ai.usage.input_tokens'] != null) out['gen_ai.usage.input_tokens'] = src['gen_ai.usage.input_tokens'];\n if (src['gen_ai.usage.output_tokens'] != null) out['gen_ai.usage.output_tokens'] = src['gen_ai.usage.output_tokens'];\n // The journal only knows the responding model (gen_ai.response.model) — we carry it over to semconv's\n // request.model field (request/response model is usually the same; no harm if there's no alias).\n if (src['gen_ai.response.model'] != null) out['gen_ai.request.model'] = src['gen_ai.response.model'];\n if (src['gen_ai.response.finish_reason'] != null) out['gen_ai.response.finish_reason'] = src['gen_ai.response.finish_reason'];\n if (src['tool.status'] != null) out['gnl.tool.status'] = src['tool.status'];\n return out;\n}\n\n/**\n * PURE function (NO network, NO OTel SDK): converts `toTraceSpans` output into an OTLP/HTTP JSON body.\n * 1 root span (`agent.run`) + 1 child span per journal entry; trace/span ids are derived deterministically\n * from runId+seq → converting the same run twice yields the SAME ids (idempotent, replay-consistent).\n */\nexport function toOtlpJson(spans: TraceSpan[], opts: ToOtlpJsonOptions): OtlpPayload {\n const { runId } = opts;\n const traceId = otlpTraceId(runId);\n const rootSpanId = otlpSpanId(runId, 'root');\n const now = opts.now ?? Date.now();\n const withTs = spans.filter((s) => s.ts != null);\n const firstTs = withTs[0]?.ts ?? now;\n const lastTs = withTs[withTs.length - 1]?.ts ?? firstTs;\n\n const otlpSpans: OtlpSpan[] = [\n {\n traceId,\n spanId: rootSpanId,\n name: 'agent.run',\n kind: SPAN_KIND_INTERNAL,\n startTimeUnixNano: nano(firstTs),\n endTimeUnixNano: nano(Math.max(lastTs, firstTs)),\n attributes: toKv({ 'gnl.run.id': runId }),\n },\n ];\n\n for (let i = 0; i < spans.length; i++) {\n const s = spans[i]!;\n const start = s.ts ?? firstTs;\n const end = spans[i + 1]?.ts ?? lastTs;\n otlpSpans.push({\n traceId,\n spanId: otlpSpanId(runId, s.seq),\n parentSpanId: rootSpanId,\n name: s.name,\n kind: s.kind === 'model' ? SPAN_KIND_CLIENT : SPAN_KIND_INTERNAL,\n startTimeUnixNano: nano(start),\n endTimeUnixNano: nano(Math.max(end, start)),\n attributes: toKv(spanAttributes(s)),\n });\n }\n\n const resourceAttrs: Record<string, unknown> = { ...(opts.resourceAttributes ?? {}) };\n if (opts.serviceName) resourceAttrs['service.name'] = opts.serviceName;\n\n return {\n resourceSpans: [\n {\n resource: { attributes: toKv(resourceAttrs) },\n scopeSpans: [{ scope: { name: '@gnldev/otel' }, spans: otlpSpans }],\n },\n ],\n };\n}\n\n// P2 opt-in retry/backoff for the POST above. known limitation: a 5xx or network\n// blip on the fetch call silently loses the export (no OTel SDK behind this to retry/batch for us —\n// that's the whole point of the zero-dep ~8KB path). Hand-rolled (no new dependency): a plain loop +\n// `setTimeout`. ZERO behavior change when `retry` is not given — exactly one fetch call, same as before.\nconst DEFAULT_RETRY_ATTEMPTS = 3;\nconst MAX_RETRY_AFTER_MS = 30_000;\n\nexport interface OtlpRetryOptions {\n /** Total attempts including the first try (not \"extra retries\"). Default 3. */\n attempts?: number;\n /** Delay before the NEXT retry — fixed ms, or a function of the retry index (0 = delay before the\n * 2nd attempt, 1 = before the 3rd, ...). Default exponential: `500 * 2^n`. Ignored on a 429 that\n * carries a `Retry-After` header (see below). */\n backoffMs?: number | ((attempt: number) => number);\n /**\n * Decide whether a failure should be retried. Called with the HTTP status code for a non-throwing\n * response, or the thrown `Error` for a network failure. Default: network errors + 408/429/5xx\n * (a 4xx like 400/401/403 is a permanent rejection — retrying it would just waste attempts).\n */\n retryOn?: (result: number | Error) => boolean;\n}\n\nfunction defaultRetryOn(result: number | Error): boolean {\n if (result instanceof Error) return true; // network error (DNS/connection/timeout) — worth a retry\n return result === 408 || result === 429 || (result >= 500 && result < 600);\n}\n\nfunction defaultBackoffMs(attempt: number): number {\n return 500 * 2 ** attempt;\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\n/** Retry-After (RFC 9110): either delta-seconds or an HTTP-date. Capped at MAX_RETRY_AFTER_MS so a\n * misbehaving/huge value from an untrusted-ish collector can't stall the caller for a long time. */\nfunction retryAfterMs(res: Response): number | undefined {\n const header = res.headers?.get?.('retry-after');\n if (!header) return undefined;\n const asSeconds = Number(header);\n if (!Number.isNaN(asSeconds)) return Math.max(0, Math.min(asSeconds * 1000, MAX_RETRY_AFTER_MS));\n const asDate = Date.parse(header);\n if (!Number.isNaN(asDate)) return Math.max(0, Math.min(asDate - Date.now(), MAX_RETRY_AFTER_MS));\n return undefined;\n}\n\nfunction delayFor(retry: OtlpRetryOptions, attempt: number, res?: Response): number {\n if (res?.status === 429) {\n const fromHeader = retryAfterMs(res);\n if (fromHeader !== undefined) return fromHeader;\n }\n const backoff = retry.backoffMs ?? defaultBackoffMs;\n return typeof backoff === 'function' ? backoff(attempt) : backoff;\n}\n\n/** Runs `doFetch` up to `retry.attempts` times, retrying on `retry.retryOn`-eligible failures. Returns\n * the last response (even if not ok — SAME \"don't throw on 4xx/5xx\" contract as the no-retry path) or\n * rethrows the last network error once attempts are exhausted (SAME as the no-retry path, which never\n * caught fetch's own throw either). */\nasync function fetchWithRetry(doFetch: () => Promise<Response>, retry: OtlpRetryOptions): Promise<Response> {\n const attempts = retry.attempts ?? DEFAULT_RETRY_ATTEMPTS;\n const retryOn = retry.retryOn ?? defaultRetryOn;\n for (let i = 0; i < attempts; i++) {\n const isLast = i === attempts - 1;\n try {\n const res = await doFetch();\n if (res.ok || isLast || !retryOn(res.status)) return res;\n await sleep(delayFor(retry, i, res));\n } catch (err) {\n if (isLast || !retryOn(err as Error)) throw err;\n await sleep(delayFor(retry, i));\n }\n }\n /* c8 ignore next */\n throw new Error('unreachable'); // attempts >= 1 guaranteed by callers; loop always returns/throws above\n}\n\nexport interface ExportRunToOtlpOptions {\n /** OTLP/HTTP JSON traces endpoint — e.g. 'http://localhost:4318/v1/traces' (Jaeger/Tempo/Collector) or\n * the user's own Langfuse/Datadog/Honeycomb OTLP proxy. We never send to a default endpoint ourselves. */\n endpoint: string;\n /** Additional HTTP headers (e.g. Authorization, x-honeycomb-team). */\n headers?: Record<string, string>;\n serviceName?: string;\n resourceAttributes?: Record<string, string | number | boolean>;\n now?: number;\n /** Opt-in retry/backoff for the POST (default: none — exactly one attempt, unchanged behavior). */\n retry?: OtlpRetryOptions;\n /**\n * How long one export attempt may take before it is abandoned (ms, default 30000).\n *\n * `fetch` has no default timeout, so a collector that accepts the connection and never answers held\n * this call open with nothing to observe. `retry` made that worse rather than better: retry counts\n * ATTEMPTS, and an attempt that never settles is never a failure, so a configured backoff never got\n * to run — the option that exists for an unhealthy collector was disabled by the specific kind of\n * unhealthy this is. Each attempt is bounded separately, so `retry` now sees a timeout as the\n * failure it is and backs off as configured.\n */\n timeoutMs?: number;\n}\n\nexport interface ExportRunToOtlpResult {\n traceId: string;\n spans: number;\n ok: boolean;\n status: number;\n}\n\n/**\n * Converts a run from the journal into OTLP/HTTP JSON and POSTs it to the given endpoint. DOES NOT USE\n * the OTel SDK — only `fetch` (Node ≥18 global). The user sends to THEIR OWN backend; for tests use\n * `toOtlpJson` without the network, or mock global `fetch` (see test/otlp.test.ts).\n */\nexport async function exportRunToOtlp(\n reader: JournalReader,\n runId: string,\n opts: ExportRunToOtlpOptions,\n): Promise<ExportRunToOtlpResult> {\n const spans = await toTraceSpans(reader, runId);\n const payload = toOtlpJson(spans, {\n runId,\n serviceName: opts.serviceName,\n resourceAttributes: opts.resourceAttributes,\n now: opts.now,\n });\n const doFetch = () =>\n fetch(opts.endpoint, {\n method: 'POST',\n headers: { 'content-type': 'application/json', ...(opts.headers ?? {}) },\n body: JSON.stringify(payload),\n // Do NOT follow redirects. The default is 'follow', and what travels here is a run's full trace —\n // prompts, tool arguments, model output — under provider credentials. Runtimes strip\n // `Authorization` across origins but not the headers these presets actually use (`x-api-key`,\n // `x-honeycomb-team`, `x-bt-parent`), so a redirect from a mistyped or compromised endpoint hands\n // both the key and the payload to whoever answers. A telemetry POST gains nothing from a\n // redirect; failing loudly is the better trade.\n redirect: 'error',\n // Per ATTEMPT, not per export: see `timeoutMs` — a signal shared across retries would abort the\n // second attempt before it started, turning the bound into a cap on the whole backoff instead.\n signal: AbortSignal.timeout(opts.timeoutMs ?? 30_000),\n });\n const res = opts.retry ? await fetchWithRetry(doFetch, opts.retry) : await doFetch();\n return {\n traceId: otlpTraceId(runId),\n spans: payload.resourceSpans[0]!.scopeSpans[0]!.spans.length,\n ok: res.ok,\n status: res.status,\n };\n}\n"]}
@@ -0,0 +1,50 @@
1
+ import type { ExportRunToOtlpOptions } from './otlp.js';
2
+ type Overrides = Partial<Pick<ExportRunToOtlpOptions, 'endpoint' | 'serviceName' | 'resourceAttributes'>>;
3
+ /** Langfuse (cloud eu/us or self-hosted `baseUrl`): Basic auth = publicKey:secretKey. */
4
+ export declare function langfuse(opts: {
5
+ publicKey: string;
6
+ secretKey: string;
7
+ /** 'eu' (default) | 'us' — cloud region. Ignored if `baseUrl` is given. */
8
+ region?: 'eu' | 'us';
9
+ /** Base URL for self-hosted (e.g. 'https://langfuse.acme.internal'). */
10
+ baseUrl?: string;
11
+ } & Overrides): ExportRunToOtlpOptions;
12
+ /** Generic API-key-authenticated OTLP/HTTP endpoint: x-api-key (+ optional project name header).
13
+ * `endpoint` is REQUIRED — unlike the other presets this has no hardcoded provider default, so it
14
+ * works with any OTLP/HTTP collector that authenticates via an `x-api-key`-style header. */
15
+ export declare function apiKeyOtlp(opts: {
16
+ endpoint: string;
17
+ apiKey: string;
18
+ project?: string;
19
+ projectHeader?: string;
20
+ } & Overrides): ExportRunToOtlpOptions;
21
+ /** Braintrust: Bearer + x-bt-parent (project). */
22
+ export declare function braintrust(opts: {
23
+ apiKey: string;
24
+ project: string;
25
+ } & Overrides): ExportRunToOtlpOptions;
26
+ /** Honeycomb: x-honeycomb-team (+ optional dataset — for classic accounts). */
27
+ export declare function honeycomb(opts: {
28
+ apiKey: string;
29
+ dataset?: string;
30
+ } & Overrides): ExportRunToOtlpOptions;
31
+ /** Datadog: via the local Datadog Agent's OTLP receiver (OTLP ingest must be enabled on the Agent). */
32
+ export declare function datadogAgent(opts?: {
33
+ host?: string;
34
+ port?: number;
35
+ } & Overrides): ExportRunToOtlpOptions;
36
+ /** Generic OTel Collector / Jaeger / Tempo: just give the base URL (`/v1/traces` is appended). */
37
+ export declare function collector(opts: {
38
+ baseUrl: string;
39
+ headers?: Record<string, string>;
40
+ } & Overrides): ExportRunToOtlpOptions;
41
+ /** All of them under one name: can also be used as `otlpPresets.langfuse({...})`. */
42
+ export declare const otlpPresets: {
43
+ readonly langfuse: typeof langfuse;
44
+ readonly apiKeyOtlp: typeof apiKeyOtlp;
45
+ readonly braintrust: typeof braintrust;
46
+ readonly honeycomb: typeof honeycomb;
47
+ readonly datadogAgent: typeof datadogAgent;
48
+ readonly collector: typeof collector;
49
+ };
50
+ export {};
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Appends an OTLP path to a base URL through the URL parser, instead of gluing two strings together.
3
+ *
4
+ * A base carrying a fragment or a query does not concatenate — it SWALLOWS the path. Measured with
5
+ * langfuse's preset:
6
+ *
7
+ * base 'https://lf.acme.internal#' -> 'https://lf.acme.internal#/api/public/otel/v1/traces'
8
+ * base 'https://lf.acme.internal?a=1' -> 'https://lf.acme.internal?a=1/api/public/otel/v1/traces'
9
+ *
10
+ * Both of those request path `/`. What travels on this wire is a run's entire trace — prompts, tool
11
+ * arguments, model output — under the exporter's credentials, so posting it to a host's site root
12
+ * instead of its OTLP receiver is not a 404 to shrug at. A trailing slash was the milder version of the
13
+ * same thing: `//api/...`, which plenty of servers do not route.
14
+ *
15
+ * Rejected rather than silently stripped: a base with a query or fragment is not a base URL that was
16
+ * meant to have a path appended, and guessing which half the author intended is how the wrong endpoint
17
+ * gets configured quietly.
18
+ */
19
+ function otlpEndpoint(base, path) {
20
+ let u;
21
+ try {
22
+ u = new URL(base);
23
+ }
24
+ catch {
25
+ throw new Error(`@gnldev/otel: '${base}' is not a valid base URL (expected something like 'https://host' or 'http://host:4318').`);
26
+ }
27
+ if (u.search || u.hash) {
28
+ throw new Error(`@gnldev/otel: the base URL '${base}' carries a ${u.hash ? 'fragment' : 'query string'}, so appending ` +
29
+ `'${path}' would produce a URL that requests '/' instead. Give the base only ` +
30
+ '(scheme, host, optional port and path), or pass the full `endpoint` yourself.');
31
+ }
32
+ u.pathname = `${u.pathname.replace(/\/$/, '')}${path}`;
33
+ return u.toString();
34
+ }
35
+ const b64 = (s) => Buffer.from(s, 'utf8').toString('base64');
36
+ /** Langfuse (cloud eu/us or self-hosted `baseUrl`): Basic auth = publicKey:secretKey. */
37
+ export function langfuse(opts) {
38
+ const base = opts.baseUrl ?? (opts.region === 'us' ? 'https://us.cloud.langfuse.com' : 'https://cloud.langfuse.com');
39
+ return {
40
+ endpoint: opts.endpoint ?? otlpEndpoint(base, '/api/public/otel/v1/traces'),
41
+ headers: { authorization: `Basic ${b64(`${opts.publicKey}:${opts.secretKey}`)}` },
42
+ serviceName: opts.serviceName,
43
+ resourceAttributes: opts.resourceAttributes,
44
+ };
45
+ }
46
+ /** Generic API-key-authenticated OTLP/HTTP endpoint: x-api-key (+ optional project name header).
47
+ * `endpoint` is REQUIRED — unlike the other presets this has no hardcoded provider default, so it
48
+ * works with any OTLP/HTTP collector that authenticates via an `x-api-key`-style header. */
49
+ export function apiKeyOtlp(opts) {
50
+ return {
51
+ endpoint: opts.endpoint,
52
+ headers: { 'x-api-key': opts.apiKey, ...(opts.project ? { [opts.projectHeader ?? 'x-project']: opts.project } : {}) },
53
+ serviceName: opts.serviceName,
54
+ resourceAttributes: opts.resourceAttributes,
55
+ };
56
+ }
57
+ /** Braintrust: Bearer + x-bt-parent (project). */
58
+ export function braintrust(opts) {
59
+ return {
60
+ endpoint: opts.endpoint ?? 'https://api.braintrust.dev/otel/v1/traces',
61
+ headers: { authorization: `Bearer ${opts.apiKey}`, 'x-bt-parent': `project_name:${opts.project}` },
62
+ serviceName: opts.serviceName,
63
+ resourceAttributes: opts.resourceAttributes,
64
+ };
65
+ }
66
+ /** Honeycomb: x-honeycomb-team (+ optional dataset — for classic accounts). */
67
+ export function honeycomb(opts) {
68
+ return {
69
+ endpoint: opts.endpoint ?? 'https://api.honeycomb.io/v1/traces',
70
+ headers: { 'x-honeycomb-team': opts.apiKey, ...(opts.dataset ? { 'x-honeycomb-dataset': opts.dataset } : {}) },
71
+ serviceName: opts.serviceName,
72
+ resourceAttributes: opts.resourceAttributes,
73
+ };
74
+ }
75
+ /** Datadog: via the local Datadog Agent's OTLP receiver (OTLP ingest must be enabled on the Agent). */
76
+ export function datadogAgent(opts = {}) {
77
+ const host = opts.host ?? 'localhost';
78
+ const port = opts.port ?? 4318;
79
+ return {
80
+ endpoint: opts.endpoint ?? otlpEndpoint(`http://${host}:${port}`, '/v1/traces'),
81
+ serviceName: opts.serviceName,
82
+ resourceAttributes: opts.resourceAttributes,
83
+ };
84
+ }
85
+ /** Generic OTel Collector / Jaeger / Tempo: just give the base URL (`/v1/traces` is appended). */
86
+ export function collector(opts) {
87
+ return {
88
+ endpoint: opts.endpoint ?? otlpEndpoint(opts.baseUrl, '/v1/traces'),
89
+ headers: opts.headers,
90
+ serviceName: opts.serviceName,
91
+ resourceAttributes: opts.resourceAttributes,
92
+ };
93
+ }
94
+ /** All of them under one name: can also be used as `otlpPresets.langfuse({...})`. */
95
+ export const otlpPresets = { langfuse, apiKeyOtlp, braintrust, honeycomb, datadogAgent, collector };
96
+ //# sourceMappingURL=presets.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"presets.js","sourceRoot":"","sources":["../src/presets.ts"],"names":[],"mappings":"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","sourcesContent":["// Named observability presets — a \"one-line integration\" for common OTEL providers.\n// Each preset turns the provider's DOCUMENTED OTLP/HTTP endpoint + auth headers into a ready-made\n// `ExportRunToOtlpOptions`; the user just calls `exportRunToOtlp(reader, runId, preset)`, done.\n// GNL never sends to an endpoint on its own — a preset only produces CONFIGURATION (pure functions).\n// Endpoint addresses are the defaults from the provider's documentation; every preset has an `endpoint` override.\nimport type { ExportRunToOtlpOptions } from './otlp.js';\n\ntype Overrides = Partial<Pick<ExportRunToOtlpOptions, 'endpoint' | 'serviceName' | 'resourceAttributes'>>;\n\n/**\n * Appends an OTLP path to a base URL through the URL parser, instead of gluing two strings together.\n *\n * A base carrying a fragment or a query does not concatenate — it SWALLOWS the path. Measured with\n * langfuse's preset:\n *\n * base 'https://lf.acme.internal#' -> 'https://lf.acme.internal#/api/public/otel/v1/traces'\n * base 'https://lf.acme.internal?a=1' -> 'https://lf.acme.internal?a=1/api/public/otel/v1/traces'\n *\n * Both of those request path `/`. What travels on this wire is a run's entire trace — prompts, tool\n * arguments, model output — under the exporter's credentials, so posting it to a host's site root\n * instead of its OTLP receiver is not a 404 to shrug at. A trailing slash was the milder version of the\n * same thing: `//api/...`, which plenty of servers do not route.\n *\n * Rejected rather than silently stripped: a base with a query or fragment is not a base URL that was\n * meant to have a path appended, and guessing which half the author intended is how the wrong endpoint\n * gets configured quietly.\n */\nfunction otlpEndpoint(base: string, path: string): string {\n let u: URL;\n try {\n u = new URL(base);\n } catch {\n throw new Error(`@gnldev/otel: '${base}' is not a valid base URL (expected something like 'https://host' or 'http://host:4318').`);\n }\n if (u.search || u.hash) {\n throw new Error(\n `@gnldev/otel: the base URL '${base}' carries a ${u.hash ? 'fragment' : 'query string'}, so appending ` +\n `'${path}' would produce a URL that requests '/' instead. Give the base only ` +\n '(scheme, host, optional port and path), or pass the full `endpoint` yourself.',\n );\n }\n u.pathname = `${u.pathname.replace(/\\/$/, '')}${path}`;\n return u.toString();\n}\n\n\n\nconst b64 = (s: string): string => Buffer.from(s, 'utf8').toString('base64');\n\n/** Langfuse (cloud eu/us or self-hosted `baseUrl`): Basic auth = publicKey:secretKey. */\nexport function langfuse(opts: {\n publicKey: string;\n secretKey: string;\n /** 'eu' (default) | 'us' — cloud region. Ignored if `baseUrl` is given. */\n region?: 'eu' | 'us';\n /** Base URL for self-hosted (e.g. 'https://langfuse.acme.internal'). */\n baseUrl?: string;\n} & Overrides): ExportRunToOtlpOptions {\n const base = opts.baseUrl ?? (opts.region === 'us' ? 'https://us.cloud.langfuse.com' : 'https://cloud.langfuse.com');\n return {\n endpoint: opts.endpoint ?? otlpEndpoint(base, '/api/public/otel/v1/traces'),\n headers: { authorization: `Basic ${b64(`${opts.publicKey}:${opts.secretKey}`)}` },\n serviceName: opts.serviceName,\n resourceAttributes: opts.resourceAttributes,\n };\n}\n\n/** Generic API-key-authenticated OTLP/HTTP endpoint: x-api-key (+ optional project name header).\n * `endpoint` is REQUIRED — unlike the other presets this has no hardcoded provider default, so it\n * works with any OTLP/HTTP collector that authenticates via an `x-api-key`-style header. */\nexport function apiKeyOtlp(opts: { endpoint: string; apiKey: string; project?: string; projectHeader?: string } & Overrides): ExportRunToOtlpOptions {\n return {\n endpoint: opts.endpoint,\n headers: { 'x-api-key': opts.apiKey, ...(opts.project ? { [opts.projectHeader ?? 'x-project']: opts.project } : {}) },\n serviceName: opts.serviceName,\n resourceAttributes: opts.resourceAttributes,\n };\n}\n\n/** Braintrust: Bearer + x-bt-parent (project). */\nexport function braintrust(opts: { apiKey: string; project: string } & Overrides): ExportRunToOtlpOptions {\n return {\n endpoint: opts.endpoint ?? 'https://api.braintrust.dev/otel/v1/traces',\n headers: { authorization: `Bearer ${opts.apiKey}`, 'x-bt-parent': `project_name:${opts.project}` },\n serviceName: opts.serviceName,\n resourceAttributes: opts.resourceAttributes,\n };\n}\n\n/** Honeycomb: x-honeycomb-team (+ optional dataset — for classic accounts). */\nexport function honeycomb(opts: { apiKey: string; dataset?: string } & Overrides): ExportRunToOtlpOptions {\n return {\n endpoint: opts.endpoint ?? 'https://api.honeycomb.io/v1/traces',\n headers: { 'x-honeycomb-team': opts.apiKey, ...(opts.dataset ? { 'x-honeycomb-dataset': opts.dataset } : {}) },\n serviceName: opts.serviceName,\n resourceAttributes: opts.resourceAttributes,\n };\n}\n\n/** Datadog: via the local Datadog Agent's OTLP receiver (OTLP ingest must be enabled on the Agent). */\nexport function datadogAgent(opts: { host?: string; port?: number } & Overrides = {}): ExportRunToOtlpOptions {\n const host = opts.host ?? 'localhost';\n const port = opts.port ?? 4318;\n return {\n endpoint: opts.endpoint ?? otlpEndpoint(`http://${host}:${port}`, '/v1/traces'),\n serviceName: opts.serviceName,\n resourceAttributes: opts.resourceAttributes,\n };\n}\n\n/** Generic OTel Collector / Jaeger / Tempo: just give the base URL (`/v1/traces` is appended). */\nexport function collector(opts: { baseUrl: string; headers?: Record<string, string> } & Overrides): ExportRunToOtlpOptions {\n return {\n endpoint: opts.endpoint ?? otlpEndpoint(opts.baseUrl, '/v1/traces'),\n headers: opts.headers,\n serviceName: opts.serviceName,\n resourceAttributes: opts.resourceAttributes,\n };\n}\n\n/** All of them under one name: can also be used as `otlpPresets.langfuse({...})`. */\nexport const otlpPresets = { langfuse, apiKeyOtlp, braintrust, honeycomb, datadogAgent, collector } as const;\n"]}
@@ -0,0 +1,29 @@
1
+ import type { IdGenerator } from '@opentelemetry/sdk-trace-base';
2
+ import type { JournalEntry } from '@gnldev/durable';
3
+ /** runId → fixed 32-hex trace id (the same run always yields the same trace). */
4
+ export declare function traceIdFor(runId: string): string;
5
+ /** (runId, seq) → fixed 16-hex span id. */
6
+ export declare function spanIdFor(runId: string, seq: number | 'root'): string;
7
+ /**
8
+ * An IdGenerator that hands out precomputed ids in sequence. The trace and span queues are SEPARATE →
9
+ * gives the correct id regardless of the SDK's generateTraceId/generateSpanId call order. If a queue
10
+ * runs out, falls back deterministically (hash). This way the same run → same trace_id/span_id → idempotent export.
11
+ */
12
+ export declare class QueueIdGenerator implements IdGenerator {
13
+ private readonly runId;
14
+ private readonly traceIds;
15
+ private readonly spanIds;
16
+ private ti;
17
+ private si;
18
+ constructor(runId: string, traceIds: string[], spanIds: string[]);
19
+ generateTraceId(): string;
20
+ generateSpanId(): string;
21
+ }
22
+ export interface MappedSpan {
23
+ name: string;
24
+ attributes: Record<string, string | number | boolean>;
25
+ isError: boolean;
26
+ isSuspended: boolean;
27
+ }
28
+ /** Converts a journal entry into an OTEL span name + gen_ai semantic attributes. */
29
+ export declare function mapEntry(e: JournalEntry): MappedSpan;
package/dist/spans.js ADDED
@@ -0,0 +1,77 @@
1
+ // Journal entry → OTEL span mapping + DETERMINISTIC id generation (re-export idempotent).
2
+ import { createHash } from 'node:crypto';
3
+ import { flattenUsage, finishReasonText } from '@gnldev/durable';
4
+ /** The step's token counts, flat, whichever AI SDK shape the record was written in. */
5
+ const usageOf = (v) => flattenUsage(v?.usage);
6
+ /** runId → fixed 32-hex trace id (the same run always yields the same trace). */
7
+ export function traceIdFor(runId) {
8
+ return createHash('sha256').update(`gnl-trace:${runId}`).digest('hex').slice(0, 32);
9
+ }
10
+ /** (runId, seq) → fixed 16-hex span id. */
11
+ export function spanIdFor(runId, seq) {
12
+ return createHash('sha256').update(`gnl-span:${runId}:${seq}`).digest('hex').slice(0, 16);
13
+ }
14
+ /**
15
+ * An IdGenerator that hands out precomputed ids in sequence. The trace and span queues are SEPARATE →
16
+ * gives the correct id regardless of the SDK's generateTraceId/generateSpanId call order. If a queue
17
+ * runs out, falls back deterministically (hash). This way the same run → same trace_id/span_id → idempotent export.
18
+ */
19
+ export class QueueIdGenerator {
20
+ runId;
21
+ traceIds;
22
+ spanIds;
23
+ ti = 0;
24
+ si = 0;
25
+ constructor(runId, traceIds, spanIds) {
26
+ this.runId = runId;
27
+ this.traceIds = traceIds;
28
+ this.spanIds = spanIds;
29
+ }
30
+ generateTraceId() {
31
+ return this.traceIds[this.ti++] ?? traceIdFor(`${this.runId}#t${this.ti}`);
32
+ }
33
+ generateSpanId() {
34
+ return this.spanIds[this.si++] ?? spanIdFor(`${this.runId}#s`, this.si);
35
+ }
36
+ }
37
+ /** Drop undefined/null attributes (OTEL setAttributes doesn't like them). */
38
+ function clean(attrs) {
39
+ const out = {};
40
+ for (const [k, v] of Object.entries(attrs)) {
41
+ if (v === undefined || v === null)
42
+ continue;
43
+ if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean')
44
+ out[k] = v;
45
+ }
46
+ return out;
47
+ }
48
+ /** Converts a journal entry into an OTEL span name + gen_ai semantic attributes. */
49
+ export function mapEntry(e) {
50
+ const v = e.value;
51
+ if (e.kind === 'model') {
52
+ return {
53
+ name: 'llm.generate',
54
+ attributes: clean({
55
+ 'gen_ai.operation.name': 'generate',
56
+ 'gen_ai.response.model': v?.response?.modelId,
57
+ // Through the shared readers: OTel attributes must be scalars, and an AI SDK 7 record's
58
+ // usage is nested while its finishReason is an object — emitted raw they arrive as
59
+ // "[object Object]" or NaN, which every backend either drops or charts as garbage.
60
+ 'gen_ai.response.finish_reason': finishReasonText(v?.finishReason),
61
+ 'gen_ai.usage.input_tokens': usageOf(v).inputTokens,
62
+ 'gen_ai.usage.output_tokens': usageOf(v).outputTokens,
63
+ 'gen_ai.usage.total_tokens': usageOf(v).totalTokens,
64
+ }),
65
+ isError: false,
66
+ isSuspended: false,
67
+ };
68
+ }
69
+ const status = v?.status;
70
+ return {
71
+ name: 'tool.execute',
72
+ attributes: clean({ 'gnl.tool.status': status }),
73
+ isError: status === 'failed',
74
+ isSuspended: status === 'suspended',
75
+ };
76
+ }
77
+ //# sourceMappingURL=spans.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spans.js","sourceRoot":"","sources":["../src/spans.ts"],"names":[],"mappings":"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","sourcesContent":["// Journal entry → OTEL span mapping + DETERMINISTIC id generation (re-export idempotent).\nimport { createHash } from 'node:crypto';\nimport type { IdGenerator } from '@opentelemetry/sdk-trace-base';\nimport type { JournalEntry } from '@gnldev/durable';\nimport { flattenUsage, finishReasonText } from '@gnldev/durable';\n\n/** The step's token counts, flat, whichever AI SDK shape the record was written in. */\nconst usageOf = (v: any) => flattenUsage(v?.usage);\n\n/** runId → fixed 32-hex trace id (the same run always yields the same trace). */\nexport function traceIdFor(runId: string): string {\n return createHash('sha256').update(`gnl-trace:${runId}`).digest('hex').slice(0, 32);\n}\n\n/** (runId, seq) → fixed 16-hex span id. */\nexport function spanIdFor(runId: string, seq: number | 'root'): string {\n return createHash('sha256').update(`gnl-span:${runId}:${seq}`).digest('hex').slice(0, 16);\n}\n\n/**\n * An IdGenerator that hands out precomputed ids in sequence. The trace and span queues are SEPARATE →\n * gives the correct id regardless of the SDK's generateTraceId/generateSpanId call order. If a queue\n * runs out, falls back deterministically (hash). This way the same run → same trace_id/span_id → idempotent export.\n */\nexport class QueueIdGenerator implements IdGenerator {\n private ti = 0;\n private si = 0;\n constructor(\n private readonly runId: string,\n private readonly traceIds: string[],\n private readonly spanIds: string[],\n ) {}\n generateTraceId(): string {\n return this.traceIds[this.ti++] ?? traceIdFor(`${this.runId}#t${this.ti}`);\n }\n generateSpanId(): string {\n return this.spanIds[this.si++] ?? spanIdFor(`${this.runId}#s`, this.si);\n }\n}\n\nexport interface MappedSpan {\n name: string;\n attributes: Record<string, string | number | boolean>;\n isError: boolean;\n isSuspended: boolean;\n}\n\n/** Drop undefined/null attributes (OTEL setAttributes doesn't like them). */\nfunction clean(attrs: Record<string, unknown>): Record<string, string | number | boolean> {\n const out: Record<string, string | number | boolean> = {};\n for (const [k, v] of Object.entries(attrs)) {\n if (v === undefined || v === null) continue;\n if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') out[k] = v;\n }\n return out;\n}\n\n/** Converts a journal entry into an OTEL span name + gen_ai semantic attributes. */\nexport function mapEntry(e: JournalEntry): MappedSpan {\n const v: any = e.value;\n if (e.kind === 'model') {\n return {\n name: 'llm.generate',\n attributes: clean({\n 'gen_ai.operation.name': 'generate',\n 'gen_ai.response.model': v?.response?.modelId,\n // Through the shared readers: OTel attributes must be scalars, and an AI SDK 7 record's\n // usage is nested while its finishReason is an object — emitted raw they arrive as\n // \"[object Object]\" or NaN, which every backend either drops or charts as garbage.\n 'gen_ai.response.finish_reason': finishReasonText(v?.finishReason),\n 'gen_ai.usage.input_tokens': usageOf(v).inputTokens,\n 'gen_ai.usage.output_tokens': usageOf(v).outputTokens,\n 'gen_ai.usage.total_tokens': usageOf(v).totalTokens,\n }),\n isError: false,\n isSuspended: false,\n };\n }\n const status = v?.status;\n return {\n name: 'tool.execute',\n attributes: clean({ 'gnl.tool.status': status }),\n isError: status === 'failed',\n isSuspended: status === 'suspended',\n };\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,84 @@
1
+ {
2
+ "name": "@gnldev/otel",
3
+ "version": "0.1.0",
4
+ "license": "Apache-2.0",
5
+ "engines": {
6
+ "node": ">=22.13.0"
7
+ },
8
+ "description": "OpenTelemetry trace exporter for @gnldev/durable. Journal → OTEL spans (deterministic ids = idempotent, crash-proof, exactly-once observability).",
9
+ "keywords": [
10
+ "ai",
11
+ "agent",
12
+ "llm",
13
+ "typescript",
14
+ "ai-sdk",
15
+ "durable",
16
+ "exactly-once",
17
+ "opentelemetry",
18
+ "tracing",
19
+ "langfuse"
20
+ ],
21
+ "type": "module",
22
+ "main": "./dist/index.js",
23
+ "types": "./dist/index.d.ts",
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "default": "./dist/index.js"
28
+ },
29
+ "./live": {
30
+ "types": "./dist/live.d.ts",
31
+ "default": "./dist/live.js"
32
+ },
33
+ "./metrics": {
34
+ "types": "./dist/metrics.d.ts",
35
+ "default": "./dist/metrics.js"
36
+ },
37
+ "./package.json": "./package.json"
38
+ },
39
+ "files": [
40
+ "dist"
41
+ ],
42
+ "dependencies": {
43
+ "@opentelemetry/api": "^1.9.0",
44
+ "@opentelemetry/sdk-trace-base": "^2.10.0",
45
+ "@opentelemetry/semantic-conventions": "^1.28.0"
46
+ },
47
+ "peerDependencies": {
48
+ "@opentelemetry/exporter-trace-otlp-http": "^0.221.0",
49
+ "ai": "^7.0.0",
50
+ "@gnldev/durable": "^0.1.0"
51
+ },
52
+ "peerDependenciesMeta": {
53
+ "@opentelemetry/exporter-trace-otlp-http": {
54
+ "optional": true
55
+ },
56
+ "ai": {
57
+ "optional": true
58
+ }
59
+ },
60
+ "devDependencies": {
61
+ "@ai-sdk/provider": "^4.0.0",
62
+ "ai": "^7.0.0",
63
+ "zod": "^3.25.0",
64
+ "@gnldev/durable": "0.1.0"
65
+ },
66
+ "author": "Karaca Yılmaz (https://gnl.dev)",
67
+ "homepage": "https://gnl.dev",
68
+ "bugs": {
69
+ "url": "https://github.com/Karaca7/gnldev/issues"
70
+ },
71
+ "repository": {
72
+ "type": "git",
73
+ "url": "git+https://github.com/Karaca7/gnldev.git",
74
+ "directory": "packages/otel"
75
+ },
76
+ "publishConfig": {
77
+ "access": "public"
78
+ },
79
+ "scripts": {
80
+ "build": "tsc -p tsconfig.json",
81
+ "typecheck": "tsc -p tsconfig.json --noEmit",
82
+ "test": "vitest run"
83
+ }
84
+ }