@joinremba/catalog 0.2.0 → 0.3.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/package.json +26 -1
- package/src/adapters/express.test.ts +67 -0
- package/src/adapters/express.ts +79 -0
- package/src/adapters/fastify.test.ts +37 -0
- package/src/adapters/fastify.ts +77 -0
- package/src/otel.test.ts +42 -0
- package/src/otel.ts +77 -0
- package/src/sampling.test.ts +41 -0
- package/src/sampling.ts +75 -0
- package/src/webhook.test.ts +31 -0
- package/src/webhook.ts +135 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@joinremba/catalog",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Production-ready logging and error event layer for TypeScript backends, built on Pino.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -25,6 +25,31 @@
|
|
|
25
25
|
"types": "./src/adapters/hono.ts",
|
|
26
26
|
"import": "./src/adapters/hono.ts",
|
|
27
27
|
"default": "./src/adapters/hono.ts"
|
|
28
|
+
},
|
|
29
|
+
"./adapters/express": {
|
|
30
|
+
"types": "./src/adapters/express.ts",
|
|
31
|
+
"import": "./src/adapters/express.ts",
|
|
32
|
+
"default": "./src/adapters/express.ts"
|
|
33
|
+
},
|
|
34
|
+
"./adapters/fastify": {
|
|
35
|
+
"types": "./src/adapters/fastify.ts",
|
|
36
|
+
"import": "./src/adapters/fastify.ts",
|
|
37
|
+
"default": "./src/adapters/fastify.ts"
|
|
38
|
+
},
|
|
39
|
+
"./webhook": {
|
|
40
|
+
"types": "./src/webhook.ts",
|
|
41
|
+
"import": "./src/webhook.ts",
|
|
42
|
+
"default": "./src/webhook.ts"
|
|
43
|
+
},
|
|
44
|
+
"./otel": {
|
|
45
|
+
"types": "./src/otel.ts",
|
|
46
|
+
"import": "./src/otel.ts",
|
|
47
|
+
"default": "./src/otel.ts"
|
|
48
|
+
},
|
|
49
|
+
"./sampling": {
|
|
50
|
+
"types": "./src/sampling.ts",
|
|
51
|
+
"import": "./src/sampling.ts",
|
|
52
|
+
"default": "./src/sampling.ts"
|
|
28
53
|
}
|
|
29
54
|
},
|
|
30
55
|
"files": [
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { createCatalog } from "../index";
|
|
3
|
+
import { requestIdMiddleware, httpLoggerMiddleware } from "./express";
|
|
4
|
+
|
|
5
|
+
function mockReq() {
|
|
6
|
+
const headers: Record<string, string | undefined> = {};
|
|
7
|
+
return {
|
|
8
|
+
method: "GET",
|
|
9
|
+
path: "/api/users",
|
|
10
|
+
ip: "127.0.0.1",
|
|
11
|
+
headers,
|
|
12
|
+
query: { page: "1" },
|
|
13
|
+
get(name: string) {
|
|
14
|
+
return headers[name.toLowerCase()];
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function mockRes() {
|
|
20
|
+
const listeners: Record<string, Array<(...args: unknown[]) => void>> = {};
|
|
21
|
+
return {
|
|
22
|
+
statusCode: 200,
|
|
23
|
+
status(code: number) {
|
|
24
|
+
this.statusCode = code;
|
|
25
|
+
return this;
|
|
26
|
+
},
|
|
27
|
+
on(event: string, fn: (...args: unknown[]) => void) {
|
|
28
|
+
(listeners[event] ??= []).push(fn);
|
|
29
|
+
return this;
|
|
30
|
+
},
|
|
31
|
+
emit(event: string, ...args: unknown[]) {
|
|
32
|
+
for (const fn of listeners[event] ?? []) fn(...args);
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test("requestIdMiddleware sets requestId", () => {
|
|
38
|
+
const catalog = createCatalog({ service: "test" });
|
|
39
|
+
const req = mockReq();
|
|
40
|
+
const res = mockRes();
|
|
41
|
+
requestIdMiddleware(catalog)(req as any, res as any, () => {});
|
|
42
|
+
expect((req as any).requestId).toBeDefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("httpLoggerMiddleware skips excluded paths", () => {
|
|
46
|
+
const catalog = createCatalog({ service: "test" });
|
|
47
|
+
const req = mockReq();
|
|
48
|
+
req.path = "/health";
|
|
49
|
+
const res = mockRes();
|
|
50
|
+
let called = false;
|
|
51
|
+
httpLoggerMiddleware(catalog)(req as any, res as any, () => {
|
|
52
|
+
called = true;
|
|
53
|
+
});
|
|
54
|
+
expect(called).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("httpLoggerMiddleware logs on finish", () => {
|
|
58
|
+
const catalog = createCatalog({ service: "test" });
|
|
59
|
+
const req = mockReq();
|
|
60
|
+
const res = mockRes();
|
|
61
|
+
let called = false;
|
|
62
|
+
httpLoggerMiddleware(catalog)(req as any, res as any, () => {
|
|
63
|
+
called = true;
|
|
64
|
+
});
|
|
65
|
+
expect(called).toBe(true);
|
|
66
|
+
expect(() => res.emit("finish")).not.toThrow();
|
|
67
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Catalog } from "../index";
|
|
2
|
+
|
|
3
|
+
interface ExpressRequest {
|
|
4
|
+
method: string;
|
|
5
|
+
path: string;
|
|
6
|
+
ip?: string;
|
|
7
|
+
headers: Record<string, string | string[] | undefined>;
|
|
8
|
+
query: Record<string, string | undefined>;
|
|
9
|
+
get(name: string): string | undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ExpressResponse {
|
|
13
|
+
statusCode: number;
|
|
14
|
+
status(code: number): ExpressResponse;
|
|
15
|
+
on(event: string, listener: (...args: unknown[]) => void): ExpressResponse;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type ExpressNext = (err?: unknown) => void;
|
|
19
|
+
|
|
20
|
+
export interface ExpressRequestIdOptions {
|
|
21
|
+
header?: string;
|
|
22
|
+
generate?: () => string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function requestIdMiddleware(_catalog: Catalog, options?: ExpressRequestIdOptions) {
|
|
26
|
+
const headerName = options?.header?.toLowerCase() ?? "x-request-id";
|
|
27
|
+
const generate = options?.generate ?? (() => crypto.randomUUID());
|
|
28
|
+
|
|
29
|
+
return (req: ExpressRequest, _res: ExpressResponse, next: ExpressNext) => {
|
|
30
|
+
const existing = req.get(headerName) ?? req.get("x-request-id");
|
|
31
|
+
(req as unknown as Record<string, unknown>).requestId = existing ?? generate();
|
|
32
|
+
next();
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface HttpLogOptions {
|
|
37
|
+
excludePaths?: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function httpLoggerMiddleware(catalog: Catalog, options?: HttpLogOptions) {
|
|
41
|
+
const exclude = new Set(options?.excludePaths ?? ["/health", "/favicon.ico"]);
|
|
42
|
+
|
|
43
|
+
return (req: ExpressRequest, res: ExpressResponse, next: ExpressNext) => {
|
|
44
|
+
if (exclude.has(req.path)) return next();
|
|
45
|
+
|
|
46
|
+
const requestId = (req as unknown as Record<string, unknown>).requestId as string | undefined;
|
|
47
|
+
const log = requestId ? catalog.child({ requestId }) : catalog;
|
|
48
|
+
const start = performance.now();
|
|
49
|
+
const method = req.method;
|
|
50
|
+
const path = req.path;
|
|
51
|
+
|
|
52
|
+
log.info({
|
|
53
|
+
message: `--> ${method} ${path}`,
|
|
54
|
+
method,
|
|
55
|
+
path,
|
|
56
|
+
query: req.query,
|
|
57
|
+
userAgent: req.get("user-agent"),
|
|
58
|
+
ip: req.ip,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
res.on("finish", () => {
|
|
62
|
+
const duration = ((performance.now() - start) * 1000).toFixed(2);
|
|
63
|
+
const status = res.statusCode;
|
|
64
|
+
const level = status >= 500 ? "error" : status >= 400 ? "warn" : "info";
|
|
65
|
+
|
|
66
|
+
const logData = {
|
|
67
|
+
message: `<-- ${method} ${path} ${status} ${duration}ms`,
|
|
68
|
+
status,
|
|
69
|
+
durationMs: duration,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
if (level === "info") log.info(logData);
|
|
73
|
+
else if (level === "warn") log.warn(logData);
|
|
74
|
+
else log.error(logData);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
next();
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { createCatalog } from "../index";
|
|
3
|
+
import { requestIdHook, httpLoggerHook } from "./fastify";
|
|
4
|
+
|
|
5
|
+
function mockRequest() {
|
|
6
|
+
return {
|
|
7
|
+
method: "GET",
|
|
8
|
+
url: "/api/users",
|
|
9
|
+
ip: "127.0.0.1",
|
|
10
|
+
headers: { "user-agent": "test-agent" },
|
|
11
|
+
query: { page: "1" },
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function mockReply(statusCode = 200) {
|
|
16
|
+
return { statusCode };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test("requestIdHook sets requestId", () => {
|
|
20
|
+
const catalog = createCatalog({ service: "test" });
|
|
21
|
+
const req = mockRequest();
|
|
22
|
+
const reply = mockReply();
|
|
23
|
+
requestIdHook(catalog)(req as any, reply as any, () => {});
|
|
24
|
+
expect((req as any).requestId).toBeDefined();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("httpLoggerHook skips excluded paths", () => {
|
|
28
|
+
const catalog = createCatalog({ service: "test" });
|
|
29
|
+
const req = mockRequest();
|
|
30
|
+
req.url = "/health";
|
|
31
|
+
const reply = mockReply();
|
|
32
|
+
let called = false;
|
|
33
|
+
httpLoggerHook(catalog)(req as any, reply as any, () => {
|
|
34
|
+
called = true;
|
|
35
|
+
});
|
|
36
|
+
expect(called).toBe(true);
|
|
37
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Catalog } from "../index";
|
|
2
|
+
|
|
3
|
+
interface FastifyRequest {
|
|
4
|
+
method: string;
|
|
5
|
+
url: string;
|
|
6
|
+
ip: string;
|
|
7
|
+
headers: Record<string, string | string[] | undefined>;
|
|
8
|
+
query: Record<string, string | undefined>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface FastifyReply {
|
|
12
|
+
statusCode: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface FastifyRequestIdOptions {
|
|
16
|
+
header?: string;
|
|
17
|
+
generate?: () => string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function requestIdHook(catalog: Catalog, options?: FastifyRequestIdOptions) {
|
|
21
|
+
const headerName = options?.header?.toLowerCase() ?? "x-request-id";
|
|
22
|
+
const generate = options?.generate ?? (() => crypto.randomUUID());
|
|
23
|
+
|
|
24
|
+
return (request: FastifyRequest, _reply: FastifyReply, done: () => void) => {
|
|
25
|
+
const existing = request.headers[headerName] ?? request.headers["x-request-id"];
|
|
26
|
+
(request as unknown as Record<string, unknown>).requestId = existing ?? generate();
|
|
27
|
+
done();
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface HttpLogOptions {
|
|
32
|
+
excludePaths?: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function httpLoggerHook(catalog: Catalog, options?: HttpLogOptions) {
|
|
36
|
+
const exclude = new Set(options?.excludePaths ?? ["/health", "/favicon.ico"]);
|
|
37
|
+
|
|
38
|
+
return (request: FastifyRequest, reply: FastifyReply, done: () => void) => {
|
|
39
|
+
if (exclude.has(request.url)) return done();
|
|
40
|
+
|
|
41
|
+
const requestId = (request as unknown as Record<string, unknown>).requestId as
|
|
42
|
+
| string
|
|
43
|
+
| undefined;
|
|
44
|
+
const log = requestId ? catalog.child({ requestId }) : catalog;
|
|
45
|
+
const start = performance.now();
|
|
46
|
+
const method = request.method;
|
|
47
|
+
const url = request.url;
|
|
48
|
+
|
|
49
|
+
log.info({
|
|
50
|
+
message: `--> ${method} ${url}`,
|
|
51
|
+
method,
|
|
52
|
+
url,
|
|
53
|
+
query: request.query,
|
|
54
|
+
userAgent: request.headers["user-agent"],
|
|
55
|
+
ip: request.ip,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
done();
|
|
59
|
+
|
|
60
|
+
// Capture duration when reply is sent (Fastify calls onResponse after send)
|
|
61
|
+
queueMicrotask(() => {
|
|
62
|
+
const duration = ((performance.now() - start) * 1000).toFixed(2);
|
|
63
|
+
const status = reply.statusCode;
|
|
64
|
+
const level = status >= 500 ? "error" : status >= 400 ? "warn" : "info";
|
|
65
|
+
|
|
66
|
+
const logData = {
|
|
67
|
+
message: `<-- ${method} ${url} ${status} ${duration}ms`,
|
|
68
|
+
status,
|
|
69
|
+
durationMs: duration,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
if (level === "info") log.info(logData);
|
|
73
|
+
else if (level === "warn") log.warn(logData);
|
|
74
|
+
else log.error(logData);
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
}
|
package/src/otel.test.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { createCatalog } from "./index";
|
|
3
|
+
import { otelBridge } from "./otel";
|
|
4
|
+
import type { OtelApi } from "./otel";
|
|
5
|
+
|
|
6
|
+
function mockOtelApi(): OtelApi {
|
|
7
|
+
return {
|
|
8
|
+
trace: {
|
|
9
|
+
getActiveSpan() {
|
|
10
|
+
return {
|
|
11
|
+
spanContext() {
|
|
12
|
+
return { traceId: "abc123", spanId: "def456", traceFlags: 1 };
|
|
13
|
+
},
|
|
14
|
+
addEvent(_name: string, _attrs?: Record<string, unknown>) {},
|
|
15
|
+
setAttribute(_key: string, _value: unknown) {},
|
|
16
|
+
};
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
SpanStatusCode: { OK: 0, ERROR: 1, UNSET: 2 },
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test("otelBridge injects trace context", () => {
|
|
24
|
+
const catalog = createCatalog({ service: "test" });
|
|
25
|
+
const otel = mockOtelApi();
|
|
26
|
+
const bridged = otelBridge(catalog, { api: otel });
|
|
27
|
+
|
|
28
|
+
expect(() => {
|
|
29
|
+
bridged.info("app.started", { version: "1.0.0" });
|
|
30
|
+
}).not.toThrow();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("otelBridge child preserves bridging", () => {
|
|
34
|
+
const catalog = createCatalog({ service: "test" });
|
|
35
|
+
const otel = mockOtelApi();
|
|
36
|
+
const bridged = otelBridge(catalog, { api: otel });
|
|
37
|
+
const child = bridged.child({ requestId: "req-1" });
|
|
38
|
+
|
|
39
|
+
expect(() => {
|
|
40
|
+
child.info("child.event");
|
|
41
|
+
}).not.toThrow();
|
|
42
|
+
});
|
package/src/otel.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Catalog, LogLevel } from "./index";
|
|
2
|
+
|
|
3
|
+
export interface OtelSpanContext {
|
|
4
|
+
traceId: string;
|
|
5
|
+
spanId: string;
|
|
6
|
+
traceFlags?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface OtelSpan {
|
|
10
|
+
spanContext(): OtelSpanContext;
|
|
11
|
+
addEvent(name: string, attributes?: Record<string, unknown>): void;
|
|
12
|
+
setAttribute(key: string, value: unknown): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface OtelApi {
|
|
16
|
+
trace: {
|
|
17
|
+
getActiveSpan(): OtelSpan | undefined;
|
|
18
|
+
};
|
|
19
|
+
SpanStatusCode: { OK: number; ERROR: number; UNSET: number };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface OtelBridgeOptions {
|
|
23
|
+
api: OtelApi;
|
|
24
|
+
captureSpanEvents?: boolean;
|
|
25
|
+
spanAttributePrefix?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function otelBridge(catalog: Catalog, options: OtelBridgeOptions) {
|
|
29
|
+
const { api, captureSpanEvents = false, spanAttributePrefix = "log" } = options;
|
|
30
|
+
|
|
31
|
+
function withTraceContext(
|
|
32
|
+
first: string | Record<string, unknown>,
|
|
33
|
+
data?: Record<string, unknown>
|
|
34
|
+
) {
|
|
35
|
+
const span = api.trace.getActiveSpan();
|
|
36
|
+
if (!span) return { first, data };
|
|
37
|
+
|
|
38
|
+
const ctx = span.spanContext();
|
|
39
|
+
const traceData: Record<string, unknown> = {
|
|
40
|
+
trace_id: ctx.traceId,
|
|
41
|
+
span_id: ctx.spanId,
|
|
42
|
+
...(data as Record<string, unknown>),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
if (captureSpanEvents) {
|
|
46
|
+
const msg = typeof first === "string" ? first : "";
|
|
47
|
+
span.addEvent(msg, traceData);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
span.setAttribute(`${spanAttributePrefix}.trace_id`, ctx.traceId);
|
|
51
|
+
|
|
52
|
+
return { first, data: traceData };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function adapt(method: LogLevel) {
|
|
56
|
+
return (first: string | Record<string, unknown>, second?: Record<string, unknown>) => {
|
|
57
|
+
const { first: f, data: d } = withTraceContext(first, second);
|
|
58
|
+
const logFn = (catalog as unknown as Record<string, (...args: unknown[]) => void>)[method]!;
|
|
59
|
+
logFn(f, d);
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
trace: adapt("trace"),
|
|
65
|
+
debug: adapt("debug"),
|
|
66
|
+
info: adapt("info"),
|
|
67
|
+
warn: adapt("warn"),
|
|
68
|
+
error: adapt("error"),
|
|
69
|
+
fatal: adapt("fatal"),
|
|
70
|
+
child(bindings: Record<string, unknown>) {
|
|
71
|
+
return otelBridge(catalog.child(bindings), options);
|
|
72
|
+
},
|
|
73
|
+
get level(): LogLevel {
|
|
74
|
+
return catalog.level;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { createCatalog } from "./index";
|
|
3
|
+
import { samplingCatalog } from "./sampling";
|
|
4
|
+
|
|
5
|
+
test("samplingCatalog at rate 1.0 passes all events", () => {
|
|
6
|
+
const catalog = createCatalog({ service: "test" });
|
|
7
|
+
let called = false;
|
|
8
|
+
|
|
9
|
+
const spy = new Proxy(catalog, {
|
|
10
|
+
get(target, prop) {
|
|
11
|
+
if (prop === "info")
|
|
12
|
+
return () => {
|
|
13
|
+
called = true;
|
|
14
|
+
};
|
|
15
|
+
if (typeof prop === "string") {
|
|
16
|
+
return (target as unknown as Record<string, unknown>)[prop];
|
|
17
|
+
}
|
|
18
|
+
return undefined;
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const s2 = samplingCatalog(spy, { rate: 1.0 });
|
|
23
|
+
s2.info("test.event");
|
|
24
|
+
expect(called).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("samplingCatalog at rate 0.0 drops all events", () => {
|
|
28
|
+
const catalog = createCatalog({ service: "test" });
|
|
29
|
+
const sampled = samplingCatalog(catalog, { rate: 0.0 });
|
|
30
|
+
expect(() => sampled.info("dropped.event")).not.toThrow();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("samplingCatalog filters by level", () => {
|
|
34
|
+
const catalog = createCatalog({ service: "test" });
|
|
35
|
+
const sampled = samplingCatalog(catalog, { rate: 1.0, level: "warn" });
|
|
36
|
+
expect(() => {
|
|
37
|
+
sampled.info("info.event");
|
|
38
|
+
sampled.warn("warn.event");
|
|
39
|
+
sampled.error("error.event");
|
|
40
|
+
}).not.toThrow();
|
|
41
|
+
});
|
package/src/sampling.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Catalog, LogLevel } from "./index";
|
|
2
|
+
|
|
3
|
+
type SamplerFn = (level: LogLevel, message: string, data?: Record<string, unknown>) => boolean;
|
|
4
|
+
|
|
5
|
+
export interface SamplingOptions {
|
|
6
|
+
rate: number;
|
|
7
|
+
level?: LogLevel;
|
|
8
|
+
sampler?: SamplerFn;
|
|
9
|
+
keyFn?: (level: LogLevel, message: string, data?: Record<string, unknown>) => string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function defaultKeyFn(_level: LogLevel, message: string, _data?: Record<string, unknown>): string {
|
|
13
|
+
return message;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function deterministicSampler(
|
|
17
|
+
rate: number,
|
|
18
|
+
keyFn: (level: LogLevel, message: string, data?: Record<string, unknown>) => string
|
|
19
|
+
): SamplerFn {
|
|
20
|
+
return (level, message, data) => {
|
|
21
|
+
const key = keyFn(level, message, data);
|
|
22
|
+
let hash = 0;
|
|
23
|
+
for (let i = 0; i < key.length; i++) {
|
|
24
|
+
const char = key.charCodeAt(i);
|
|
25
|
+
hash = (hash << 5) - hash + char;
|
|
26
|
+
hash |= 0;
|
|
27
|
+
}
|
|
28
|
+
const normalized = Math.abs(hash) / 0x7fffffff;
|
|
29
|
+
return normalized < rate;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function samplingCatalog(catalog: Catalog, options: SamplingOptions) {
|
|
34
|
+
const { rate, level: levelFilter, sampler, keyFn } = options;
|
|
35
|
+
const levels: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"];
|
|
36
|
+
const minLevelIdx = levelFilter ? levels.indexOf(levelFilter) : 0;
|
|
37
|
+
|
|
38
|
+
const shouldSample: SamplerFn =
|
|
39
|
+
sampler ??
|
|
40
|
+
(keyFn ? deterministicSampler(rate, keyFn) : deterministicSampler(rate, defaultKeyFn));
|
|
41
|
+
|
|
42
|
+
function adapt(method: LogLevel) {
|
|
43
|
+
return (first: string | Record<string, unknown>, second?: Record<string, unknown>) => {
|
|
44
|
+
const levelIdx = levels.indexOf(method);
|
|
45
|
+
if (levelIdx < minLevelIdx) return;
|
|
46
|
+
|
|
47
|
+
const message = typeof first === "string" ? first : "";
|
|
48
|
+
const data = typeof first === "object" ? (first as Record<string, unknown>) : second;
|
|
49
|
+
|
|
50
|
+
if (!shouldSample(method, message, data)) return;
|
|
51
|
+
|
|
52
|
+
const logFn = (catalog as unknown as Record<string, (...args: unknown[]) => void>)[method]!;
|
|
53
|
+
if (typeof first === "string") {
|
|
54
|
+
logFn(first, second);
|
|
55
|
+
} else {
|
|
56
|
+
logFn(first);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
trace: adapt("trace"),
|
|
63
|
+
debug: adapt("debug"),
|
|
64
|
+
info: adapt("info"),
|
|
65
|
+
warn: adapt("warn"),
|
|
66
|
+
error: adapt("error"),
|
|
67
|
+
fatal: adapt("fatal"),
|
|
68
|
+
child(bindings: Record<string, unknown>) {
|
|
69
|
+
return samplingCatalog(catalog.child(bindings), options);
|
|
70
|
+
},
|
|
71
|
+
get level(): LogLevel {
|
|
72
|
+
return catalog.level;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { createCatalog } from "./index";
|
|
3
|
+
import { webhookLogger } from "./webhook";
|
|
4
|
+
|
|
5
|
+
test("webhookLogger enqueues events without throwing", () => {
|
|
6
|
+
const catalog = createCatalog({ service: "test" });
|
|
7
|
+
const wh = webhookLogger(catalog, {
|
|
8
|
+
targets: [{ url: "https://hooks.example.com/logs" }],
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
expect(() => {
|
|
12
|
+
wh.info("app.started", { version: "1.0.0" });
|
|
13
|
+
wh.error("db.connection_failed", { database: "prod" });
|
|
14
|
+
}).not.toThrow();
|
|
15
|
+
|
|
16
|
+
wh.stop();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("webhookLogger filters by target level", () => {
|
|
20
|
+
const catalog = createCatalog({ service: "test" });
|
|
21
|
+
const wh = webhookLogger(catalog, {
|
|
22
|
+
targets: [{ url: "https://hooks.example.com/errors", level: "error" }],
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
expect(() => {
|
|
26
|
+
wh.info("app.started");
|
|
27
|
+
wh.error("critical.failure");
|
|
28
|
+
}).not.toThrow();
|
|
29
|
+
|
|
30
|
+
wh.stop();
|
|
31
|
+
});
|
package/src/webhook.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { Catalog, LogLevel } from "./index";
|
|
2
|
+
|
|
3
|
+
export interface WebhookTarget {
|
|
4
|
+
url: string;
|
|
5
|
+
level?: LogLevel;
|
|
6
|
+
headers?: Record<string, string>;
|
|
7
|
+
secret?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface WebhookOptions {
|
|
11
|
+
targets: WebhookTarget[];
|
|
12
|
+
batchIntervalMs?: number;
|
|
13
|
+
maxBatchSize?: number;
|
|
14
|
+
retryCount?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface QueuedEvent {
|
|
18
|
+
level: LogLevel;
|
|
19
|
+
message: string;
|
|
20
|
+
data?: Record<string, unknown>;
|
|
21
|
+
timestamp: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function signPayload(payload: string, secret: string): Promise<string> {
|
|
25
|
+
const encoder = new TextEncoder();
|
|
26
|
+
const key = await crypto.subtle.importKey(
|
|
27
|
+
"raw",
|
|
28
|
+
encoder.encode(secret),
|
|
29
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
30
|
+
false,
|
|
31
|
+
["sign"]
|
|
32
|
+
);
|
|
33
|
+
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
|
|
34
|
+
return Array.from(new Uint8Array(sig))
|
|
35
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
36
|
+
.join("");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function webhookLogger(catalog: Catalog, options: WebhookOptions) {
|
|
40
|
+
const { targets, batchIntervalMs = 5000, maxBatchSize = 50, retryCount = 2 } = options;
|
|
41
|
+
const queues: Map<string, QueuedEvent[]> = new Map();
|
|
42
|
+
const timers: Map<string, ReturnType<typeof setInterval>> = new Map();
|
|
43
|
+
|
|
44
|
+
for (const target of targets) {
|
|
45
|
+
queues.set(target.url, []);
|
|
46
|
+
const timer = setInterval(() => flush(target), batchIntervalMs);
|
|
47
|
+
timers.set(target.url, timer);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function flush(target: WebhookTarget) {
|
|
51
|
+
const queue = queues.get(target.url);
|
|
52
|
+
if (!queue || queue.length === 0) return;
|
|
53
|
+
|
|
54
|
+
const batch = queue.splice(0, maxBatchSize);
|
|
55
|
+
const body = JSON.stringify({ events: batch });
|
|
56
|
+
const headers: Record<string, string> = {
|
|
57
|
+
"Content-Type": "application/json",
|
|
58
|
+
...target.headers,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
if (target.secret) {
|
|
62
|
+
headers["X-Signature-256"] = await signPayload(body, target.secret);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (let attempt = 0; attempt <= retryCount; attempt++) {
|
|
66
|
+
try {
|
|
67
|
+
const res = await fetch(target.url, { method: "POST", headers, body });
|
|
68
|
+
if (res.ok) break;
|
|
69
|
+
if (attempt < retryCount) await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
|
|
70
|
+
} catch {
|
|
71
|
+
if (attempt < retryCount) await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function shouldLog(level: LogLevel, targetLevel?: LogLevel): boolean {
|
|
77
|
+
const levels: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"];
|
|
78
|
+
const targetIdx = targetLevel ? levels.indexOf(targetLevel) : 0;
|
|
79
|
+
const eventIdx = levels.indexOf(level);
|
|
80
|
+
return eventIdx >= targetIdx;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function enqueue(
|
|
84
|
+
level: LogLevel,
|
|
85
|
+
first: string | Record<string, unknown>,
|
|
86
|
+
second?: Record<string, unknown>
|
|
87
|
+
) {
|
|
88
|
+
const message = typeof first === "string" ? first : "";
|
|
89
|
+
const data = typeof first === "object" ? first : second;
|
|
90
|
+
|
|
91
|
+
for (const target of targets) {
|
|
92
|
+
if (!shouldLog(level, target.level)) continue;
|
|
93
|
+
const queue = queues.get(target.url);
|
|
94
|
+
if (queue) {
|
|
95
|
+
queue.push({ level, message, data, timestamp: Date.now() });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function stop() {
|
|
101
|
+
for (const [url, timer] of timers) {
|
|
102
|
+
clearInterval(timer);
|
|
103
|
+
const target = targets.find((t) => t.url === url);
|
|
104
|
+
if (target) {
|
|
105
|
+
clearInterval(timer);
|
|
106
|
+
flush(target);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
timers.clear();
|
|
110
|
+
queues.clear();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
trace(first: string | Record<string, unknown>, second?: Record<string, unknown>) {
|
|
115
|
+
enqueue("trace", first, second);
|
|
116
|
+
},
|
|
117
|
+
debug(first: string | Record<string, unknown>, second?: Record<string, unknown>) {
|
|
118
|
+
enqueue("debug", first, second);
|
|
119
|
+
},
|
|
120
|
+
info(first: string | Record<string, unknown>, second?: Record<string, unknown>) {
|
|
121
|
+
enqueue("info", first, second);
|
|
122
|
+
},
|
|
123
|
+
warn(first: string | Record<string, unknown>, second?: Record<string, unknown>) {
|
|
124
|
+
enqueue("warn", first, second);
|
|
125
|
+
},
|
|
126
|
+
error(first: string | Record<string, unknown>, second?: Record<string, unknown>) {
|
|
127
|
+
enqueue("error", first, second);
|
|
128
|
+
},
|
|
129
|
+
fatal(first: string | Record<string, unknown>, second?: Record<string, unknown>) {
|
|
130
|
+
enqueue("fatal", first, second);
|
|
131
|
+
},
|
|
132
|
+
stop,
|
|
133
|
+
flush: () => Promise.all(targets.map(flush)),
|
|
134
|
+
};
|
|
135
|
+
}
|