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 +453 -0
- package/dist/index.js +25966 -0
- package/index.ts +27 -0
- package/package.json +59 -0
- package/src/adapters/bun.ts +62 -0
- package/src/adapters/elysia.ts +35 -0
- package/src/compress/snappy.ts +9 -0
- package/src/core/Compat.ts +92 -0
- package/src/core/OtelTurbine.ts +121 -0
- package/src/core/Pipeline.ts +169 -0
- package/src/core/RemoteWriteConfig.ts +8 -0
- package/src/proto/writeRequest.ts +133 -0
- package/src/tests/bunHandler.test.ts +134 -0
- package/src/tests/compat.test.ts +120 -0
- package/src/tests/otlpToTimeSeries.test.ts +184 -0
- package/src/tests/pipeline.test.ts +140 -0
- package/src/tests/proto.test.ts +102 -0
- package/src/tests/schemaEngine.test.ts +222 -0
- package/src/tests/varint.test.ts +53 -0
- package/src/transform/SchemaEngine.ts +174 -0
- package/src/transform/otlpToTimeSeries.ts +156 -0
- package/src/types/otlp.ts +83 -0
- package/src/types/prometheus.ts +23 -0
- package/src/types/schema.ts +36 -0
- package/src/util/varint.ts +80 -0
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
|
+
[](https://www.npmjs.com/package/otelturbine)
|
|
10
|
+
[](https://github.com/your-org/otelturbine/actions/workflows/ci.yml)
|
|
11
|
+
[](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
|