@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/live.js ADDED
@@ -0,0 +1,197 @@
1
+ // @gnldev/otel/live — LIVE (real-time) observability. Unlike the post-hoc exportRun, it emits live OTEL
2
+ // spans + cost WHILE model/tool calls are ACTUALLY running. The wrappers compose BELOW durable → they
3
+ // never run on replay (journal cache hit) → the live layer NEVER TOUCHES THE JOURNAL, does not break determinism.
4
+ // It carries real wall-clock time/latency (non-det) — that's why it goes only to the exporter, never the journal.
5
+ import { createRequire } from 'node:module';
6
+ import { wrapLanguageModel } from 'ai';
7
+ import { BasicTracerProvider, SimpleSpanProcessor, InMemorySpanExporter, } from '@opentelemetry/sdk-trace-base';
8
+ import { trace, context, SpanKind, SpanStatusCode } from '@opentelemetry/api';
9
+ import { priceFor, costOf, DEFAULT_PRICING } from '@gnldev/durable';
10
+ import { flattenUsage } from '@gnldev/durable';
11
+ function clean(attrs) {
12
+ const out = {};
13
+ for (const [k, v] of Object.entries(attrs)) {
14
+ if (v === undefined || v === null)
15
+ continue;
16
+ if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean')
17
+ out[k] = v;
18
+ }
19
+ return out;
20
+ }
21
+ function otlpExporterSync(endpoint) {
22
+ const mod = '@opentelemetry/exporter-trace-otlp-http'; // string variable → doesn't break the optional peer build
23
+ const { OTLPTraceExporter } = createRequire(import.meta.url)(mod);
24
+ return new OTLPTraceExporter({ url: endpoint });
25
+ }
26
+ export function liveObservability(opts = {}) {
27
+ const pricing = opts.pricing ?? DEFAULT_PRICING;
28
+ const sampleRate = opts.sampleRate ?? 1;
29
+ const exporter = opts.exporter ?? (opts.endpoint ? otlpExporterSync(opts.endpoint) : new InMemorySpanExporter());
30
+ // OpenTelemetry JS 2.x removed `provider.addSpanProcessor()`: a processor can no longer be
31
+ // attached after construction, it is declared with the provider. Passed via `spanProcessors`.
32
+ const provider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] });
33
+ const tracer = provider.getTracer('@gnldev/otel/live');
34
+ const roots = new Map();
35
+ const total = {
36
+ inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, modelCalls: 0, toolCalls: 0, costUsd: 0,
37
+ };
38
+ function ensureRoot(runId, startTime) {
39
+ let r = roots.get(runId);
40
+ if (!r) {
41
+ // FINDING (roots leak guardrail): the caller may have forgotten to clean up via `flush(runId)`
42
+ // (a multi-run/long-lived server scenario). If the limit is exceeded, the oldest (first in
43
+ // insertion order) root is forcibly ended and removed so the Map doesn't grow unbounded.
44
+ // If maxPendingRuns is not given (default), this is disabled — existing behavior is unchanged.
45
+ if (opts.maxPendingRuns !== undefined && roots.size >= opts.maxPendingRuns) {
46
+ const oldestId = roots.keys().next().value;
47
+ if (oldestId !== undefined) {
48
+ const oldest = roots.get(oldestId);
49
+ oldest.span.end(startTime);
50
+ roots.delete(oldestId);
51
+ }
52
+ }
53
+ const span = tracer.startSpan('agent.run', {
54
+ kind: SpanKind.INTERNAL,
55
+ startTime,
56
+ attributes: clean({ 'gnl.run_id': runId, 'service.name': opts.serviceName }),
57
+ });
58
+ r = { span, ctx: trace.setSpan(context.active(), span) };
59
+ roots.set(runId, r);
60
+ }
61
+ return r;
62
+ }
63
+ function recordModel(runId, e) {
64
+ const r = ensureRoot(runId, e.start);
65
+ total.modelCalls++;
66
+ // Same shared reader the journal path uses: AI SDK 7 nests the counts, so reading the flat
67
+ // fields yields undefined → `?? 0` → a live cost feed that reports zero forever, and an
68
+ // onCost budget alarm that therefore never fires. `cachedTokens` was never an SDK field at all.
69
+ const u = flattenUsage(e.usage);
70
+ const { inputTokens: inp, outputTokens: outp, cachedTokens: cached, totalTokens: tot } = u;
71
+ total.inputTokens += inp;
72
+ total.outputTokens += outp;
73
+ total.cachedTokens += cached;
74
+ total.totalTokens += tot;
75
+ const p = e.modelId ? priceFor(e.modelId, pricing) : undefined;
76
+ // costOf reads FLAT fields — hand it the flattened object, not the raw record.
77
+ const cost = p ? costOf(u, p) : 0;
78
+ total.costUsd += cost;
79
+ const span = tracer.startSpan('llm.generate', {
80
+ kind: SpanKind.CLIENT,
81
+ startTime: e.start,
82
+ attributes: clean({
83
+ 'gen_ai.operation.name': 'generate',
84
+ 'gen_ai.response.model': e.modelId,
85
+ 'gen_ai.response.finish_reason': e.finishReason,
86
+ 'gen_ai.usage.input_tokens': inp,
87
+ 'gen_ai.usage.output_tokens': outp,
88
+ 'gen_ai.usage.total_tokens': tot,
89
+ 'gnl.cost_usd': cost,
90
+ }),
91
+ }, r.ctx);
92
+ span.end(e.end > e.start ? e.end : e.start);
93
+ opts.onCost?.({ ...total });
94
+ }
95
+ function recordTool(runId, e) {
96
+ const r = ensureRoot(runId, e.start);
97
+ total.toolCalls++;
98
+ const span = tracer.startSpan('tool.execute', { kind: SpanKind.INTERNAL, startTime: e.start, attributes: clean({ 'gnl.tool.name': e.name, 'gnl.tool.status': e.status }) }, r.ctx);
99
+ if (e.status === 'failed')
100
+ span.setStatus({ code: SpanStatusCode.ERROR });
101
+ span.end(e.end > e.start ? e.end : e.start);
102
+ }
103
+ function liveMiddleware(runId) {
104
+ return {
105
+ specificationVersion: 'v4',
106
+ wrapGenerate: async ({ doGenerate }) => {
107
+ const start = Date.now();
108
+ const result = await doGenerate();
109
+ const r = result;
110
+ recordModel(runId, { start, end: Date.now(), usage: r?.usage, finishReason: r?.finishReason, modelId: r?.response?.modelId });
111
+ return result;
112
+ },
113
+ wrapStream: async ({ doStream }) => {
114
+ const start = Date.now();
115
+ const { stream, ...rest } = await doStream();
116
+ let usage;
117
+ let finishReason;
118
+ let modelId;
119
+ const recorder = new TransformStream({
120
+ transform(chunk, controller) {
121
+ if (chunk?.type === 'finish') {
122
+ usage = chunk.usage;
123
+ finishReason = chunk.finishReason;
124
+ }
125
+ if (chunk?.response?.modelId)
126
+ modelId = chunk.response.modelId;
127
+ controller.enqueue(chunk);
128
+ },
129
+ flush: () => recordModel(runId, { start, end: Date.now(), usage, finishReason, modelId }),
130
+ });
131
+ return { stream: stream.pipeThrough(recorder), ...rest };
132
+ },
133
+ };
134
+ }
135
+ function liveTool(t, name, runId) {
136
+ if (!t || typeof t.execute !== 'function')
137
+ return t;
138
+ const original = t.execute;
139
+ return {
140
+ ...t,
141
+ execute: async (input, options) => {
142
+ const start = Date.now();
143
+ try {
144
+ const output = await original(input, options);
145
+ recordTool(runId, { start, end: Date.now(), name, status: 'succeeded' });
146
+ return output;
147
+ }
148
+ catch (e) {
149
+ recordTool(runId, { start, end: Date.now(), name, status: 'failed' });
150
+ throw e;
151
+ }
152
+ },
153
+ };
154
+ }
155
+ function instrument(args) {
156
+ // Sampling: if this run won't be traced, return args unchanged (zero cost).
157
+ if (sampleRate < 1 && Math.random() >= sampleRate)
158
+ return args;
159
+ const runId = args.runId;
160
+ const model = args.model ? wrapLanguageModel({ model: args.model, middleware: liveMiddleware(runId) }) : args.model;
161
+ let tools = args.tools;
162
+ if (args.tools) {
163
+ tools = {};
164
+ for (const [name, t] of Object.entries(args.tools))
165
+ tools[name] = liveTool(t, name, runId);
166
+ }
167
+ return { ...args, model, tools };
168
+ }
169
+ return {
170
+ instrument,
171
+ cost: () => ({ ...total }),
172
+ async flush(runId) {
173
+ const end = Date.now();
174
+ if (runId !== undefined) {
175
+ // CLEANUP POINT: when a run finishes (or is exported), close ONLY that run's root + remove it
176
+ // from the Map. Does not touch other concurrent runs' roots — this is the actual fix for the roots leak.
177
+ const r = roots.get(runId);
178
+ if (r) {
179
+ r.span.end(end);
180
+ roots.delete(runId);
181
+ }
182
+ }
183
+ else {
184
+ for (const { span } of roots.values())
185
+ span.end(end);
186
+ roots.clear();
187
+ }
188
+ await provider.forceFlush();
189
+ },
190
+ async shutdown() {
191
+ await provider.shutdown();
192
+ },
193
+ exporter,
194
+ pendingRuns: () => roots.size,
195
+ };
196
+ }
197
+ //# sourceMappingURL=live.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"live.js","sourceRoot":"","sources":["../src/live.ts"],"names":[],"mappings":"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","sourcesContent":["// @gnldev/otel/live — LIVE (real-time) observability. Unlike the post-hoc exportRun, it emits live OTEL\n// spans + cost WHILE model/tool calls are ACTUALLY running. The wrappers compose BELOW durable → they\n// never run on replay (journal cache hit) → the live layer NEVER TOUCHES THE JOURNAL, does not break determinism.\n// It carries real wall-clock time/latency (non-det) — that's why it goes only to the exporter, never the journal.\nimport { createRequire } from 'node:module';\nimport { wrapLanguageModel } from 'ai';\nimport type { LanguageModelV4, LanguageModelV4Middleware } from '@ai-sdk/provider';\nimport {\n BasicTracerProvider,\n SimpleSpanProcessor,\n InMemorySpanExporter,\n type SpanExporter,\n} from '@opentelemetry/sdk-trace-base';\nimport { trace, context, SpanKind, SpanStatusCode, type Span, type Context } from '@opentelemetry/api';\nimport { priceFor, costOf, DEFAULT_PRICING } from '@gnldev/durable';\nimport type { ModelPricing } from '@gnldev/durable';\nimport { flattenUsage } from '@gnldev/durable';\n\nexport interface LiveCost {\n inputTokens: number;\n outputTokens: number;\n cachedTokens: number;\n totalTokens: number;\n modelCalls: number;\n toolCalls: number;\n costUsd: number;\n}\n\nexport interface LiveObservabilityOptions {\n /** A ready-made OTEL SpanExporter (InMemorySpanExporter for tests). If not given, falls back to the endpoint or InMemory. */\n exporter?: SpanExporter;\n /** OTLP-HTTP endpoint (e.g. 'http://localhost:4318/v1/traces'). If exporter is not given, one is built from this (lazily). */\n endpoint?: string;\n /** Price table for cost calculation (default DEFAULT_PRICING). */\n pricing?: Record<string, ModelPricing>;\n /** service.name span attribute. */\n serviceName?: string;\n /** Sampling rate 0..1 (default 1 = all). instrument() kicks in with this probability. */\n sampleRate?: number;\n /** Called with the current running total cost after every model call (live budget alarm). */\n onCost?: (cost: LiveCost) => void;\n /**\n * FINDING (roots leak guard): the `roots` Map holds one root span per new runId; normally this is\n * cleared by `flush(runId)` (or plain `flush()` if never closed). If the caller forgets to do this\n * (e.g. a long-lived multi-run server), the Map grows unbounded. This is the last-resort guardrail\n * for that case: once the Map exceeds this size, the OLDEST (insertion-order) root is forcibly ended\n * and removed. If not given, the guardrail is OFF (default behavior unchanged) — only opted-in callers\n * get it. Kept simple: no TTL, just a size limit.\n */\n maxPendingRuns?: number;\n}\n\nexport interface LiveInstrumentArgs {\n runId: string;\n model: unknown;\n tools?: Record<string, any>;\n [k: string]: unknown;\n}\n\nexport interface LiveObservability {\n /** Wraps runDurable/streamDurable args to inject live tracing (model + tool). Returns the same type. */\n instrument<T extends LiveInstrumentArgs>(args: T): T;\n /** Total cost/tokens accumulated so far (live). */\n cost(): LiveCost;\n /**\n * If `runId` is given, closes ONLY that run's root span + removes it from the `roots` Map (does not\n * touch other concurrent runs) — this is the cleanup point that must be called as each run finishes\n * in a multi-run scenario. If `runId` is omitted (old behavior, UNCHANGED), closes ALL open root spans\n * + clears the Map entirely. In both cases forces a flush to the exporter.\n */\n flush(runId?: string): Promise<void>;\n /** Shut down the provider (exporter shutdown). */\n shutdown(): Promise<void>;\n /** The exporter used (if InMemorySpanExporter, tests can call getFinishedSpans()). */\n exporter: SpanExporter;\n /** Number of runs currently held in the `roots` Map (not yet closed via `flush(runId)`) — for leak monitoring. */\n pendingRuns(): number;\n}\n\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\nfunction otlpExporterSync(endpoint: string): SpanExporter {\n const mod = '@opentelemetry/exporter-trace-otlp-http'; // string variable → doesn't break the optional peer build\n const { OTLPTraceExporter } = createRequire(import.meta.url)(mod) as any;\n return new OTLPTraceExporter({ url: endpoint });\n}\n\ninterface ModelEmit {\n start: number;\n end: number;\n usage?: any;\n finishReason?: string;\n modelId?: string;\n}\ninterface ToolEmit {\n start: number;\n end: number;\n name: string;\n status: 'succeeded' | 'failed';\n}\n\nexport function liveObservability(opts: LiveObservabilityOptions = {}): LiveObservability {\n const pricing = opts.pricing ?? DEFAULT_PRICING;\n const sampleRate = opts.sampleRate ?? 1;\n const exporter = opts.exporter ?? (opts.endpoint ? otlpExporterSync(opts.endpoint) : new InMemorySpanExporter());\n // OpenTelemetry JS 2.x removed `provider.addSpanProcessor()`: a processor can no longer be\n // attached after construction, it is declared with the provider. Passed via `spanProcessors`.\n const provider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] });\n const tracer = provider.getTracer('@gnldev/otel/live');\n\n const roots = new Map<string, { span: Span; ctx: Context }>();\n const total: LiveCost = {\n inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, modelCalls: 0, toolCalls: 0, costUsd: 0,\n };\n\n function ensureRoot(runId: string, startTime: number): { span: Span; ctx: Context } {\n let r = roots.get(runId);\n if (!r) {\n // FINDING (roots leak guardrail): the caller may have forgotten to clean up via `flush(runId)`\n // (a multi-run/long-lived server scenario). If the limit is exceeded, the oldest (first in\n // insertion order) root is forcibly ended and removed so the Map doesn't grow unbounded.\n // If maxPendingRuns is not given (default), this is disabled — existing behavior is unchanged.\n if (opts.maxPendingRuns !== undefined && roots.size >= opts.maxPendingRuns) {\n const oldestId = roots.keys().next().value as string | undefined;\n if (oldestId !== undefined) {\n const oldest = roots.get(oldestId)!;\n oldest.span.end(startTime);\n roots.delete(oldestId);\n }\n }\n const span = tracer.startSpan('agent.run', {\n kind: SpanKind.INTERNAL,\n startTime,\n attributes: clean({ 'gnl.run_id': runId, 'service.name': opts.serviceName }),\n });\n r = { span, ctx: trace.setSpan(context.active(), span) };\n roots.set(runId, r);\n }\n return r;\n }\n\n function recordModel(runId: string, e: ModelEmit): void {\n const r = ensureRoot(runId, e.start);\n total.modelCalls++;\n // Same shared reader the journal path uses: AI SDK 7 nests the counts, so reading the flat\n // fields yields undefined → `?? 0` → a live cost feed that reports zero forever, and an\n // onCost budget alarm that therefore never fires. `cachedTokens` was never an SDK field at all.\n const u = flattenUsage(e.usage);\n const { inputTokens: inp, outputTokens: outp, cachedTokens: cached, totalTokens: tot } = u;\n total.inputTokens += inp;\n total.outputTokens += outp;\n total.cachedTokens += cached;\n total.totalTokens += tot;\n const p = e.modelId ? priceFor(e.modelId, pricing) : undefined;\n // costOf reads FLAT fields — hand it the flattened object, not the raw record.\n const cost = p ? costOf(u, p) : 0;\n total.costUsd += cost;\n const span = tracer.startSpan(\n 'llm.generate',\n {\n kind: SpanKind.CLIENT,\n startTime: e.start,\n attributes: clean({\n 'gen_ai.operation.name': 'generate',\n 'gen_ai.response.model': e.modelId,\n 'gen_ai.response.finish_reason': e.finishReason,\n 'gen_ai.usage.input_tokens': inp,\n 'gen_ai.usage.output_tokens': outp,\n 'gen_ai.usage.total_tokens': tot,\n 'gnl.cost_usd': cost,\n }),\n },\n r.ctx,\n );\n span.end(e.end > e.start ? e.end : e.start);\n opts.onCost?.({ ...total });\n }\n\n function recordTool(runId: string, e: ToolEmit): void {\n const r = ensureRoot(runId, e.start);\n total.toolCalls++;\n const span = tracer.startSpan(\n 'tool.execute',\n { kind: SpanKind.INTERNAL, startTime: e.start, attributes: clean({ 'gnl.tool.name': e.name, 'gnl.tool.status': e.status }) },\n r.ctx,\n );\n if (e.status === 'failed') span.setStatus({ code: SpanStatusCode.ERROR });\n span.end(e.end > e.start ? e.end : e.start);\n }\n\n function liveMiddleware(runId: string): LanguageModelV4Middleware {\n return {\n specificationVersion: 'v4',\n wrapGenerate: async ({ doGenerate }) => {\n const start = Date.now();\n const result = await doGenerate();\n const r: any = result;\n recordModel(runId, { start, end: Date.now(), usage: r?.usage, finishReason: r?.finishReason, modelId: r?.response?.modelId });\n return result;\n },\n wrapStream: async ({ doStream }) => {\n const start = Date.now();\n const { stream, ...rest } = await doStream();\n let usage: any;\n let finishReason: string | undefined;\n let modelId: string | undefined;\n const recorder = new TransformStream<any, any>({\n transform(chunk, controller) {\n if (chunk?.type === 'finish') {\n usage = chunk.usage;\n finishReason = chunk.finishReason;\n }\n if (chunk?.response?.modelId) modelId = chunk.response.modelId;\n controller.enqueue(chunk);\n },\n flush: () => recordModel(runId, { start, end: Date.now(), usage, finishReason, modelId }),\n });\n return { stream: (stream as any).pipeThrough(recorder), ...rest } as any;\n },\n };\n }\n\n function liveTool(t: any, name: string, runId: string): any {\n if (!t || typeof t.execute !== 'function') return t;\n const original = t.execute;\n return {\n ...t,\n execute: async (input: any, options: any) => {\n const start = Date.now();\n try {\n const output = await original(input, options);\n recordTool(runId, { start, end: Date.now(), name, status: 'succeeded' });\n return output;\n } catch (e) {\n recordTool(runId, { start, end: Date.now(), name, status: 'failed' });\n throw e;\n }\n },\n };\n }\n\n function instrument<T extends LiveInstrumentArgs>(args: T): T {\n // Sampling: if this run won't be traced, return args unchanged (zero cost).\n if (sampleRate < 1 && Math.random() >= sampleRate) return args;\n const runId = args.runId;\n const model = args.model ? wrapLanguageModel({ model: args.model as LanguageModelV4, middleware: liveMiddleware(runId) }) : args.model;\n let tools = args.tools;\n if (args.tools) {\n tools = {};\n for (const [name, t] of Object.entries(args.tools)) tools[name] = liveTool(t, name, runId);\n }\n return { ...args, model, tools };\n }\n\n return {\n instrument,\n cost: () => ({ ...total }),\n async flush(runId?: string) {\n const end = Date.now();\n if (runId !== undefined) {\n // CLEANUP POINT: when a run finishes (or is exported), close ONLY that run's root + remove it\n // from the Map. Does not touch other concurrent runs' roots — this is the actual fix for the roots leak.\n const r = roots.get(runId);\n if (r) {\n r.span.end(end);\n roots.delete(runId);\n }\n } else {\n for (const { span } of roots.values()) span.end(end);\n roots.clear();\n }\n await provider.forceFlush();\n },\n async shutdown() {\n await provider.shutdown();\n },\n exporter,\n pendingRuns: () => roots.size,\n };\n}\n"]}
@@ -0,0 +1,100 @@
1
+ import type { Journal } from '@gnldev/durable';
2
+ import type { OtlpKeyValue } from './otlp.js';
3
+ export interface OtlpNumberDataPoint {
4
+ attributes: OtlpKeyValue[];
5
+ startTimeUnixNano: string;
6
+ timeUnixNano: string;
7
+ asInt?: string;
8
+ asDouble?: number;
9
+ }
10
+ export interface OtlpHistogramDataPoint {
11
+ attributes: OtlpKeyValue[];
12
+ startTimeUnixNano: string;
13
+ timeUnixNano: string;
14
+ count: string;
15
+ sum: number;
16
+ bucketCounts: string[];
17
+ explicitBounds: number[];
18
+ }
19
+ export interface OtlpMetric {
20
+ name: string;
21
+ unit?: string;
22
+ sum?: {
23
+ dataPoints: OtlpNumberDataPoint[];
24
+ aggregationTemporality: number;
25
+ isMonotonic: boolean;
26
+ };
27
+ histogram?: {
28
+ dataPoints: OtlpHistogramDataPoint[];
29
+ aggregationTemporality: number;
30
+ };
31
+ }
32
+ export interface OtlpMetricsPayload {
33
+ resourceMetrics: {
34
+ resource: {
35
+ attributes: OtlpKeyValue[];
36
+ };
37
+ scopeMetrics: {
38
+ scope: {
39
+ name: string;
40
+ };
41
+ metrics: OtlpMetric[];
42
+ }[];
43
+ }[];
44
+ }
45
+ export interface ToOtlpMetricsOptions {
46
+ /** resource attribute service.name. */
47
+ serviceName?: string;
48
+ /**
49
+ * The organization label, for the case where the caller scopes by some other means.
50
+ *
51
+ * Normally leave it unset: a journal from `withOrg` carries its organization on itself, and the
52
+ * label is read from there. Supplying one that DISAGREES with the handle throws, because the two
53
+ * failure modes are not symmetrical — the numbers come from the handle and the label is what the
54
+ * dashboard believes, so a mismatch publishes one tenant's traffic under another tenant's name
55
+ * and nothing downstream can detect it. That was the shape of this option before: two independent
56
+ * inputs that had to agree, with nothing checking that they did.
57
+ */
58
+ orgId?: string;
59
+ resourceAttributes?: Record<string, string | number | boolean>;
60
+ /** Observation time (ms). Injectable so a test is deterministic; default `Date.now()`. */
61
+ now?: number;
62
+ /**
63
+ * When this journal's counters started accumulating (ms) — see `nextEpoch`, which decides it.
64
+ *
65
+ * Required rather than defaulted, because every plausible default is wrong. `Date.now()` moves on
66
+ * every export and on every process restart, and a collector reading a fresh start time on a
67
+ * series it already knows treats it as a counter reset: the entire accumulated total gets charged
68
+ * to one interval, as a spike that never happened.
69
+ */
70
+ startTime: number;
71
+ }
72
+ /** What `nextEpoch` remembers between exports. */
73
+ export interface MetricsEpoch {
74
+ startTime: number;
75
+ total: number;
76
+ }
77
+ /**
78
+ * Decides the `startTimeUnixNano` for the next export, which is the whole of the reset handling.
79
+ *
80
+ * The rule has two halves that pull in opposite directions, and both matter:
81
+ *
82
+ * grew or held → keep the epoch. This is the common case, and renewing it here is the bug that
83
+ * puts a phantom spike on the graph after every deploy.
84
+ * FELL → move it. `rebuildMetrics` recomputes the counters from the journal and the
85
+ * total legitimately drops after a purge. Keeping the epoch through that shows
86
+ * a monotonic series going backwards, and the collector reconstructs a rate out
87
+ * of the wraparound.
88
+ *
89
+ * `total` is one number on purpose: any of the counters falling means the same rebuild happened, so
90
+ * `runs` stands in for all of them.
91
+ */
92
+ export declare function nextEpoch(prev: MetricsEpoch | undefined, total: number, now: number): MetricsEpoch;
93
+ /**
94
+ * Reads the running totals and returns an OTLP/HTTP metrics body.
95
+ *
96
+ * Always returns a payload, even for a journal that has never run anything: absent and zero look
97
+ * identical on a graph and mean opposite things. A series that only appears once it is non-zero
98
+ * makes its first value read as a jump from nothing, and "no data" can never be alerted on.
99
+ */
100
+ export declare function toOtlpMetricsJson(journal: Journal, opts: ToOtlpMetricsOptions): Promise<OtlpMetricsPayload>;
@@ -0,0 +1,145 @@
1
+ // @gnldev/otel/metrics — the journal's materialized counters as an OTLP/HTTP JSON metrics body.
2
+ //
3
+ // Same ethos as otlp.ts: no OTel SDK, the body is built by hand and sent with `fetch`. That is not
4
+ // only about size here — it is the only way this data can be expressed. The counters hold duration
5
+ // BUCKET COUNTS, never the individual observations, and the SDK's histogram API accepts recorded
6
+ // values (`histogram.record(ms)`). Feeding pre-aggregated buckets through it would mean writing a
7
+ // custom MetricReader that emits `ResourceMetrics` directly, i.e. building the payload by hand
8
+ // anyway and paying for a dependency that contributes only transport — transport otlp.ts already
9
+ // has, with redirect refusal, per-attempt timeouts and Retry-After handling.
10
+ //
11
+ // What is exported, and what deliberately is not:
12
+ //
13
+ // `__metrics__:all` ONLY. The day buckets are not emitted, and that is a correctness decision
14
+ // before it is a cost one. A cumulative series whose value returns to zero every UTC midnight is
15
+ // read by every collector as a counter reset, so daily counters shipped as cumulative sums invent
16
+ // a spike once a day. (They are also 15x the reads: a bucket is `1 + METRICS_SHARDS` point reads,
17
+ // so `all` is 17 and `all` + 14 days is 255 — per organization, per round.)
18
+ import { readCounter, METRICS_ALL_KEY, orgScopeOf } from '@gnldev/durable';
19
+ import { toKv, nano } from './otlp.js';
20
+ /** OTLP AggregationTemporality: 1 = DELTA, 2 = CUMULATIVE. */
21
+ const CUMULATIVE = 2;
22
+ /**
23
+ * The duration buckets `metrics.ts` writes, in the order OTLP wants them.
24
+ *
25
+ * `explicitBounds` has one fewer entry than `bucketCounts`: the last count is the implicit `+Inf`
26
+ * bucket. The bounds must match `durationBucketField`'s thresholds exactly — a mismatch here does
27
+ * not fail anywhere, it just relabels every latency on the dashboard.
28
+ */
29
+ const DURATION_BOUNDS = [1_000, 5_000, 15_000, 60_000];
30
+ const DURATION_FIELDS = ['durLt1s', 'durLt5s', 'durLt15s', 'durLt60s', 'durGte60s'];
31
+ /**
32
+ * Decides the `startTimeUnixNano` for the next export, which is the whole of the reset handling.
33
+ *
34
+ * The rule has two halves that pull in opposite directions, and both matter:
35
+ *
36
+ * grew or held → keep the epoch. This is the common case, and renewing it here is the bug that
37
+ * puts a phantom spike on the graph after every deploy.
38
+ * FELL → move it. `rebuildMetrics` recomputes the counters from the journal and the
39
+ * total legitimately drops after a purge. Keeping the epoch through that shows
40
+ * a monotonic series going backwards, and the collector reconstructs a rate out
41
+ * of the wraparound.
42
+ *
43
+ * `total` is one number on purpose: any of the counters falling means the same rebuild happened, so
44
+ * `runs` stands in for all of them.
45
+ */
46
+ export function nextEpoch(prev, total, now) {
47
+ if (!prev)
48
+ return { startTime: now, total };
49
+ if (total < prev.total)
50
+ return { startTime: now, total }; // a rebuild: the series starts again
51
+ return { startTime: prev.startTime, total };
52
+ }
53
+ function intPoint(value, startTimeUnixNano, timeUnixNano) {
54
+ // `asInt` is a STRING: OTLP/JSON maps uint64 that way (see otlp.ts), and a number would silently
55
+ // lose precision on token totals past 2^53 — the one deployment size where it would ever matter.
56
+ return { attributes: [], startTimeUnixNano, timeUnixNano, asInt: String(Math.round(value)) };
57
+ }
58
+ function sumMetric(name, unit, value, start, time) {
59
+ return {
60
+ name,
61
+ unit,
62
+ sum: { dataPoints: [intPoint(value, start, time)], aggregationTemporality: CUMULATIVE, isMonotonic: true },
63
+ };
64
+ }
65
+ /**
66
+ * Reads the running totals and returns an OTLP/HTTP metrics body.
67
+ *
68
+ * Always returns a payload, even for a journal that has never run anything: absent and zero look
69
+ * identical on a graph and mean opposite things. A series that only appears once it is non-zero
70
+ * makes its first value read as a jump from nothing, and "no data" can never be alerted on.
71
+ */
72
+ export async function toOtlpMetricsJson(journal, opts) {
73
+ // The label is DERIVED from the handle, not accepted alongside it. `withOrg` marks the journal it
74
+ // returns and `orgScopeOf` reads that mark back, so the numbers and the name they are published
75
+ // under come from one source and cannot drift apart. An explicit `orgId` is still allowed for a
76
+ // caller that scopes some other way, but one that contradicts the handle is a programming error
77
+ // loud enough to stop on: it would put this tenant's traffic on another tenant's graph.
78
+ const scoped = orgScopeOf(journal);
79
+ if (scoped && opts.orgId && opts.orgId !== scoped) {
80
+ throw new Error(`@gnldev/otel: the journal is scoped to organization '${scoped}' but orgId '${opts.orgId}' was given. `
81
+ + `The counters would be ${scoped}'s and the metrics would be labelled ${opts.orgId} — pass the journal `
82
+ + `for the organization you mean, and leave orgId unset.`);
83
+ }
84
+ const orgId = scoped ?? opts.orgId;
85
+ const now = opts.now ?? Date.now();
86
+ const time = nano(now);
87
+ const start = nano(opts.startTime);
88
+ // `readCounter`, never `getCounters`: a logical counter is spread over `METRICS_SHARDS` physical
89
+ // rows and a direct read answers for ONE of them — roughly a sixteenth of the traffic at the
90
+ // default, which is low enough to look like a quiet week rather than a bug.
91
+ const fields = (await readCounter(journal, METRICS_ALL_KEY)) ?? {};
92
+ const n = (k) => Number(fields[k] ?? 0);
93
+ const metrics = [
94
+ sumMetric('gnl.runs', '{run}', n('runs'), start, time),
95
+ sumMetric('gnl.tokens', '{token}', n('tokens'), start, time),
96
+ sumMetric('gnl.model_steps', '{step}', n('modelSteps'), start, time),
97
+ sumMetric('gnl.tool_calls', '{call}', n('toolCalls'), start, time),
98
+ {
99
+ // Divided exactly once, here. Money accumulates as integer micro-USD because a float counter
100
+ // drifts; the dashboard wants dollars. Emitting the raw micros ALONGSIDE this would be worse
101
+ // than either alone — a panel summing everything that looks like a cost adds micros to
102
+ // dollars, and no individual number in it is wrong.
103
+ name: 'gnl.cost.usd',
104
+ unit: 'USD',
105
+ sum: {
106
+ dataPoints: [{ attributes: [], startTimeUnixNano: start, timeUnixNano: time, asDouble: n('costUsdMicros') / 1_000_000 }],
107
+ aggregationTemporality: CUMULATIVE,
108
+ isMonotonic: true,
109
+ },
110
+ },
111
+ {
112
+ name: 'gnl.run.duration',
113
+ unit: 'ms',
114
+ histogram: {
115
+ dataPoints: [{
116
+ attributes: [],
117
+ startTimeUnixNano: start,
118
+ timeUnixNano: time,
119
+ count: String(Math.round(n('runs'))),
120
+ sum: n('durMs'),
121
+ bucketCounts: DURATION_FIELDS.map((f) => String(Math.round(n(f)))),
122
+ explicitBounds: DURATION_BOUNDS,
123
+ }],
124
+ aggregationTemporality: CUMULATIVE,
125
+ },
126
+ },
127
+ ];
128
+ return {
129
+ resourceMetrics: [{
130
+ resource: {
131
+ // Organization identifies the whole stream, so it sits on the resource rather than on every
132
+ // data point — where it would be repeated per metric and would invite someone to put two
133
+ // organizations in one export. Nothing unbounded goes anywhere near a label: `runId` is the
134
+ // one that would ruin a backend, and it is right there in the per-run rows the Studio reads.
135
+ attributes: toKv({
136
+ 'service.name': opts.serviceName ?? 'gnl',
137
+ ...(orgId ? { 'gnl.org.id': orgId } : {}),
138
+ ...(opts.resourceAttributes ?? {}),
139
+ }),
140
+ },
141
+ scopeMetrics: [{ scope: { name: '@gnldev/otel' }, metrics }],
142
+ }],
143
+ };
144
+ }
145
+ //# sourceMappingURL=metrics.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"metrics.js","sourceRoot":"","sources":["../src/metrics.ts"],"names":[],"mappings":"AAAA,gGAAgG;AAChG,EAAE;AACF,mGAAmG;AACnG,mGAAmG;AACnG,iGAAiG;AACjG,kGAAkG;AAClG,+FAA+F;AAC/F,iGAAiG;AACjG,6EAA6E;AAC7E,EAAE;AACF,kDAAkD;AAClD,EAAE;AACF,gGAAgG;AAChG,mGAAmG;AACnG,oGAAoG;AACpG,oGAAoG;AACpG,8EAA8E;AAC9E,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE3E,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAGvC,8DAA8D;AAC9D,MAAM,UAAU,GAAG,CAAC,CAAC;AAErB;;;;;;GAMG;AACH,MAAM,eAAe,GAAG,CAAC,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;AACvD,MAAM,eAAe,GAAG,CAAC,SAAS,EAAE,SAAS,EAAE,UAAU,EAAE,UAAU,EAAE,WAAW,CAAU,CAAC;AAiE7F;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,SAAS,CAAC,IAA8B,EAAE,KAAa,EAAE,GAAW;IAClF,IAAI,CAAC,IAAI;QAAE,OAAO,EAAE,SAAS,EAAE,GAAG,EAAE,KAAK,EAAE,CAAC;IAC5C,IAAI,KAAK,GAAG,IAAI,CAAC,KAAK;QAAE,OAAO,EAAE,SAAS,EAAE,GAAG,EAAE,KAAK,EAAE,CAAC,CAAC,qCAAqC;IAC/F,OAAO,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,KAAK,EAAE,CAAC;AAC9C,CAAC;AAED,SAAS,QAAQ,CAAC,KAAa,EAAE,iBAAyB,EAAE,YAAoB;IAC9E,iGAAiG;IACjG,iGAAiG;IACjG,OAAO,EAAE,UAAU,EAAE,EAAE,EAAE,iBAAiB,EAAE,YAAY,EAAE,KAAK,EAAE,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC;AAC/F,CAAC;AAED,SAAS,SAAS,CAAC,IAAY,EAAE,IAAY,EAAE,KAAa,EAAE,KAAa,EAAE,IAAY;IACvF,OAAO;QACL,IAAI;QACJ,IAAI;QACJ,GAAG,EAAE,EAAE,UAAU,EAAE,CAAC,QAAQ,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,CAAC,CAAC,EAAE,sBAAsB,EAAE,UAAU,EAAE,WAAW,EAAE,IAAI,EAAE;KAC3G,CAAC;AACJ,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAC,OAAgB,EAAE,IAA0B;IAClF,kGAAkG;IAClG,gGAAgG;IAChG,gGAAgG;IAChG,gGAAgG;IAChG,wFAAwF;IACxF,MAAM,MAAM,GAAG,UAAU,CAAC,OAAO,CAAC,CAAC;IACnC,IAAI,MAAM,IAAI,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,KAAK,KAAK,MAAM,EAAE,CAAC;QAClD,MAAM,IAAI,KAAK,CACb,wDAAwD,MAAM,gBAAgB,IAAI,CAAC,KAAK,eAAe;cACrG,yBAAyB,MAAM,wCAAwC,IAAI,CAAC,KAAK,sBAAsB;cACvG,uDAAuD,CAC1D,CAAC;IACJ,CAAC;IACD,MAAM,KAAK,GAAG,MAAM,IAAI,IAAI,CAAC,KAAK,CAAC;IAEnC,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,IAAI,IAAI,CAAC,GAAG,EAAE,CAAC;IACnC,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC;IACvB,MAAM,KAAK,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;IAEnC,iGAAiG;IACjG,6FAA6F;IAC7F,4EAA4E;IAC5E,MAAM,MAAM,GAAG,CAAC,MAAM,WAAW,CAAC,OAAO,EAAE,eAAe,CAAC,CAAC,IAAI,EAAE,CAAC;IACnE,MAAM,CAAC,GAAG,CAAC,CAAS,EAAE,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;IAEhD,MAAM,OAAO,GAAiB;QAC5B,SAAS,CAAC,UAAU,EAAE,OAAO,EAAE,CAAC,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,IAAI,CAAC;QACtD,SAAS,CAAC,YAAY,EAAE,SAAS,EAAE,CAAC,CAAC,QAAQ,CAAC,EAAE,KAAK,EAAE,IAAI,CAAC;QAC5D,SAAS,CAAC,iBAAiB,EAAE,QAAQ,EAAE,CAAC,CAAC,YAAY,CAAC,EAAE,KAAK,EAAE,IAAI,CAAC;QACpE,SAAS,CAAC,gBAAgB,EAAE,QAAQ,EAAE,CAAC,CAAC,WAAW,CAAC,EAAE,KAAK,EAAE,IAAI,CAAC;QAClE;YACE,6FAA6F;YAC7F,6FAA6F;YAC7F,uFAAuF;YACvF,oDAAoD;YACpD,IAAI,EAAE,cAAc;YACpB,IAAI,EAAE,KAAK;YACX,GAAG,EAAE;gBACH,UAAU,EAAE,CAAC,EAAE,UAAU,EAAE,EAAE,EAAE,iBAAiB,EAAE,KAAK,EAAE,YAAY,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC,CAAC,eAAe,CAAC,GAAG,SAAS,EAAE,CAAC;gBACxH,sBAAsB,EAAE,UAAU;gBAClC,WAAW,EAAE,IAAI;aAClB;SACF;QACD;YACE,IAAI,EAAE,kBAAkB;YACxB,IAAI,EAAE,IAAI;YACV,SAAS,EAAE;gBACT,UAAU,EAAE,CAAC;wBACX,UAAU,EAAE,EAAE;wBACd,iBAAiB,EAAE,KAAK;wBACxB,YAAY,EAAE,IAAI;wBAClB,KAAK,EAAE,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC;wBACpC,GAAG,EAAE,CAAC,CAAC,OAAO,CAAC;wBACf,YAAY,EAAE,eAAe,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;wBAClE,cAAc,EAAE,eAAe;qBAChC,CAAC;gBACF,sBAAsB,EAAE,UAAU;aACnC;SACF;KACF,CAAC;IAEF,OAAO;QACL,eAAe,EAAE,CAAC;gBAChB,QAAQ,EAAE;oBACR,4FAA4F;oBAC5F,yFAAyF;oBACzF,4FAA4F;oBAC5F,6FAA6F;oBAC7F,UAAU,EAAE,IAAI,CAAC;wBACf,cAAc,EAAE,IAAI,CAAC,WAAW,IAAI,KAAK;wBACzC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,YAAY,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;wBACzC,GAAG,CAAC,IAAI,CAAC,kBAAkB,IAAI,EAAE,CAAC;qBACnC,CAAC;iBACH;gBACD,YAAY,EAAE,CAAC,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,cAAc,EAAE,EAAE,OAAO,EAAE,CAAC;aAC7D,CAAC;KACH,CAAC;AACJ,CAAC","sourcesContent":["// @gnldev/otel/metrics — the journal's materialized counters as an OTLP/HTTP JSON metrics body.\n//\n// Same ethos as otlp.ts: no OTel SDK, the body is built by hand and sent with `fetch`. That is not\n// only about size here — it is the only way this data can be expressed. The counters hold duration\n// BUCKET COUNTS, never the individual observations, and the SDK's histogram API accepts recorded\n// values (`histogram.record(ms)`). Feeding pre-aggregated buckets through it would mean writing a\n// custom MetricReader that emits `ResourceMetrics` directly, i.e. building the payload by hand\n// anyway and paying for a dependency that contributes only transport — transport otlp.ts already\n// has, with redirect refusal, per-attempt timeouts and Retry-After handling.\n//\n// What is exported, and what deliberately is not:\n//\n// `__metrics__:all` ONLY. The day buckets are not emitted, and that is a correctness decision\n// before it is a cost one. A cumulative series whose value returns to zero every UTC midnight is\n// read by every collector as a counter reset, so daily counters shipped as cumulative sums invent\n// a spike once a day. (They are also 15x the reads: a bucket is `1 + METRICS_SHARDS` point reads,\n// so `all` is 17 and `all` + 14 days is 255 — per organization, per round.)\nimport { readCounter, METRICS_ALL_KEY, orgScopeOf } from '@gnldev/durable';\nimport type { Journal } from '@gnldev/durable';\nimport { toKv, nano } from './otlp.js';\nimport type { OtlpKeyValue } from './otlp.js';\n\n/** OTLP AggregationTemporality: 1 = DELTA, 2 = CUMULATIVE. */\nconst CUMULATIVE = 2;\n\n/**\n * The duration buckets `metrics.ts` writes, in the order OTLP wants them.\n *\n * `explicitBounds` has one fewer entry than `bucketCounts`: the last count is the implicit `+Inf`\n * bucket. The bounds must match `durationBucketField`'s thresholds exactly — a mismatch here does\n * not fail anywhere, it just relabels every latency on the dashboard.\n */\nconst DURATION_BOUNDS = [1_000, 5_000, 15_000, 60_000];\nconst DURATION_FIELDS = ['durLt1s', 'durLt5s', 'durLt15s', 'durLt60s', 'durGte60s'] as const;\n\nexport interface OtlpNumberDataPoint {\n attributes: OtlpKeyValue[];\n startTimeUnixNano: string;\n timeUnixNano: string;\n asInt?: string;\n asDouble?: number;\n}\nexport interface OtlpHistogramDataPoint {\n attributes: OtlpKeyValue[];\n startTimeUnixNano: string;\n timeUnixNano: string;\n count: string;\n sum: number;\n bucketCounts: string[];\n explicitBounds: number[];\n}\nexport interface OtlpMetric {\n name: string;\n unit?: string;\n sum?: { dataPoints: OtlpNumberDataPoint[]; aggregationTemporality: number; isMonotonic: boolean };\n histogram?: { dataPoints: OtlpHistogramDataPoint[]; aggregationTemporality: number };\n}\nexport interface OtlpMetricsPayload {\n resourceMetrics: {\n resource: { attributes: OtlpKeyValue[] };\n scopeMetrics: { scope: { name: string }; metrics: OtlpMetric[] }[];\n }[];\n}\n\nexport interface ToOtlpMetricsOptions {\n /** resource attribute service.name. */\n serviceName?: string;\n /**\n * The organization label, for the case where the caller scopes by some other means.\n *\n * Normally leave it unset: a journal from `withOrg` carries its organization on itself, and the\n * label is read from there. Supplying one that DISAGREES with the handle throws, because the two\n * failure modes are not symmetrical — the numbers come from the handle and the label is what the\n * dashboard believes, so a mismatch publishes one tenant's traffic under another tenant's name\n * and nothing downstream can detect it. That was the shape of this option before: two independent\n * inputs that had to agree, with nothing checking that they did.\n */\n orgId?: string;\n resourceAttributes?: Record<string, string | number | boolean>;\n /** Observation time (ms). Injectable so a test is deterministic; default `Date.now()`. */\n now?: number;\n /**\n * When this journal's counters started accumulating (ms) — see `nextEpoch`, which decides it.\n *\n * Required rather than defaulted, because every plausible default is wrong. `Date.now()` moves on\n * every export and on every process restart, and a collector reading a fresh start time on a\n * series it already knows treats it as a counter reset: the entire accumulated total gets charged\n * to one interval, as a spike that never happened.\n */\n startTime: number;\n}\n\n/** What `nextEpoch` remembers between exports. */\nexport interface MetricsEpoch {\n startTime: number;\n total: number;\n}\n\n/**\n * Decides the `startTimeUnixNano` for the next export, which is the whole of the reset handling.\n *\n * The rule has two halves that pull in opposite directions, and both matter:\n *\n * grew or held → keep the epoch. This is the common case, and renewing it here is the bug that\n * puts a phantom spike on the graph after every deploy.\n * FELL → move it. `rebuildMetrics` recomputes the counters from the journal and the\n * total legitimately drops after a purge. Keeping the epoch through that shows\n * a monotonic series going backwards, and the collector reconstructs a rate out\n * of the wraparound.\n *\n * `total` is one number on purpose: any of the counters falling means the same rebuild happened, so\n * `runs` stands in for all of them.\n */\nexport function nextEpoch(prev: MetricsEpoch | undefined, total: number, now: number): MetricsEpoch {\n if (!prev) return { startTime: now, total };\n if (total < prev.total) return { startTime: now, total }; // a rebuild: the series starts again\n return { startTime: prev.startTime, total };\n}\n\nfunction intPoint(value: number, startTimeUnixNano: string, timeUnixNano: string): OtlpNumberDataPoint {\n // `asInt` is a STRING: OTLP/JSON maps uint64 that way (see otlp.ts), and a number would silently\n // lose precision on token totals past 2^53 — the one deployment size where it would ever matter.\n return { attributes: [], startTimeUnixNano, timeUnixNano, asInt: String(Math.round(value)) };\n}\n\nfunction sumMetric(name: string, unit: string, value: number, start: string, time: string): OtlpMetric {\n return {\n name,\n unit,\n sum: { dataPoints: [intPoint(value, start, time)], aggregationTemporality: CUMULATIVE, isMonotonic: true },\n };\n}\n\n/**\n * Reads the running totals and returns an OTLP/HTTP metrics body.\n *\n * Always returns a payload, even for a journal that has never run anything: absent and zero look\n * identical on a graph and mean opposite things. A series that only appears once it is non-zero\n * makes its first value read as a jump from nothing, and \"no data\" can never be alerted on.\n */\nexport async function toOtlpMetricsJson(journal: Journal, opts: ToOtlpMetricsOptions): Promise<OtlpMetricsPayload> {\n // The label is DERIVED from the handle, not accepted alongside it. `withOrg` marks the journal it\n // returns and `orgScopeOf` reads that mark back, so the numbers and the name they are published\n // under come from one source and cannot drift apart. An explicit `orgId` is still allowed for a\n // caller that scopes some other way, but one that contradicts the handle is a programming error\n // loud enough to stop on: it would put this tenant's traffic on another tenant's graph.\n const scoped = orgScopeOf(journal);\n if (scoped && opts.orgId && opts.orgId !== scoped) {\n throw new Error(\n `@gnldev/otel: the journal is scoped to organization '${scoped}' but orgId '${opts.orgId}' was given. `\n + `The counters would be ${scoped}'s and the metrics would be labelled ${opts.orgId} — pass the journal `\n + `for the organization you mean, and leave orgId unset.`,\n );\n }\n const orgId = scoped ?? opts.orgId;\n\n const now = opts.now ?? Date.now();\n const time = nano(now);\n const start = nano(opts.startTime);\n\n // `readCounter`, never `getCounters`: a logical counter is spread over `METRICS_SHARDS` physical\n // rows and a direct read answers for ONE of them — roughly a sixteenth of the traffic at the\n // default, which is low enough to look like a quiet week rather than a bug.\n const fields = (await readCounter(journal, METRICS_ALL_KEY)) ?? {};\n const n = (k: string) => Number(fields[k] ?? 0);\n\n const metrics: OtlpMetric[] = [\n sumMetric('gnl.runs', '{run}', n('runs'), start, time),\n sumMetric('gnl.tokens', '{token}', n('tokens'), start, time),\n sumMetric('gnl.model_steps', '{step}', n('modelSteps'), start, time),\n sumMetric('gnl.tool_calls', '{call}', n('toolCalls'), start, time),\n {\n // Divided exactly once, here. Money accumulates as integer micro-USD because a float counter\n // drifts; the dashboard wants dollars. Emitting the raw micros ALONGSIDE this would be worse\n // than either alone — a panel summing everything that looks like a cost adds micros to\n // dollars, and no individual number in it is wrong.\n name: 'gnl.cost.usd',\n unit: 'USD',\n sum: {\n dataPoints: [{ attributes: [], startTimeUnixNano: start, timeUnixNano: time, asDouble: n('costUsdMicros') / 1_000_000 }],\n aggregationTemporality: CUMULATIVE,\n isMonotonic: true,\n },\n },\n {\n name: 'gnl.run.duration',\n unit: 'ms',\n histogram: {\n dataPoints: [{\n attributes: [],\n startTimeUnixNano: start,\n timeUnixNano: time,\n count: String(Math.round(n('runs'))),\n sum: n('durMs'),\n bucketCounts: DURATION_FIELDS.map((f) => String(Math.round(n(f)))),\n explicitBounds: DURATION_BOUNDS,\n }],\n aggregationTemporality: CUMULATIVE,\n },\n },\n ];\n\n return {\n resourceMetrics: [{\n resource: {\n // Organization identifies the whole stream, so it sits on the resource rather than on every\n // data point — where it would be repeated per metric and would invite someone to put two\n // organizations in one export. Nothing unbounded goes anywhere near a label: `runId` is the\n // one that would ruin a backend, and it is right there in the per-run rows the Studio reads.\n attributes: toKv({\n 'service.name': opts.serviceName ?? 'gnl',\n ...(orgId ? { 'gnl.org.id': orgId } : {}),\n ...(opts.resourceAttributes ?? {}),\n }),\n },\n scopeMetrics: [{ scope: { name: '@gnldev/otel' }, metrics }],\n }],\n };\n}\n"]}
package/dist/otlp.d.ts ADDED
@@ -0,0 +1,112 @@
1
+ import type { JournalReader, TraceSpan } from '@gnldev/durable';
2
+ /** runId → 16-byte (32-hex) deterministic trace id — the same run yields the SAME trace on every export (idempotent). */
3
+ export declare function otlpTraceId(runId: string): string;
4
+ /** (runId, seq|'root') → 8-byte (16-hex) deterministic span id. */
5
+ export declare function otlpSpanId(runId: string, seq: number | 'root'): string;
6
+ export type OtlpAttributeValue = {
7
+ stringValue: string;
8
+ } | {
9
+ intValue: string;
10
+ } | {
11
+ doubleValue: number;
12
+ } | {
13
+ boolValue: boolean;
14
+ };
15
+ export interface OtlpKeyValue {
16
+ key: string;
17
+ value: OtlpAttributeValue;
18
+ }
19
+ export interface OtlpSpan {
20
+ traceId: string;
21
+ spanId: string;
22
+ parentSpanId?: string;
23
+ name: string;
24
+ kind: number;
25
+ /** In OTLP/JSON, uint64 fields are STRINGS to avoid precision loss (protobuf JSON mapping). */
26
+ startTimeUnixNano: string;
27
+ endTimeUnixNano: string;
28
+ attributes: OtlpKeyValue[];
29
+ }
30
+ export interface OtlpPayload {
31
+ resourceSpans: Array<{
32
+ resource: {
33
+ attributes: OtlpKeyValue[];
34
+ };
35
+ scopeSpans: Array<{
36
+ scope: {
37
+ name: string;
38
+ };
39
+ spans: OtlpSpan[];
40
+ }>;
41
+ }>;
42
+ }
43
+ export interface ToOtlpJsonOptions {
44
+ runId: string;
45
+ /** resource attribute service.name. */
46
+ serviceName?: string;
47
+ /** Additional resource attributes (deployment.environment, etc.). */
48
+ resourceAttributes?: Record<string, string | number | boolean>;
49
+ /** Timestamp (ms) used when journal entries have no ts at all. Can be injected for determinism in tests; default Date.now(). */
50
+ now?: number;
51
+ }
52
+ /** Package-internal: shared with metrics.ts so the two bodies encode attributes identically.
53
+ * NOT re-exported from index.ts — this is not part of the package's public surface. */
54
+ export declare function toKv(attrs: Record<string, unknown>): OtlpKeyValue[];
55
+ /** ms → nanosecond string (BigInt: no precision loss). */
56
+ /** Package-internal, shared with metrics.ts: ms → the uint64-as-string nanoseconds OTLP/JSON wants. */
57
+ export declare function nano(ms: number): string;
58
+ /**
59
+ * PURE function (NO network, NO OTel SDK): converts `toTraceSpans` output into an OTLP/HTTP JSON body.
60
+ * 1 root span (`agent.run`) + 1 child span per journal entry; trace/span ids are derived deterministically
61
+ * from runId+seq → converting the same run twice yields the SAME ids (idempotent, replay-consistent).
62
+ */
63
+ export declare function toOtlpJson(spans: TraceSpan[], opts: ToOtlpJsonOptions): OtlpPayload;
64
+ export interface OtlpRetryOptions {
65
+ /** Total attempts including the first try (not "extra retries"). Default 3. */
66
+ attempts?: number;
67
+ /** Delay before the NEXT retry — fixed ms, or a function of the retry index (0 = delay before the
68
+ * 2nd attempt, 1 = before the 3rd, ...). Default exponential: `500 * 2^n`. Ignored on a 429 that
69
+ * carries a `Retry-After` header (see below). */
70
+ backoffMs?: number | ((attempt: number) => number);
71
+ /**
72
+ * Decide whether a failure should be retried. Called with the HTTP status code for a non-throwing
73
+ * response, or the thrown `Error` for a network failure. Default: network errors + 408/429/5xx
74
+ * (a 4xx like 400/401/403 is a permanent rejection — retrying it would just waste attempts).
75
+ */
76
+ retryOn?: (result: number | Error) => boolean;
77
+ }
78
+ export interface ExportRunToOtlpOptions {
79
+ /** OTLP/HTTP JSON traces endpoint — e.g. 'http://localhost:4318/v1/traces' (Jaeger/Tempo/Collector) or
80
+ * the user's own Langfuse/Datadog/Honeycomb OTLP proxy. We never send to a default endpoint ourselves. */
81
+ endpoint: string;
82
+ /** Additional HTTP headers (e.g. Authorization, x-honeycomb-team). */
83
+ headers?: Record<string, string>;
84
+ serviceName?: string;
85
+ resourceAttributes?: Record<string, string | number | boolean>;
86
+ now?: number;
87
+ /** Opt-in retry/backoff for the POST (default: none — exactly one attempt, unchanged behavior). */
88
+ retry?: OtlpRetryOptions;
89
+ /**
90
+ * How long one export attempt may take before it is abandoned (ms, default 30000).
91
+ *
92
+ * `fetch` has no default timeout, so a collector that accepts the connection and never answers held
93
+ * this call open with nothing to observe. `retry` made that worse rather than better: retry counts
94
+ * ATTEMPTS, and an attempt that never settles is never a failure, so a configured backoff never got
95
+ * to run — the option that exists for an unhealthy collector was disabled by the specific kind of
96
+ * unhealthy this is. Each attempt is bounded separately, so `retry` now sees a timeout as the
97
+ * failure it is and backs off as configured.
98
+ */
99
+ timeoutMs?: number;
100
+ }
101
+ export interface ExportRunToOtlpResult {
102
+ traceId: string;
103
+ spans: number;
104
+ ok: boolean;
105
+ status: number;
106
+ }
107
+ /**
108
+ * Converts a run from the journal into OTLP/HTTP JSON and POSTs it to the given endpoint. DOES NOT USE
109
+ * the OTel SDK — only `fetch` (Node ≥18 global). The user sends to THEIR OWN backend; for tests use
110
+ * `toOtlpJson` without the network, or mock global `fetch` (see test/otlp.test.ts).
111
+ */
112
+ export declare function exportRunToOtlp(reader: JournalReader, runId: string, opts: ExportRunToOtlpOptions): Promise<ExportRunToOtlpResult>;