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/index.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// otelturbine — OTLP → Prometheus Remote-Write Pipeline Library
|
|
2
|
+
|
|
3
|
+
// Core builder
|
|
4
|
+
export { OtelTurbine, BuiltOtelTurbine } from './src/core/OtelTurbine.ts';
|
|
5
|
+
|
|
6
|
+
// Pipeline (for advanced/testing use)
|
|
7
|
+
export { Pipeline } from './src/core/Pipeline.ts';
|
|
8
|
+
export { applyRequestLabelInjections } from './src/core/Pipeline.ts';
|
|
9
|
+
export type { PipelineResult, LabelInjectionRule, ProcessOptions } from './src/core/Pipeline.ts';
|
|
10
|
+
export { createCompatHandler, CompatRequestSession } from './src/core/Compat.ts';
|
|
11
|
+
export type { CompatHandler, CompatRequestLike, CompatHeaders } from './src/core/Compat.ts';
|
|
12
|
+
|
|
13
|
+
// Types
|
|
14
|
+
export type { MetricSchema, LabelPattern, DefaultAction, CompiledSchema } from './src/types/schema.ts';
|
|
15
|
+
export type { TimeSeries, Label, Sample, WriteRequest } from './src/types/prometheus.ts';
|
|
16
|
+
export type { OtlpMetricsPayload, OtlpResourceMetrics, OtlpMetric } from './src/types/otlp.ts';
|
|
17
|
+
export type { RemoteWriteConfig } from './src/core/RemoteWriteConfig.ts';
|
|
18
|
+
|
|
19
|
+
// Transform utilities (for advanced use)
|
|
20
|
+
export { otlpToTimeSeries } from './src/transform/otlpToTimeSeries.ts';
|
|
21
|
+
export { compileSchemas, applySchemas } from './src/transform/SchemaEngine.ts';
|
|
22
|
+
|
|
23
|
+
// Proto encoding (for advanced use)
|
|
24
|
+
export { encodeWriteRequest } from './src/proto/writeRequest.ts';
|
|
25
|
+
|
|
26
|
+
// Compression (for advanced use)
|
|
27
|
+
export { snappyCompress, snappyUncompress } from './src/compress/snappy.ts';
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "otelturbine",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OTLP/HTTP → Prometheus remote-write pipeline library for Bun",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"module": "index.ts",
|
|
7
|
+
"types": "index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"bun": "./index.ts",
|
|
11
|
+
"types": "./index.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"src",
|
|
19
|
+
"index.ts",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "bun build ./index.ts --outdir ./dist --target node --format esm --external snappy",
|
|
24
|
+
"test": "bun test",
|
|
25
|
+
"bench": "bun run bench.ts",
|
|
26
|
+
"typecheck": "tsc --noEmit",
|
|
27
|
+
"prepublishOnly": "bun test && bun run build"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"otlp",
|
|
31
|
+
"opentelemetry",
|
|
32
|
+
"prometheus",
|
|
33
|
+
"remote-write",
|
|
34
|
+
"metrics",
|
|
35
|
+
"bun",
|
|
36
|
+
"elysia"
|
|
37
|
+
],
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"engines": {
|
|
40
|
+
"bun": ">=1.0.0"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"typescript": "^5",
|
|
44
|
+
"elysia": ">=1.0.0"
|
|
45
|
+
},
|
|
46
|
+
"peerDependenciesMeta": {
|
|
47
|
+
"elysia": {
|
|
48
|
+
"optional": true
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@types/bun": "^1.3.9",
|
|
53
|
+
"elysia": "^1.4.26",
|
|
54
|
+
"mitata": "^1.0.34"
|
|
55
|
+
},
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"snappy": "^7.3.3"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bun-native adapter helpers for the Analyta pipeline.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Pipeline } from '../core/Pipeline.ts';
|
|
6
|
+
|
|
7
|
+
/** Map pipeline status codes to appropriate HTTP responses. */
|
|
8
|
+
function pipelineResultToResponse(status: number, message: string): Response {
|
|
9
|
+
switch (status) {
|
|
10
|
+
case 200:
|
|
11
|
+
return new Response(null, { status: 204 }); // Success → 204 No Content to caller
|
|
12
|
+
case 204:
|
|
13
|
+
return new Response(null, { status: 204 }); // Empty after filtering → 204
|
|
14
|
+
case 400:
|
|
15
|
+
return new Response(message, { status: 400 });
|
|
16
|
+
case 415:
|
|
17
|
+
return new Response(message, { status: 415 });
|
|
18
|
+
case 502:
|
|
19
|
+
return new Response(message, { status: 502 });
|
|
20
|
+
default:
|
|
21
|
+
return new Response(message, { status });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Returns a handler for use as a Bun.serve route POST handler.
|
|
27
|
+
* Assumes the route only handles POST (method checking done by Bun.serve routing).
|
|
28
|
+
*/
|
|
29
|
+
export function bunRouteHandler(pipeline: Pipeline): (req: Request) => Promise<Response> {
|
|
30
|
+
return async (req: Request): Promise<Response> => {
|
|
31
|
+
const contentType = req.headers.get('content-type') ?? 'application/json';
|
|
32
|
+
const body = await req.text();
|
|
33
|
+
const result = await pipeline.process(body, contentType);
|
|
34
|
+
return pipelineResultToResponse(result.status, result.message);
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Returns a Bun fetch handler that handles POST to the specified path.
|
|
40
|
+
* Returns 405 for wrong method, 404 for unmatched paths.
|
|
41
|
+
*/
|
|
42
|
+
export function bunHandler(
|
|
43
|
+
pipeline: Pipeline,
|
|
44
|
+
path: string
|
|
45
|
+
): (req: Request) => Promise<Response> {
|
|
46
|
+
return async (req: Request): Promise<Response> => {
|
|
47
|
+
const url = new URL(req.url);
|
|
48
|
+
if (url.pathname !== path) {
|
|
49
|
+
return new Response('Not Found', { status: 404 });
|
|
50
|
+
}
|
|
51
|
+
if (req.method !== 'POST') {
|
|
52
|
+
return new Response('Method Not Allowed', {
|
|
53
|
+
status: 405,
|
|
54
|
+
headers: { Allow: 'POST' },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
const contentType = req.headers.get('content-type') ?? 'application/json';
|
|
58
|
+
const body = await req.text();
|
|
59
|
+
const result = await pipeline.process(body, contentType);
|
|
60
|
+
return pipelineResultToResponse(result.status, result.message);
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ElysiaJS adapter for Analyta.
|
|
3
|
+
* Uses import type only — Elysia is an optional peer dependency.
|
|
4
|
+
* At runtime, dynamically imports Elysia only if the user has it installed.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Elysia as ElysiaType } from 'elysia';
|
|
8
|
+
import type { Pipeline } from '../core/Pipeline.ts';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Creates an ElysiaJS plugin that registers a POST handler for OTLP metrics.
|
|
12
|
+
*
|
|
13
|
+
* @param pipeline - The compiled Analyta pipeline
|
|
14
|
+
* @param path - The route path (default: '/v1/metrics')
|
|
15
|
+
* @returns An Elysia plugin instance (typed as unknown to avoid hard dep)
|
|
16
|
+
*/
|
|
17
|
+
export function createElysiaPlugin(pipeline: Pipeline, path: string): unknown {
|
|
18
|
+
// Dynamic import to avoid hard runtime dependency
|
|
19
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
20
|
+
const { Elysia } = require('elysia') as { Elysia: typeof ElysiaType };
|
|
21
|
+
|
|
22
|
+
return new Elysia({ name: 'otelturbine' }).post(path, async ({ request }) => {
|
|
23
|
+
const contentType = request.headers.get('content-type') ?? 'application/json';
|
|
24
|
+
const body = await request.text();
|
|
25
|
+
const result = await pipeline.process(body, contentType);
|
|
26
|
+
|
|
27
|
+
switch (result.status) {
|
|
28
|
+
case 200:
|
|
29
|
+
case 204:
|
|
30
|
+
return new Response(null, { status: 204 });
|
|
31
|
+
default:
|
|
32
|
+
return new Response(result.message, { status: result.status });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { compressSync, uncompressSync } from 'snappy';
|
|
2
|
+
|
|
3
|
+
export function snappyCompress(data: Uint8Array): Uint8Array {
|
|
4
|
+
return compressSync(data);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function snappyUncompress(data: Uint8Array): Uint8Array {
|
|
8
|
+
return uncompressSync(data, { asBuffer: true }) as Buffer;
|
|
9
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { Pipeline, PipelineResult, LabelInjectionRule } from './Pipeline.ts';
|
|
2
|
+
|
|
3
|
+
export type CompatHeaders =
|
|
4
|
+
| Headers
|
|
5
|
+
| { get?: (name: string) => string | null | undefined }
|
|
6
|
+
| Record<string, string | string[] | undefined>;
|
|
7
|
+
|
|
8
|
+
export interface CompatRequestLike {
|
|
9
|
+
method?: string;
|
|
10
|
+
headers?: CompatHeaders;
|
|
11
|
+
contentType?: string;
|
|
12
|
+
body?: unknown;
|
|
13
|
+
text?: () => Promise<string> | string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class CompatRequestSession {
|
|
17
|
+
private readonly injectRules: LabelInjectionRule[] = [];
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
private readonly pipeline: Pipeline,
|
|
21
|
+
private readonly req: Request | CompatRequestLike
|
|
22
|
+
) {}
|
|
23
|
+
|
|
24
|
+
injectLabel(selector: LabelInjectionRule['selector'], labels: Record<string, string>): this {
|
|
25
|
+
this.injectRules.push({ selector, labels: { ...labels } });
|
|
26
|
+
return this;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async push(): Promise<PipelineResult> {
|
|
30
|
+
const normalized = await normalizeRequest(this.req);
|
|
31
|
+
if (normalized.method && normalized.method !== 'POST') {
|
|
32
|
+
return { status: 405, message: 'Method Not Allowed' };
|
|
33
|
+
}
|
|
34
|
+
return this.pipeline.process(normalized.body, normalized.contentType, {
|
|
35
|
+
injectLabels: this.injectRules,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type CompatHandler = (req: Request | CompatRequestLike) => CompatRequestSession;
|
|
41
|
+
|
|
42
|
+
export function createCompatHandler(pipeline: Pipeline): CompatHandler {
|
|
43
|
+
return (req: Request | CompatRequestLike) => new CompatRequestSession(pipeline, req);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface NormalizedRequest {
|
|
47
|
+
method: string | undefined;
|
|
48
|
+
contentType: string;
|
|
49
|
+
body: string | Uint8Array;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function normalizeRequest(req: Request | CompatRequestLike): Promise<NormalizedRequest> {
|
|
53
|
+
if (req instanceof Request) {
|
|
54
|
+
return {
|
|
55
|
+
method: req.method,
|
|
56
|
+
contentType: req.headers.get('content-type') ?? 'application/json',
|
|
57
|
+
body: await req.text(),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const method = typeof req.method === 'string' ? req.method.toUpperCase() : undefined;
|
|
62
|
+
const contentType = req.contentType ?? getHeader(req.headers, 'content-type') ?? 'application/json';
|
|
63
|
+
|
|
64
|
+
if (typeof req.text === 'function') {
|
|
65
|
+
const text = await req.text();
|
|
66
|
+
return { method, contentType, body: text };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const body = req.body;
|
|
70
|
+
if (typeof body === 'string') return { method, contentType, body };
|
|
71
|
+
if (body instanceof Uint8Array) return { method, contentType, body };
|
|
72
|
+
if (body instanceof ArrayBuffer) return { method, contentType, body: new Uint8Array(body) };
|
|
73
|
+
if (body === undefined || body === null) return { method, contentType, body: '' };
|
|
74
|
+
if (typeof body === 'object') return { method, contentType, body: JSON.stringify(body) };
|
|
75
|
+
return { method, contentType, body: String(body) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getHeader(headers: CompatHeaders | undefined, name: string): string | undefined {
|
|
79
|
+
if (!headers) return undefined;
|
|
80
|
+
if (typeof (headers as Headers).get === 'function') {
|
|
81
|
+
const value = (headers as Headers).get(name);
|
|
82
|
+
return value ?? undefined;
|
|
83
|
+
}
|
|
84
|
+
const record = headers as Record<string, string | string[] | undefined>;
|
|
85
|
+
const direct = record[name];
|
|
86
|
+
if (typeof direct === 'string') return direct;
|
|
87
|
+
if (Array.isArray(direct) && direct.length > 0) return direct[0];
|
|
88
|
+
const lowered = record[name.toLowerCase()];
|
|
89
|
+
if (typeof lowered === 'string') return lowered;
|
|
90
|
+
if (Array.isArray(lowered) && lowered.length > 0) return lowered[0];
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// OtelTurbine fluent builder and built instance.
|
|
2
|
+
//
|
|
3
|
+
// Usage:
|
|
4
|
+
// const turbine = new OtelTurbine()
|
|
5
|
+
// .remoteWrite('http://prometheus:9090/api/v1/write')
|
|
6
|
+
// .defaultAction('drop')
|
|
7
|
+
// .schema([{ name: /^http_/, labels: { method: /^GET$/ } }])
|
|
8
|
+
// .build();
|
|
9
|
+
//
|
|
10
|
+
// Bun.serve({ routes: { '/v1/metrics': { POST: turbine.bunRouteHandler() } } });
|
|
11
|
+
// Bun.serve({ fetch: turbine.bunHandler() });
|
|
12
|
+
// new Elysia().use(turbine.elysiaPlugin({ path: '/v1/metrics' }));
|
|
13
|
+
|
|
14
|
+
import type { MetricSchema, DefaultAction } from '../types/schema.ts';
|
|
15
|
+
import type { RemoteWriteConfig } from './RemoteWriteConfig.ts';
|
|
16
|
+
import { compileSchemas } from '../transform/SchemaEngine.ts';
|
|
17
|
+
import { Pipeline } from './Pipeline.ts';
|
|
18
|
+
import { bunRouteHandler, bunHandler } from '../adapters/bun.ts';
|
|
19
|
+
import { createElysiaPlugin } from '../adapters/elysia.ts';
|
|
20
|
+
import { createCompatHandler } from './Compat.ts';
|
|
21
|
+
import type { CompatHandler } from './Compat.ts';
|
|
22
|
+
|
|
23
|
+
/** OtelTurbine fluent builder. */
|
|
24
|
+
export class OtelTurbine {
|
|
25
|
+
private _remoteWrite?: RemoteWriteConfig;
|
|
26
|
+
private _defaultAction: DefaultAction = 'pass';
|
|
27
|
+
private _schemas: MetricSchema[] = [];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Configure the Prometheus remote-write endpoint.
|
|
31
|
+
* @param url Remote-write URL
|
|
32
|
+
* @param options Optional timeout and extra headers
|
|
33
|
+
*/
|
|
34
|
+
remoteWrite(url: string, options?: { timeout?: number; headers?: Record<string, string> }): this {
|
|
35
|
+
this._remoteWrite = {
|
|
36
|
+
url,
|
|
37
|
+
timeout: options?.timeout ?? 10_000,
|
|
38
|
+
headers: options?.headers,
|
|
39
|
+
};
|
|
40
|
+
return this;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Set the default action for metrics that match no schema.
|
|
45
|
+
* - 'pass' (default): forward unchanged
|
|
46
|
+
* - 'drop': discard
|
|
47
|
+
*/
|
|
48
|
+
defaultAction(action: DefaultAction): this {
|
|
49
|
+
this._defaultAction = action;
|
|
50
|
+
return this;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Register metric schemas for filtering and transformation.
|
|
55
|
+
* Schemas are matched in order; first match wins.
|
|
56
|
+
*/
|
|
57
|
+
schema(schemas: MetricSchema[]): this {
|
|
58
|
+
this._schemas = [...this._schemas, ...schemas];
|
|
59
|
+
return this;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Build the configured pipeline. Throws if remoteWrite was not configured. */
|
|
63
|
+
build(): BuiltOtelTurbine {
|
|
64
|
+
if (!this._remoteWrite) {
|
|
65
|
+
throw new Error('OtelTurbine: remoteWrite() must be called before build()');
|
|
66
|
+
}
|
|
67
|
+
const compiled = compileSchemas(this._schemas);
|
|
68
|
+
const pipeline = new Pipeline(this._remoteWrite, compiled, this._defaultAction);
|
|
69
|
+
return new BuiltOtelTurbine(pipeline);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A configured, built OtelTurbine pipeline ready to handle requests. */
|
|
74
|
+
export class BuiltOtelTurbine {
|
|
75
|
+
constructor(private readonly pipeline: Pipeline) {}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Returns a Bun route handler for use as a route value in Bun.serve routes.
|
|
79
|
+
* Handles only POST requests; returns 405 for other methods.
|
|
80
|
+
*
|
|
81
|
+
* Usage: Bun.serve({ routes: { '/v1/metrics': { POST: turbine.bunRouteHandler() } } })
|
|
82
|
+
*/
|
|
83
|
+
bunRouteHandler(): (req: Request) => Promise<Response> {
|
|
84
|
+
return bunRouteHandler(this.pipeline);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Returns a Bun fetch handler for use as the `fetch` option in Bun.serve.
|
|
89
|
+
* Handles POST /v1/metrics; returns 405 for wrong method, 404 for other paths.
|
|
90
|
+
*
|
|
91
|
+
* Usage: Bun.serve({ fetch: turbine.bunHandler() })
|
|
92
|
+
*/
|
|
93
|
+
bunHandler(path = '/v1/metrics'): (req: Request) => Promise<Response> {
|
|
94
|
+
return bunHandler(this.pipeline, path);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Returns an ElysiaJS plugin that registers the OTLP metrics endpoint.
|
|
99
|
+
* Elysia is an optional peer dependency; typed as unknown to avoid hard dep.
|
|
100
|
+
*
|
|
101
|
+
* Usage: new Elysia().use(turbine.elysiaPlugin({ path: '/v1/metrics' }))
|
|
102
|
+
*/
|
|
103
|
+
elysiaPlugin(options?: { path?: string }): unknown {
|
|
104
|
+
return createElysiaPlugin(this.pipeline, options?.path ?? '/v1/metrics');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Returns a framework-agnostic request adapter.
|
|
109
|
+
* Usage:
|
|
110
|
+
* const otelTurbine = turbine.compat();
|
|
111
|
+
* await otelTurbine(req).injectLabel('*', { instance_name: 'a' }).push();
|
|
112
|
+
*/
|
|
113
|
+
compat(): CompatHandler {
|
|
114
|
+
return createCompatHandler(this.pipeline);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Direct access to the pipeline for advanced use cases or testing. */
|
|
118
|
+
get rawPipeline(): Pipeline {
|
|
119
|
+
return this.pipeline;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stateless pipeline: process(body, contentType) → PipelineResult
|
|
3
|
+
*
|
|
4
|
+
* Steps:
|
|
5
|
+
* 1. Validate content type (must be application/json or application/x-protobuf)
|
|
6
|
+
* 2. Parse OTLP JSON body
|
|
7
|
+
* 3. Convert to TimeSeries[]
|
|
8
|
+
* 4. Apply schemas
|
|
9
|
+
* 5. If empty result, return 204
|
|
10
|
+
* 6. Encode to protobuf
|
|
11
|
+
* 7. Compress with snappy
|
|
12
|
+
* 8. POST to remote-write endpoint
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { RemoteWriteConfig } from './RemoteWriteConfig.ts';
|
|
16
|
+
import type { CompiledSchema, DefaultAction } from '../types/schema.ts';
|
|
17
|
+
import type { OtlpMetricsPayload } from '../types/otlp.ts';
|
|
18
|
+
import { otlpToTimeSeries } from '../transform/otlpToTimeSeries.ts';
|
|
19
|
+
import { applySchemas } from '../transform/SchemaEngine.ts';
|
|
20
|
+
import { encodeWriteRequest } from '../proto/writeRequest.ts';
|
|
21
|
+
import { snappyCompress } from '../compress/snappy.ts';
|
|
22
|
+
import type { TimeSeries } from '../types/prometheus.ts';
|
|
23
|
+
|
|
24
|
+
export interface PipelineResult {
|
|
25
|
+
status: number;
|
|
26
|
+
message: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface LabelInjectionRule {
|
|
30
|
+
selector: '*' | string | RegExp;
|
|
31
|
+
labels: Record<string, string>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ProcessOptions {
|
|
35
|
+
injectLabels?: LabelInjectionRule[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function applyRequestLabelInjections(
|
|
39
|
+
series: TimeSeries[],
|
|
40
|
+
injectRules: LabelInjectionRule[]
|
|
41
|
+
): TimeSeries[] {
|
|
42
|
+
if (injectRules.length === 0) return series;
|
|
43
|
+
|
|
44
|
+
return series.map((ts) => {
|
|
45
|
+
const labelsMap = new Map(ts.labels.map((l) => [l.name, l.value]));
|
|
46
|
+
const metricName = labelsMap.get('__name__') ?? '';
|
|
47
|
+
|
|
48
|
+
for (const rule of injectRules) {
|
|
49
|
+
if (!selectorMatches(rule.selector, metricName)) continue;
|
|
50
|
+
for (const [key, value] of Object.entries(rule.labels)) {
|
|
51
|
+
labelsMap.set(key, value);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const labels = [...labelsMap.entries()]
|
|
56
|
+
.map(([name, value]) => ({ name, value }))
|
|
57
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
labels,
|
|
61
|
+
samples: ts.samples,
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function selectorMatches(selector: LabelInjectionRule['selector'], metricName: string): boolean {
|
|
67
|
+
if (selector === '*') return true;
|
|
68
|
+
if (typeof selector === 'string') return selector === metricName;
|
|
69
|
+
return selector.test(metricName);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class Pipeline {
|
|
73
|
+
constructor(
|
|
74
|
+
private readonly remoteWrite: RemoteWriteConfig,
|
|
75
|
+
private readonly schemas: CompiledSchema[],
|
|
76
|
+
private readonly defaultAction: DefaultAction
|
|
77
|
+
) {}
|
|
78
|
+
|
|
79
|
+
async process(
|
|
80
|
+
body: string | Uint8Array,
|
|
81
|
+
contentType: string,
|
|
82
|
+
options?: ProcessOptions
|
|
83
|
+
): Promise<PipelineResult> {
|
|
84
|
+
// Only accept JSON content types for OTLP
|
|
85
|
+
const ct = contentType.split(';')[0]!.trim().toLowerCase();
|
|
86
|
+
if (ct === 'application/x-protobuf') {
|
|
87
|
+
return { status: 415, message: 'Protobuf OTLP not supported; use application/json' };
|
|
88
|
+
}
|
|
89
|
+
if (ct !== 'application/json') {
|
|
90
|
+
return { status: 415, message: `Unsupported content type: ${contentType}` };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Parse JSON
|
|
94
|
+
let payload: OtlpMetricsPayload;
|
|
95
|
+
try {
|
|
96
|
+
const text = typeof body === 'string' ? body : new TextDecoder().decode(body);
|
|
97
|
+
payload = JSON.parse(text) as OtlpMetricsPayload;
|
|
98
|
+
} catch {
|
|
99
|
+
return { status: 400, message: 'Invalid JSON body' };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Validate basic shape
|
|
103
|
+
if (!payload || !Array.isArray(payload.resourceMetrics)) {
|
|
104
|
+
return { status: 400, message: 'Invalid OTLP payload: missing resourceMetrics' };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Convert to TimeSeries
|
|
108
|
+
let series = otlpToTimeSeries(payload);
|
|
109
|
+
|
|
110
|
+
// Apply schemas
|
|
111
|
+
if (this.schemas.length > 0) {
|
|
112
|
+
series = applySchemas(series, this.schemas, this.defaultAction);
|
|
113
|
+
} else if (this.defaultAction === 'drop') {
|
|
114
|
+
series = [];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Per-request injection happens after schema filtering so user logic always applies.
|
|
118
|
+
if (options?.injectLabels && options.injectLabels.length > 0) {
|
|
119
|
+
series = applyRequestLabelInjections(series, options.injectLabels);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Empty result → 204 No Content
|
|
123
|
+
if (series.length === 0) {
|
|
124
|
+
return { status: 204, message: 'No metrics after filtering' };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Encode to protobuf
|
|
128
|
+
const protoBytes = encodeWriteRequest({ timeseries: series });
|
|
129
|
+
|
|
130
|
+
// Compress with snappy
|
|
131
|
+
const compressed = snappyCompress(protoBytes);
|
|
132
|
+
|
|
133
|
+
// POST to remote-write
|
|
134
|
+
try {
|
|
135
|
+
const controller = new AbortController();
|
|
136
|
+
const timer = setTimeout(() => controller.abort(), this.remoteWrite.timeout);
|
|
137
|
+
|
|
138
|
+
let response: Response;
|
|
139
|
+
try {
|
|
140
|
+
response = await fetch(this.remoteWrite.url, {
|
|
141
|
+
method: 'POST',
|
|
142
|
+
headers: {
|
|
143
|
+
'Content-Type': 'application/x-protobuf',
|
|
144
|
+
'Content-Encoding': 'snappy',
|
|
145
|
+
'X-Prometheus-Remote-Write-Version': '0.1.0',
|
|
146
|
+
...this.remoteWrite.headers,
|
|
147
|
+
},
|
|
148
|
+
body: compressed,
|
|
149
|
+
signal: controller.signal,
|
|
150
|
+
});
|
|
151
|
+
} finally {
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!response.ok) {
|
|
156
|
+
const body = await response.text().catch(() => '');
|
|
157
|
+
return {
|
|
158
|
+
status: 502,
|
|
159
|
+
message: `Remote write failed: HTTP ${response.status} ${body}`.slice(0, 500),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { status: 200, message: 'OK' };
|
|
164
|
+
} catch (err) {
|
|
165
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
166
|
+
return { status: 502, message: `Remote write error: ${msg}` };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|