otelturbine 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.
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Two-pass protobuf encoder for Prometheus WriteRequest.
3
+ *
4
+ * Pass 1 — size calculation: walks the structure using integer math and
5
+ * Buffer.byteLength (native, non-allocating) to compute the exact total
6
+ * byte count. No heap allocations.
7
+ *
8
+ * Pass 2 — single-allocation write: allocates exactly one Uint8Array, then
9
+ * writes every field directly into it using TextEncoder.encodeInto() and
10
+ * writeIntVarint(). Zero intermediate buffers, zero copies.
11
+ *
12
+ * Proto schema:
13
+ * message Label { string name = 1; string value = 2; }
14
+ * message Sample { double value = 1; int64 timestamp = 2; }
15
+ * message TimeSeries { repeated Label labels = 1; repeated Sample samples = 2; }
16
+ * message WriteRequest { repeated TimeSeries timeseries = 1; }
17
+ */
18
+
19
+ import type { WriteRequest, TimeSeries, Label, Sample } from '../types/prometheus.ts';
20
+ import { writeIntVarint, intVarintSize, writeVarint, varintSize } from '../util/varint.ts';
21
+
22
+ const ENC = new TextEncoder();
23
+
24
+ // ─── Size calculation (pass 1) ──────────────────────────────────────────────
25
+
26
+ function tsSize(ts: bigint): number {
27
+ // Fast path: timestamps in ms since epoch fit in JS safe integer range
28
+ return ts <= 9_007_199_254_740_991n ? intVarintSize(Number(ts)) : varintSize(ts);
29
+ }
30
+
31
+ function labelMsgSize(l: Label): number {
32
+ const nl = Buffer.byteLength(l.name);
33
+ const vl = Buffer.byteLength(l.value);
34
+ // tag(1) + varint(nl) + nl + tag(1) + varint(vl) + vl
35
+ return 1 + intVarintSize(nl) + nl + 1 + intVarintSize(vl) + vl;
36
+ }
37
+
38
+ function sampleMsgSize(s: Sample): number {
39
+ // field1 double: tag(1) + 8 bytes
40
+ // field2 timestamp: tag(1) + varint(ts)
41
+ return 9 + 1 + tsSize(s.timestamp);
42
+ }
43
+
44
+ function timeSeriesMsgSize(ts: TimeSeries): number {
45
+ let size = 0;
46
+ for (const l of ts.labels) {
47
+ const lms = labelMsgSize(l);
48
+ size += 1 + intVarintSize(lms) + lms;
49
+ }
50
+ for (const s of ts.samples) {
51
+ const sms = sampleMsgSize(s);
52
+ size += 1 + intVarintSize(sms) + sms;
53
+ }
54
+ return size;
55
+ }
56
+
57
+ function computeTotalSize(req: WriteRequest): number {
58
+ let size = 0;
59
+ for (const ts of req.timeseries) {
60
+ const tsms = timeSeriesMsgSize(ts);
61
+ size += 1 + intVarintSize(tsms) + tsms;
62
+ }
63
+ return size;
64
+ }
65
+
66
+ // ─── Write pass (pass 2) ────────────────────────────────────────────────────
67
+
68
+ function writeLabel(buf: Uint8Array, off: number, l: Label): number {
69
+ buf[off++] = 0x0a; // field 1 (name), LEN
70
+ const nl = Buffer.byteLength(l.name);
71
+ off += writeIntVarint(buf, off, nl);
72
+ ENC.encodeInto(l.name, buf.subarray(off));
73
+ off += nl;
74
+
75
+ buf[off++] = 0x12; // field 2 (value), LEN
76
+ const vl = Buffer.byteLength(l.value);
77
+ off += writeIntVarint(buf, off, vl);
78
+ ENC.encodeInto(l.value, buf.subarray(off));
79
+ off += vl;
80
+
81
+ return off;
82
+ }
83
+
84
+ function writeSample(buf: Uint8Array, view: DataView, off: number, s: Sample): number {
85
+ buf[off++] = 0x09; // field 1 (value), 64-bit fixed
86
+ view.setFloat64(off, s.value, true /* LE */);
87
+ off += 8;
88
+
89
+ buf[off++] = 0x10; // field 2 (timestamp), varint
90
+ const ts = s.timestamp;
91
+ if (ts <= 9_007_199_254_740_991n) {
92
+ off += writeIntVarint(buf, off, Number(ts));
93
+ } else {
94
+ off += writeVarint(buf, off, ts);
95
+ }
96
+ return off;
97
+ }
98
+
99
+ function writeTimeSeries(buf: Uint8Array, view: DataView, off: number, ts: TimeSeries): number {
100
+ for (const l of ts.labels) {
101
+ buf[off++] = 0x0a; // field 1 (labels), LEN
102
+ const lms = labelMsgSize(l);
103
+ off += writeIntVarint(buf, off, lms);
104
+ off = writeLabel(buf, off, l);
105
+ }
106
+ for (const s of ts.samples) {
107
+ buf[off++] = 0x12; // field 2 (samples), LEN
108
+ const sms = sampleMsgSize(s);
109
+ off += writeIntVarint(buf, off, sms);
110
+ off = writeSample(buf, view, off, s);
111
+ }
112
+ return off;
113
+ }
114
+
115
+ // ─── Public API ─────────────────────────────────────────────────────────────
116
+
117
+ export function encodeWriteRequest(req: WriteRequest): Uint8Array {
118
+ if (req.timeseries.length === 0) return new Uint8Array(0);
119
+
120
+ const totalSize = computeTotalSize(req);
121
+ const buf = new Uint8Array(totalSize);
122
+ const view = new DataView(buf.buffer); // one DataView for the entire buffer
123
+
124
+ let off = 0;
125
+ for (const ts of req.timeseries) {
126
+ buf[off++] = 0x0a; // field 1 (timeseries), LEN
127
+ const tsms = timeSeriesMsgSize(ts);
128
+ off += writeIntVarint(buf, off, tsms);
129
+ off = writeTimeSeries(buf, view, off, ts);
130
+ }
131
+
132
+ return buf;
133
+ }
@@ -0,0 +1,134 @@
1
+ import { describe, it, expect, mock, beforeEach, afterEach } from 'bun:test';
2
+ import { bunHandler, bunRouteHandler } from '../adapters/bun.ts';
3
+ import { Pipeline } from '../core/Pipeline.ts';
4
+
5
+ const validOtlpPayload = JSON.stringify({
6
+ resourceMetrics: [
7
+ {
8
+ resource: {},
9
+ scopeMetrics: [
10
+ {
11
+ metrics: [
12
+ {
13
+ name: 'test_metric',
14
+ gauge: {
15
+ dataPoints: [
16
+ { timeUnixNano: '1700000000000000000', asDouble: 1.0 },
17
+ ],
18
+ },
19
+ },
20
+ ],
21
+ },
22
+ ],
23
+ },
24
+ ],
25
+ });
26
+
27
+ describe('bunHandler', () => {
28
+ let originalFetch: typeof globalThis.fetch;
29
+
30
+ beforeEach(() => {
31
+ originalFetch = globalThis.fetch;
32
+ globalThis.fetch = mock(async () => new Response(null, { status: 204 })) as unknown as typeof fetch;
33
+ });
34
+
35
+ afterEach(() => {
36
+ globalThis.fetch = originalFetch;
37
+ });
38
+
39
+ function makePipeline() {
40
+ return new Pipeline(
41
+ { url: 'http://localhost:9090/api/v1/write', timeout: 5000 },
42
+ [],
43
+ 'pass'
44
+ );
45
+ }
46
+
47
+ it('returns 404 for wrong path', async () => {
48
+ const handler = bunHandler(makePipeline(), '/v1/metrics');
49
+ const req = new Request('http://localhost/wrong/path', {
50
+ method: 'POST',
51
+ body: validOtlpPayload,
52
+ headers: { 'Content-Type': 'application/json' },
53
+ });
54
+ const res = await handler(req);
55
+ expect(res.status).toBe(404);
56
+ });
57
+
58
+ it('returns 405 for GET request', async () => {
59
+ const handler = bunHandler(makePipeline(), '/v1/metrics');
60
+ const req = new Request('http://localhost/v1/metrics', { method: 'GET' });
61
+ const res = await handler(req);
62
+ expect(res.status).toBe(405);
63
+ });
64
+
65
+ it('returns 405 for PUT request', async () => {
66
+ const handler = bunHandler(makePipeline(), '/v1/metrics');
67
+ const req = new Request('http://localhost/v1/metrics', { method: 'PUT', body: '' });
68
+ const res = await handler(req);
69
+ expect(res.status).toBe(405);
70
+ });
71
+
72
+ it('processes valid POST request', async () => {
73
+ const handler = bunHandler(makePipeline(), '/v1/metrics');
74
+ const req = new Request('http://localhost/v1/metrics', {
75
+ method: 'POST',
76
+ body: validOtlpPayload,
77
+ headers: { 'Content-Type': 'application/json' },
78
+ });
79
+ const res = await handler(req);
80
+ expect(res.status).toBe(204);
81
+ });
82
+
83
+ it('returns 405 with Allow header', async () => {
84
+ const handler = bunHandler(makePipeline(), '/v1/metrics');
85
+ const req = new Request('http://localhost/v1/metrics', { method: 'GET' });
86
+ const res = await handler(req);
87
+ expect(res.headers.get('Allow')).toBe('POST');
88
+ });
89
+ });
90
+
91
+ describe('bunRouteHandler', () => {
92
+ let originalFetch: typeof globalThis.fetch;
93
+
94
+ beforeEach(() => {
95
+ originalFetch = globalThis.fetch;
96
+ globalThis.fetch = mock(async () => new Response(null, { status: 204 })) as unknown as typeof fetch;
97
+ });
98
+
99
+ afterEach(() => {
100
+ globalThis.fetch = originalFetch;
101
+ });
102
+
103
+ it('processes valid POST request', async () => {
104
+ const pipeline = new Pipeline(
105
+ { url: 'http://localhost:9090/api/v1/write', timeout: 5000 },
106
+ [],
107
+ 'pass'
108
+ );
109
+ const handler = bunRouteHandler(pipeline);
110
+ const req = new Request('http://localhost/v1/metrics', {
111
+ method: 'POST',
112
+ body: validOtlpPayload,
113
+ headers: { 'Content-Type': 'application/json' },
114
+ });
115
+ const res = await handler(req);
116
+ expect(res.status).toBe(204);
117
+ });
118
+
119
+ it('returns 400 for bad JSON', async () => {
120
+ const pipeline = new Pipeline(
121
+ { url: 'http://localhost:9090/api/v1/write', timeout: 5000 },
122
+ [],
123
+ 'pass'
124
+ );
125
+ const handler = bunRouteHandler(pipeline);
126
+ const req = new Request('http://localhost/v1/metrics', {
127
+ method: 'POST',
128
+ body: 'bad json',
129
+ headers: { 'Content-Type': 'application/json' },
130
+ });
131
+ const res = await handler(req);
132
+ expect(res.status).toBe(400);
133
+ });
134
+ });
@@ -0,0 +1,120 @@
1
+ import { describe, it, expect } from 'bun:test';
2
+ import {
3
+ createCompatHandler,
4
+ type CompatRequestLike,
5
+ } from '../core/Compat.ts';
6
+ import {
7
+ applyRequestLabelInjections,
8
+ type LabelInjectionRule,
9
+ } from '../core/Pipeline.ts';
10
+ import type { TimeSeries } from '../types/prometheus.ts';
11
+
12
+ function makeSeries(name: string, labels: Record<string, string> = {}): TimeSeries {
13
+ return {
14
+ labels: [
15
+ { name: '__name__', value: name },
16
+ ...Object.entries(labels).map(([k, v]) => ({ name: k, value: v })),
17
+ ].sort((a, b) => a.name.localeCompare(b.name)),
18
+ samples: [{ value: 1, timestamp: 1n }],
19
+ };
20
+ }
21
+
22
+ describe('compat handler', () => {
23
+ it('passes per-request injection rules to pipeline.process', async () => {
24
+ const calls: Array<{ body: string | Uint8Array; contentType: string; injectCount: number }> = [];
25
+ const fakePipeline = {
26
+ process: async (
27
+ body: string | Uint8Array,
28
+ contentType: string,
29
+ options?: { injectLabels?: LabelInjectionRule[] }
30
+ ) => {
31
+ calls.push({
32
+ body,
33
+ contentType,
34
+ injectCount: options?.injectLabels?.length ?? 0,
35
+ });
36
+ return { status: 200, message: 'OK' };
37
+ },
38
+ };
39
+
40
+ const otelTurbine = createCompatHandler(fakePipeline as never);
41
+ const req: CompatRequestLike = {
42
+ method: 'POST',
43
+ headers: { 'content-type': 'application/json' },
44
+ body: '{"resourceMetrics":[]}',
45
+ };
46
+
47
+ const result = await otelTurbine(req)
48
+ .injectLabel('*', { instance_name: 'worker-a' })
49
+ .push();
50
+
51
+ expect(result.status).toBe(200);
52
+ expect(calls).toHaveLength(1);
53
+ expect(calls[0]!.injectCount).toBe(1);
54
+ expect(calls[0]!.contentType).toBe('application/json');
55
+ });
56
+
57
+ it('keeps request ownership isolated between calls', async () => {
58
+ const injectCounts: number[] = [];
59
+ const fakePipeline = {
60
+ process: async (
61
+ _body: string | Uint8Array,
62
+ _contentType: string,
63
+ options?: { injectLabels?: LabelInjectionRule[] }
64
+ ) => {
65
+ injectCounts.push(options?.injectLabels?.length ?? 0);
66
+ return { status: 200, message: 'OK' };
67
+ },
68
+ };
69
+
70
+ const otelTurbine = createCompatHandler(fakePipeline as never);
71
+ const req: CompatRequestLike = {
72
+ method: 'POST',
73
+ headers: { 'content-type': 'application/json' },
74
+ body: '{"resourceMetrics":[]}',
75
+ };
76
+
77
+ await otelTurbine(req).injectLabel('*', { instance_name: 'one' }).push();
78
+ await otelTurbine(req).push();
79
+
80
+ expect(injectCounts).toEqual([1, 0]);
81
+ });
82
+
83
+ it('returns 405 for non-POST methods', async () => {
84
+ const fakePipeline = {
85
+ process: async () => ({ status: 200, message: 'OK' }),
86
+ };
87
+ const otelTurbine = createCompatHandler(fakePipeline as never);
88
+ const result = await otelTurbine({
89
+ method: 'GET',
90
+ headers: { 'content-type': 'application/json' },
91
+ body: '{}',
92
+ }).push();
93
+ expect(result.status).toBe(405);
94
+ });
95
+ });
96
+
97
+ describe('applyRequestLabelInjections', () => {
98
+ it('injects labels for wildcard selector', () => {
99
+ const out = applyRequestLabelInjections(
100
+ [makeSeries('cpu_usage', { host: 'a' })],
101
+ [{ selector: '*', labels: { instance_name: 'node-1' } }]
102
+ );
103
+ expect(out[0]!.labels.find((l) => l.name === 'instance_name')?.value).toBe('node-1');
104
+ });
105
+
106
+ it('supports exact and regex selectors', () => {
107
+ const out = applyRequestLabelInjections(
108
+ [
109
+ makeSeries('http_requests_total'),
110
+ makeSeries('db_connections'),
111
+ ],
112
+ [
113
+ { selector: 'db_connections', labels: { domain: 'db' } },
114
+ { selector: /^http_/, labels: { domain: 'http' } },
115
+ ]
116
+ );
117
+ expect(out[0]!.labels.find((l) => l.name === 'domain')?.value).toBe('http');
118
+ expect(out[1]!.labels.find((l) => l.name === 'domain')?.value).toBe('db');
119
+ });
120
+ });
@@ -0,0 +1,184 @@
1
+ import { describe, it, expect } from 'bun:test';
2
+ import { otlpToTimeSeries } from '../transform/otlpToTimeSeries.ts';
3
+ import type { OtlpMetricsPayload } from '../types/otlp.ts';
4
+
5
+ const basePayload = (metrics: OtlpMetricsPayload['resourceMetrics'][0]['scopeMetrics'][0]['metrics']): OtlpMetricsPayload => ({
6
+ resourceMetrics: [
7
+ {
8
+ resource: { attributes: [{ key: 'service.name', value: { stringValue: 'test-svc' } }] },
9
+ scopeMetrics: [{ metrics }],
10
+ },
11
+ ],
12
+ });
13
+
14
+ describe('otlpToTimeSeries', () => {
15
+ describe('gauge', () => {
16
+ it('converts a gauge to a single TimeSeries', () => {
17
+ const payload = basePayload([
18
+ {
19
+ name: 'cpu_usage',
20
+ gauge: {
21
+ dataPoints: [
22
+ {
23
+ attributes: [{ key: 'host', value: { stringValue: 'server1' } }],
24
+ timeUnixNano: '1700000000000000000',
25
+ asDouble: 0.75,
26
+ },
27
+ ],
28
+ },
29
+ },
30
+ ]);
31
+
32
+ const series = otlpToTimeSeries(payload);
33
+ expect(series).toHaveLength(1);
34
+ const ts = series[0]!;
35
+ expect(ts.labels.find(l => l.name === '__name__')?.value).toBe('cpu_usage');
36
+ expect(ts.labels.find(l => l.name === 'host')?.value).toBe('server1');
37
+ expect(ts.labels.find(l => l.name === 'service.name')?.value).toBe('test-svc');
38
+ expect(ts.samples[0]?.value).toBe(0.75);
39
+ expect(ts.samples[0]?.timestamp).toBe(1700000000000n);
40
+ });
41
+
42
+ it('labels are sorted alphabetically', () => {
43
+ const payload = basePayload([
44
+ {
45
+ name: 'mem_usage',
46
+ gauge: {
47
+ dataPoints: [
48
+ {
49
+ attributes: [
50
+ { key: 'zone', value: { stringValue: 'us-east' } },
51
+ { key: 'app', value: { stringValue: 'api' } },
52
+ ],
53
+ timeUnixNano: '1000000000000000000',
54
+ asDouble: 512.0,
55
+ },
56
+ ],
57
+ },
58
+ },
59
+ ]);
60
+
61
+ const series = otlpToTimeSeries(payload);
62
+ const names = series[0]!.labels.map(l => l.name);
63
+ expect(names).toEqual([...names].sort());
64
+ });
65
+
66
+ it('dp attributes win over resource attributes on conflict', () => {
67
+ const payload: OtlpMetricsPayload = {
68
+ resourceMetrics: [
69
+ {
70
+ resource: {
71
+ attributes: [{ key: 'env', value: { stringValue: 'resource-env' } }],
72
+ },
73
+ scopeMetrics: [
74
+ {
75
+ metrics: [
76
+ {
77
+ name: 'test',
78
+ gauge: {
79
+ dataPoints: [
80
+ {
81
+ attributes: [{ key: 'env', value: { stringValue: 'dp-env' } }],
82
+ timeUnixNano: '1000000000000000000',
83
+ asDouble: 1.0,
84
+ },
85
+ ],
86
+ },
87
+ },
88
+ ],
89
+ },
90
+ ],
91
+ },
92
+ ],
93
+ };
94
+
95
+ const series = otlpToTimeSeries(payload);
96
+ expect(series[0]!.labels.find(l => l.name === 'env')?.value).toBe('dp-env');
97
+ });
98
+ });
99
+
100
+ describe('sum', () => {
101
+ it('converts a sum to a single TimeSeries', () => {
102
+ const payload = basePayload([
103
+ {
104
+ name: 'requests_total',
105
+ sum: {
106
+ dataPoints: [
107
+ {
108
+ timeUnixNano: '1700000000000000000',
109
+ asInt: '100',
110
+ },
111
+ ],
112
+ isMonotonic: true,
113
+ },
114
+ },
115
+ ]);
116
+
117
+ const series = otlpToTimeSeries(payload);
118
+ expect(series).toHaveLength(1);
119
+ expect(series[0]!.labels.find(l => l.name === '__name__')?.value).toBe('requests_total');
120
+ expect(series[0]!.samples[0]?.value).toBe(100);
121
+ });
122
+ });
123
+
124
+ describe('histogram', () => {
125
+ it('converts a histogram to bucket + count + sum series', () => {
126
+ const payload = basePayload([
127
+ {
128
+ name: 'latency',
129
+ histogram: {
130
+ dataPoints: [
131
+ {
132
+ timeUnixNano: '1700000000000000000',
133
+ count: '10',
134
+ sum: 500.0,
135
+ explicitBounds: [10, 50, 100],
136
+ bucketCounts: ['2', '3', '4', '1'],
137
+ },
138
+ ],
139
+ },
140
+ },
141
+ ]);
142
+
143
+ const series = otlpToTimeSeries(payload);
144
+ // 4 buckets (3 bounds + +Inf) + _count + _sum = 6
145
+ expect(series).toHaveLength(6);
146
+
147
+ const buckets = series.filter(ts =>
148
+ ts.labels.find(l => l.name === '__name__')?.value === 'latency_bucket'
149
+ );
150
+ expect(buckets).toHaveLength(4);
151
+
152
+ // Check le labels
153
+ const leValues = buckets.map(ts => ts.labels.find(l => l.name === 'le')?.value);
154
+ expect(leValues).toContain('10');
155
+ expect(leValues).toContain('50');
156
+ expect(leValues).toContain('100');
157
+ expect(leValues).toContain('+Inf');
158
+
159
+ // Check cumulative counts
160
+ const sortedBuckets = [...buckets].sort((a, b) => {
161
+ const aLe = a.labels.find(l => l.name === 'le')?.value ?? '';
162
+ const bLe = b.labels.find(l => l.name === 'le')?.value ?? '';
163
+ if (aLe === '+Inf') return 1;
164
+ if (bLe === '+Inf') return -1;
165
+ return Number(aLe) - Number(bLe);
166
+ });
167
+ expect(sortedBuckets[0]!.samples[0]?.value).toBe(2); // le=10: 2
168
+ expect(sortedBuckets[1]!.samples[0]?.value).toBe(5); // le=50: 2+3=5
169
+ expect(sortedBuckets[2]!.samples[0]?.value).toBe(9); // le=100: 2+3+4=9
170
+ expect(sortedBuckets[3]!.samples[0]?.value).toBe(10); // le=+Inf: 2+3+4+1=10
171
+
172
+ // Check _count and _sum
173
+ const countSeries = series.find(ts =>
174
+ ts.labels.find(l => l.name === '__name__')?.value === 'latency_count'
175
+ );
176
+ expect(countSeries?.samples[0]?.value).toBe(10);
177
+
178
+ const sumSeries = series.find(ts =>
179
+ ts.labels.find(l => l.name === '__name__')?.value === 'latency_sum'
180
+ );
181
+ expect(sumSeries?.samples[0]?.value).toBe(500.0);
182
+ });
183
+ });
184
+ });