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.
package/README.md ADDED
@@ -0,0 +1,453 @@
1
+ # otelturbine
2
+
3
+ A stateless **OTLP/HTTP → Prometheus remote-write** pipeline library for [Bun](https://bun.sh). Receive metrics from any OpenTelemetry SDK, apply filtering and label transformation rules, and forward to any Prometheus-compatible storage.
4
+
5
+ ```
6
+ OTEL SDK → POST /v1/metrics → [parse] → [schema rules] → [proto+snappy] → remote-write
7
+ ```
8
+
9
+ [![npm](https://img.shields.io/npm/v/otelturbine)](https://www.npmjs.com/package/otelturbine)
10
+ [![CI](https://github.com/your-org/otelturbine/actions/workflows/ci.yml/badge.svg)](https://github.com/your-org/otelturbine/actions/workflows/ci.yml)
11
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
12
+
13
+ ---
14
+
15
+ ## Table of Contents
16
+
17
+ - [Features](#features)
18
+ - [Install](#install)
19
+ - [Quick Start](#quick-start)
20
+ - [Builder API](#builder-api)
21
+ - [`.remoteWrite(url, options?)`](#remotewriteurl-options)
22
+ - [`.defaultAction(action)`](#defaultactionaction)
23
+ - [`.schema(schemas[])`](#schemashemas)
24
+ - [`.build()`](#build)
25
+ - [MetricSchema Reference](#metricschema-reference)
26
+ - [Name matching](#name-matching)
27
+ - [Label filtering](#label-filtering)
28
+ - [Wildcard catch-all](#wildcard-catch-all)
29
+ - [Injecting labels](#injecting-labels)
30
+ - [Capping label count](#capping-label-count)
31
+ - [Adapters](#adapters)
32
+ - [Framework-agnostic compat handler](#framework-agnostic-compat-handler)
33
+ - [Bun.serve — route handler](#bunserve--route-handler)
34
+ - [Bun.serve — fetch handler](#bunserve--fetch-handler)
35
+ - [ElysiaJS plugin](#elysiajs-plugin)
36
+ - [Advanced Usage](#advanced-usage)
37
+ - [Accessing the raw pipeline](#accessing-the-raw-pipeline)
38
+ - [Using pipeline utilities directly](#using-pipeline-utilities-directly)
39
+ - [TypeScript Notes](#typescript-notes)
40
+ - [Development](#development)
41
+
42
+ ---
43
+
44
+ ## Features
45
+
46
+ - **Plug-and-play** with Bun's native HTTP server and ElysiaJS
47
+ - **Framework-agnostic compat API** for custom route logic and request-scoped label injection
48
+ - **Fluent builder** — configure once, serve forever
49
+ - **Schema engine** — filter, relabel, inject, and cap labels per metric family
50
+ - **Hand-written protobuf encoder** — zero dependencies for wire encoding
51
+ - **Native snappy compression** via NAPI-RS bindings
52
+ - **Stateless** — safe to use behind a load balancer, no internal state between requests
53
+
54
+ ---
55
+
56
+ ## Install
57
+
58
+ ```sh
59
+ bun add otelturbine
60
+ ```
61
+
62
+ `snappy` (a native addon) is a regular dependency and is installed automatically. Bun handles native addons out of the box.
63
+
64
+ For the optional [ElysiaJS](https://elysiajs.com) adapter:
65
+
66
+ ```sh
67
+ bun add elysia # peer dep, only needed if you use .elysiaPlugin()
68
+ ```
69
+
70
+ ---
71
+
72
+ ## Quick Start
73
+
74
+ ```ts
75
+ import { OtelTurbine } from 'otelturbine';
76
+
77
+ const turbine = new OtelTurbine()
78
+ .remoteWrite('http://prometheus:9090/api/v1/write')
79
+ .defaultAction('drop') // drop anything that doesn't match a schema
80
+ .schema([
81
+ {
82
+ name: /^http_/, // match all metrics starting with http_
83
+ labels: {
84
+ method: /^(GET|POST|PUT|DELETE)$/,
85
+ status: /.*/,
86
+ '*': /.*/, // keep all other labels too
87
+ },
88
+ inject: { env: 'prod' }, // always stamp env=prod
89
+ maxLabels: 12,
90
+ },
91
+ {
92
+ name: 'process_cpu_seconds_total',
93
+ labels: { '*': /.*/ }, // keep all labels, no filtering
94
+ },
95
+ ])
96
+ .build();
97
+
98
+ Bun.serve({
99
+ port: 4318,
100
+ routes: {
101
+ '/v1/metrics': { POST: turbine.bunRouteHandler() },
102
+ },
103
+ });
104
+ ```
105
+
106
+ Point your OpenTelemetry SDK at `http://localhost:4318/v1/metrics` and metrics will flow through to Prometheus.
107
+
108
+ ---
109
+
110
+ ## Builder API
111
+
112
+ ### `.remoteWrite(url, options?)`
113
+
114
+ Configure the downstream Prometheus remote-write endpoint.
115
+
116
+ ```ts
117
+ turbine.remoteWrite('http://victoria-metrics:8428/api/v1/write', {
118
+ timeout: 5_000, // ms, default 10 000
119
+ headers: { 'X-Scope-OrgID': 'tenant1' }, // forwarded as-is
120
+ })
121
+ ```
122
+
123
+ | Option | Type | Default | Description |
124
+ |--------|------|---------|-------------|
125
+ | `timeout` | `number` | `10000` | Request timeout in milliseconds |
126
+ | `headers` | `Record<string, string>` | — | Extra headers sent with every remote-write request |
127
+
128
+ **Must be called before `.build()`.**
129
+
130
+ ---
131
+
132
+ ### `.defaultAction(action)`
133
+
134
+ What to do with metrics that match **no schema**.
135
+
136
+ ```ts
137
+ .defaultAction('pass') // forward unchanged (default)
138
+ .defaultAction('drop') // silently discard
139
+ ```
140
+
141
+ ---
142
+
143
+ ### `.schema(schemas[])`
144
+
145
+ Register one or more `MetricSchema` entries. Schemas are evaluated in order — **first match wins**. Multiple calls to `.schema()` append to the list.
146
+
147
+ ```ts
148
+ .schema([
149
+ { name: 'up', labels: { job: /.*/, instance: /.*/ } },
150
+ { name: /^node_/, labels: { '*': /.*/ } },
151
+ ])
152
+ .schema([
153
+ { name: /^custom_/, inject: { source: 'myapp' } },
154
+ ])
155
+ ```
156
+
157
+ ---
158
+
159
+ ### `.build()`
160
+
161
+ Validates the configuration and returns a `BuiltOtelTurbine` instance exposing the adapter methods. Throws if `.remoteWrite()` was never called.
162
+
163
+ ---
164
+
165
+ ## MetricSchema Reference
166
+
167
+ ```ts
168
+ interface MetricSchema {
169
+ name: string | RegExp;
170
+ labels?: { [key: string]: RegExp | string; '*'?: RegExp | string };
171
+ inject?: Record<string, string>;
172
+ maxLabels?: number;
173
+ }
174
+ ```
175
+
176
+ ### Name matching
177
+
178
+ | Value | Behaviour |
179
+ |-------|-----------|
180
+ | `'exact_name'` | Matches only that metric name |
181
+ | `/^http_/` | Matches any name satisfying the RegExp |
182
+
183
+ ### Label filtering
184
+
185
+ For each incoming series that matches `name`, labels are processed in a single pass:
186
+
187
+ 1. **Explicit keys** — the label must exist and its value must match the pattern. If the label is missing or the value doesn't match, **the entire series is dropped**.
188
+ 2. **Unlisted keys** — handled by the wildcard rule (see below), or removed if no wildcard is set.
189
+
190
+ ```ts
191
+ {
192
+ name: 'http_requests_total',
193
+ labels: {
194
+ method: /^(GET|POST)$/, // keep; drop series if value is anything else
195
+ status: /^[245]\d\d$/, // keep 2xx, 4xx, 5xx only
196
+ // 'path' and anything else → removed (no wildcard set)
197
+ },
198
+ }
199
+ ```
200
+
201
+ ### Wildcard catch-all
202
+
203
+ Add `'*'` to keep unlisted labels whose **value** matches the pattern. Without it, all unlisted labels are silently removed.
204
+
205
+ ```ts
206
+ labels: {
207
+ job: /.*/,
208
+ '*': /.*/, // keep every other label regardless of value
209
+ }
210
+ ```
211
+
212
+ ```ts
213
+ labels: {
214
+ '*': /^(prod|staging)$/, // keep unlisted labels only if value is "prod" or "staging"
215
+ }
216
+ ```
217
+
218
+ ### Injecting labels
219
+
220
+ `inject` labels are always added or overwritten **after** filtering. They never cause a series to be dropped.
221
+
222
+ ```ts
223
+ {
224
+ name: /.*/,
225
+ inject: {
226
+ cluster: 'eu-west-k8s',
227
+ env: 'production',
228
+ },
229
+ }
230
+ ```
231
+
232
+ ### Capping label count
233
+
234
+ `maxLabels` sets a hard cap on the number of labels **excluding `__name__`**. Labels are trimmed alphabetically after inject. `__name__` is always preserved.
235
+
236
+ ```ts
237
+ {
238
+ name: /^trace_/,
239
+ labels: { '*': /.*/ },
240
+ maxLabels: 8,
241
+ }
242
+ ```
243
+
244
+ ---
245
+
246
+ ## Adapters
247
+
248
+ ### Framework-agnostic compat handler
249
+
250
+ Use this when your routing logic is custom and you want per-request label injection without mutating global builder state.
251
+
252
+ ```ts
253
+ const turbine = new OtelTurbine()
254
+ .remoteWrite('http://prometheus:9090/api/v1/write')
255
+ .build();
256
+
257
+ const otelTurbine = turbine.compat();
258
+
259
+ app.post('/v1/metrics/:name', async (req, { name }) => {
260
+ if (!valid(name)) return new Response('bad name', { status: 400 });
261
+ const result = await otelTurbine(req)
262
+ .injectLabel('*', { instance_name: name })
263
+ .push();
264
+ return new Response(result.message, { status: result.status === 200 ? 204 : result.status });
265
+ });
266
+ ```
267
+
268
+ Accepted request shapes:
269
+ - Native `Request`
270
+ - Objects with `method`, `headers`, and `body`
271
+ - Objects exposing `text()` for lazy body reads
272
+
273
+ ### Bun.serve — route handler
274
+
275
+ The cleanest integration when you control the route table. Bun handles method dispatch — only `POST` calls reach this handler.
276
+
277
+ ```ts
278
+ Bun.serve({
279
+ port: 4318,
280
+ routes: {
281
+ '/v1/metrics': {
282
+ POST: turbine.bunRouteHandler(),
283
+ },
284
+ },
285
+ });
286
+ ```
287
+
288
+ ### Bun.serve — fetch handler
289
+
290
+ Use this when you need full control over the `fetch` function, or you're already using a catch-all handler. Returns `404` for unmatched paths and `405` for non-POST methods.
291
+
292
+ ```ts
293
+ Bun.serve({
294
+ port: 4318,
295
+ fetch: turbine.bunHandler('/v1/metrics'), // default path is '/v1/metrics'
296
+ });
297
+ ```
298
+
299
+ ### ElysiaJS plugin
300
+
301
+ Requires `elysia` to be installed as a peer dependency (`bun add elysia`). The plugin registers a POST route and integrates naturally with Elysia's lifecycle.
302
+
303
+ ```ts
304
+ import { Elysia } from 'elysia';
305
+
306
+ new Elysia()
307
+ .use(turbine.elysiaPlugin({ path: '/v1/metrics' })) // default path is '/v1/metrics'
308
+ .listen(4318);
309
+ ```
310
+
311
+ Compose it freely alongside other plugins:
312
+
313
+ ```ts
314
+ new Elysia()
315
+ .use(cors())
316
+ .use(swagger())
317
+ .use(turbine.elysiaPlugin())
318
+ .listen(4318);
319
+ ```
320
+
321
+ ---
322
+
323
+ ## Advanced Usage
324
+
325
+ ### Accessing the raw pipeline
326
+
327
+ `BuiltOtelTurbine.rawPipeline` exposes the underlying `Pipeline` instance, useful for testing, custom routing, or wrapping in middleware.
328
+
329
+ ```ts
330
+ const turbine = new OtelTurbine()
331
+ .remoteWrite('http://prometheus:9090/api/v1/write')
332
+ .build();
333
+
334
+ const result = await turbine.rawPipeline.process(
335
+ JSON.stringify(otlpPayload),
336
+ 'application/json',
337
+ );
338
+ // result: { status: number, message: string }
339
+ ```
340
+
341
+ `Pipeline.process` status codes:
342
+
343
+ | Status | Meaning |
344
+ |--------|---------|
345
+ | `200` | Forwarded successfully |
346
+ | `204` | No metrics after filtering (nothing to send) |
347
+ | `400` | Malformed JSON or invalid OTLP shape |
348
+ | `415` | Unsupported content type (protobuf OTLP not supported) |
349
+ | `502` | Remote-write endpoint returned an error or timed out |
350
+
351
+ ### Using pipeline utilities directly
352
+
353
+ All internal utilities are exported for advanced use cases:
354
+
355
+ ```ts
356
+ import {
357
+ otlpToTimeSeries, // OtlpMetricsPayload → TimeSeries[]
358
+ compileSchemas, // MetricSchema[] → CompiledSchema[]
359
+ applySchemas, // TimeSeries[] + CompiledSchema[] → TimeSeries[]
360
+ encodeWriteRequest, // WriteRequest → Uint8Array (protobuf)
361
+ snappyCompress,
362
+ snappyUncompress,
363
+ } from 'otelturbine';
364
+ ```
365
+
366
+ Building a fully custom pipeline:
367
+
368
+ ```ts
369
+ import {
370
+ otlpToTimeSeries, compileSchemas, applySchemas,
371
+ encodeWriteRequest, snappyCompress,
372
+ } from 'otelturbine';
373
+
374
+ const schemas = compileSchemas([{ name: /.*/, labels: { '*': /.*/ } }]);
375
+
376
+ const payload = JSON.parse(await req.text());
377
+ const series = applySchemas(otlpToTimeSeries(payload), schemas, 'pass');
378
+ const proto = encodeWriteRequest({ timeseries: series });
379
+ const body = snappyCompress(proto);
380
+
381
+ await fetch('http://my-remote-write/api/v1/write', {
382
+ method: 'POST',
383
+ headers: {
384
+ 'Content-Type': 'application/x-protobuf',
385
+ 'Content-Encoding': 'snappy',
386
+ 'X-Prometheus-Remote-Write-Version': '0.1.0',
387
+ },
388
+ body,
389
+ });
390
+ ```
391
+
392
+ ---
393
+
394
+ ## TypeScript Notes
395
+
396
+ otelturbine ships its TypeScript sources directly. When consumed from Bun, types resolve from `index.ts` with no extra configuration needed.
397
+
398
+ If you use `tsc` directly and see errors about `.ts` extension imports, add the following to your `tsconfig.json`:
399
+
400
+ ```json
401
+ {
402
+ "compilerOptions": {
403
+ "moduleResolution": "bundler",
404
+ "allowImportingTsExtensions": true
405
+ }
406
+ }
407
+ ```
408
+
409
+ ---
410
+
411
+ ## Development
412
+
413
+ ```sh
414
+ bun install # install dependencies
415
+ bun test # run test suite (53 tests)
416
+ bun run bench.ts # run benchmarks
417
+ bun run typecheck # type-check without emitting
418
+ bun run build # build dist/index.js for publishing
419
+ ```
420
+
421
+ ### Project structure
422
+
423
+ ```
424
+ src/
425
+ ├── adapters/
426
+ │ ├── bun.ts bunHandler + bunRouteHandler
427
+ │ └── elysia.ts createElysiaPlugin (optional peer dep)
428
+ ├── compress/
429
+ │ └── snappy.ts thin wrapper over native snappy
430
+ ├── core/
431
+ │ ├── OtelTurbine.ts fluent builder → BuiltOtelTurbine
432
+ │ ├── Pipeline.ts stateless process() → PipelineResult
433
+ │ └── RemoteWriteConfig.ts
434
+ ├── proto/
435
+ │ └── writeRequest.ts hand-written two-pass protobuf encoder
436
+ ├── transform/
437
+ │ ├── otlpToTimeSeries.ts OTLP JSON → TimeSeries[]
438
+ │ └── SchemaEngine.ts compileSchemas + applySchemas
439
+ ├── types/
440
+ │ ├── otlp.ts OTLP payload interfaces
441
+ │ ├── prometheus.ts internal TimeSeries / WriteRequest
442
+ │ └── schema.ts MetricSchema + CompiledSchema + FastMatcher
443
+ └── util/
444
+ └── varint.ts LEB128 varint encoding
445
+ index.ts public barrel export
446
+ bench.ts mitata benchmarks
447
+ ```
448
+
449
+ ---
450
+
451
+ ## License
452
+
453
+ MIT