@forinda/kickjs-otel 3.1.3 → 4.0.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 CHANGED
@@ -1,58 +1,46 @@
1
1
  # @forinda/kickjs-otel
2
2
 
3
- OpenTelemetry adapter for KickJS — automatic tracing, metrics, and export to any OTel backend.
3
+ OpenTelemetry adapter for KickJS — auto request spans + counter/histogram metrics, route ignore list, span-attribute redaction. Works with any OTel backend (Jaeger, Grafana Tempo, Datadog, Honeycomb, etc.).
4
4
 
5
5
  ## Install
6
6
 
7
7
  ```bash
8
- # Using the KickJS CLI (recommended — auto-installs peer dependencies)
9
8
  kick add otel
10
-
11
- # Manual install
12
- pnpm add @forinda/kickjs-otel @opentelemetry/api @opentelemetry/semantic-conventions
13
- # Optional SDK packages
14
- pnpm add @opentelemetry/sdk-node @opentelemetry/sdk-trace-base @opentelemetry/sdk-metrics
15
9
  ```
16
10
 
17
- ## Features
18
-
19
- - `OtelAdapter` — lifecycle adapter that instruments requests with spans and metrics
20
- - Works with any OpenTelemetry-compatible backend (Jaeger, Grafana Tempo, Datadog, etc.)
21
- - Zero-config console exporter for development
22
-
23
11
  ## Quick Example
24
12
 
25
- ```typescript
13
+ ```ts
14
+ import { bootstrap } from '@forinda/kickjs'
26
15
  import { OtelAdapter } from '@forinda/kickjs-otel'
16
+ import { modules } from './modules'
27
17
 
28
- bootstrap({
18
+ export const app = await bootstrap({
29
19
  modules,
30
20
  adapters: [
31
- new OtelAdapter({
21
+ OtelAdapter({
32
22
  serviceName: 'my-api',
33
- enabled: true,
23
+ ignoreRoutes: ['/health', '/_debug/*'],
24
+ sensitiveKeys: ['authorization', /^x-api-key/i],
34
25
  }),
35
26
  ],
36
27
  })
37
28
  ```
38
29
 
39
- For production, initialize the OTel SDK before bootstrap:
30
+ For production, initialize the OTel SDK before `bootstrap()`:
40
31
 
41
- ```typescript
32
+ ```ts
42
33
  import { NodeSDK } from '@opentelemetry/sdk-node'
43
34
  import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
44
35
 
45
- const sdk = new NodeSDK({
46
- traceExporter: new OTLPTraceExporter({
47
- url: 'http://localhost:4318/v1/traces',
48
- }),
49
- })
50
- sdk.start()
36
+ new NodeSDK({
37
+ traceExporter: new OTLPTraceExporter({ url: 'http://localhost:4318/v1/traces' }),
38
+ }).start()
51
39
  ```
52
40
 
53
41
  ## Documentation
54
42
 
55
- [Full documentation](https://forinda.github.io/kick-js/)
43
+ [forinda.github.io/kick-js/api/otel](https://forinda.github.io/kick-js/api/otel)
56
44
 
57
45
  ## License
58
46
 
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
 
2
- import { AdapterContext, AdapterMiddleware, AppAdapter } from "@forinda/kickjs";
2
+ import * as _$_forinda_kickjs0 from "@forinda/kickjs";
3
3
 
4
4
  //#region src/types.d.ts
5
5
  interface OtelAdapterOptions {
@@ -31,7 +31,7 @@ interface OtelAdapterOptions {
31
31
  * import { sensitiveKeys } from './config/redaction'
32
32
  *
33
33
  * pino({ redact: { paths: sensitiveKeys } })
34
- * new OtelAdapter({ sensitiveKeys })
34
+ * OtelAdapter({ sensitiveKeys })
35
35
  * ```
36
36
  *
37
37
  * String entries do case-insensitive exact match on the attribute
@@ -53,6 +53,21 @@ interface OtelAdapterOptions {
53
53
  }
54
54
  //#endregion
55
55
  //#region src/otel.adapter.d.ts
56
+ /**
57
+ * Public extension methods exposed by an OtelAdapter instance beyond
58
+ * the standard {@link AppAdapter} contract. Surfaced via `TExtra` on
59
+ * `defineAdapter` so peer adapters / tests / downstream code that
60
+ * adds attributes directly on a span can share the redaction contract.
61
+ */
62
+ interface OtelAdapterExtensions {
63
+ /**
64
+ * Run the configured redactor over an attribute bag. Useful for code
65
+ * that adds attributes directly on a span (`span.setAttributes(...)`)
66
+ * outside the request middleware path — applies the same key matching
67
+ * (case-insensitive strings, regex patterns) declared on the adapter.
68
+ */
69
+ applyRedaction<T extends Record<string, unknown>>(attrs: T): Record<string, unknown>;
70
+ }
56
71
  /**
57
72
  * OpenTelemetry adapter for KickJS — automatic tracing and metrics.
58
73
  *
@@ -70,7 +85,7 @@ interface OtelAdapterOptions {
70
85
  * bootstrap({
71
86
  * modules,
72
87
  * adapters: [
73
- * new OtelAdapter({
88
+ * OtelAdapter({
74
89
  * serviceName: 'my-api',
75
90
  * serviceVersion: '1.0.0',
76
91
  * ignoreRoutes: ['/health', '/_debug/*'],
@@ -79,27 +94,7 @@ interface OtelAdapterOptions {
79
94
  * })
80
95
  * ```
81
96
  */
82
- declare class OtelAdapter implements AppAdapter {
83
- name: string;
84
- private options;
85
- private tracer;
86
- private meter;
87
- private requestCounter;
88
- private requestDuration;
89
- private readonly redact;
90
- constructor(options?: OtelAdapterOptions);
91
- beforeStart({}: AdapterContext): void;
92
- middleware(): AdapterMiddleware[];
93
- private onFinish;
94
- private shouldIgnore;
95
- /**
96
- * Run the configured redactor over an attribute bag. Exposed so
97
- * downstream code that adds attributes directly on a span
98
- * (`span.setAttributes(...)`) can share the same redaction contract.
99
- */
100
- applyRedaction<T extends Record<string, unknown>>(attrs: T): Record<string, unknown>;
101
- shutdown(): Promise<void>;
102
- }
97
+ declare const OtelAdapter: _$_forinda_kickjs0.AdapterFactory<OtelAdapterOptions, OtelAdapterExtensions>;
103
98
  //#endregion
104
99
  export { OtelAdapter, type OtelAdapterOptions };
105
100
  //# sourceMappingURL=index.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts","../src/otel.adapter.ts"],"mappings":";;;;UAAiB,kBAAA;;EAEf,WAAA;EAFe;EAKf,cAAA;;EAGA,OAAA;EANA;EASA,OAAA;EAHA;;;;EASA,gBAAA,IAAoB,GAAA,UAAa,MAAA;EAOjC;;;;;EAAA,YAAA;EA8B8C;;;;;ACjBhD;;;;;;;;;;;;;;;;EDUE,aAAA,aAA0B,MAAA;ECHlB;;;;;EDUR,eAAA,IAAmB,GAAA,UAAa,KAAA;AAAA;;;;AAtDlC;;;;;;;;;;;;;;;;;;;;;;ACqCA;;;cAAa,WAAA,YAAuB,UAAA;EAClC,IAAA;EAAA,QACQ,OAAA;EAAA,QAIA,MAAA;EAAA,QACA,KAAA;EAAA,QACA,cAAA;EAAA,QACA,eAAA;EAAA,iBACS,MAAA;cAEL,OAAA,GAAS,kBAAA;EAWrB,WAAA,CAAA,IAAgB,cAAA;EA+BhB,UAAA,CAAA,GAAc,iBAAA;EAAA,QA2CN,QAAA;EAAA,QAkCA,YAAA;EA7HA;;;;;EA4IR,cAAA,WAAyB,MAAA,kBAAA,CAAyB,KAAA,EAAO,CAAA,GAAI,MAAA;EAQvD,QAAA,CAAA,GAAY,OAAA;AAAA"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts","../src/otel.adapter.ts"],"mappings":";;;;UAAiB,kBAAA;;EAEf,WAAA;EAFe;EAKf,cAAA;;EAGA,OAAA;EANA;EASA,OAAA;EAHA;;;;EASA,gBAAA,IAAoB,GAAA,UAAa,MAAA;EAOjC;;;;;EAAA,YAAA;EA8B8C;;;;;AC1ChD;;;;;;;;;;;;;;;;EDmCE,aAAA,aAA0B,MAAA;ECoJ1B;;;;;ED7IA,eAAA,IAAmB,GAAA,UAAa,KAAA;AAAA;;;;;AAtDlC;;;;UCYiB,qBAAA;EDPf;;;;;;ECcA,cAAA,WAAyB,MAAA,mBAAyB,KAAA,EAAO,CAAA,GAAI,MAAA;AAAA;;;;;;;;;;AAP/D;;;;;;;;;;;;;;;;AAoCA;cAAa,WAAA,EAAW,kBAAA,CAAA,cAAA,CAAA,kBAAA,EAAA,qBAAA"}
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @forinda/kickjs-otel v3.1.3
2
+ * @forinda/kickjs-otel v4.0.0
3
3
  *
4
4
  * Copyright (c) Felix Orinda
5
5
  *
@@ -9,7 +9,7 @@
9
9
  * @license MIT
10
10
  */
11
11
  import { createRequire } from "node:module";
12
- import { Logger } from "@forinda/kickjs";
12
+ import { Logger, defineAdapter } from "@forinda/kickjs";
13
13
  //#region \0rolldown/runtime.js
14
14
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
15
15
  //#endregion
@@ -32,7 +32,7 @@ const log = Logger.for("OtelAdapter");
32
32
  * bootstrap({
33
33
  * modules,
34
34
  * adapters: [
35
- * new OtelAdapter({
35
+ * OtelAdapter({
36
36
  * serviceName: 'my-api',
37
37
  * serviceVersion: '1.0.0',
38
38
  * ignoreRoutes: ['/health', '/_debug/*'],
@@ -41,118 +41,113 @@ const log = Logger.for("OtelAdapter");
41
41
  * })
42
42
  * ```
43
43
  */
44
- var OtelAdapter = class {
45
- name = "OtelAdapter";
46
- options;
47
- tracer = null;
48
- meter = null;
49
- requestCounter = null;
50
- requestDuration = null;
51
- redact;
52
- constructor(options = {}) {
53
- this.options = {
54
- serviceName: options.serviceName ?? "kickjs-app",
55
- serviceVersion: options.serviceVersion ?? "0.0.0",
56
- tracing: options.tracing ?? true,
57
- metrics: options.metrics ?? true,
58
- ...options
44
+ const OtelAdapter = defineAdapter({
45
+ name: "OtelAdapter",
46
+ defaults: {
47
+ serviceName: "kickjs-app",
48
+ serviceVersion: "0.0.0",
49
+ tracing: true,
50
+ metrics: true
51
+ },
52
+ build: (options) => {
53
+ const redact = buildRedactor(options.sensitiveKeys, options.redactAttribute);
54
+ let tracer = null;
55
+ let meter = null;
56
+ let requestCounter = null;
57
+ let requestDuration = null;
58
+ const applyRedaction = (attrs) => {
59
+ const out = {};
60
+ for (const key of Object.keys(attrs)) out[key] = redact(key, attrs[key]);
61
+ return out;
59
62
  };
60
- this.redact = buildRedactor(this.options.sensitiveKeys, this.options.redactAttribute);
61
- }
62
- beforeStart({}) {
63
- try {
64
- const otelApi = __require("@opentelemetry/api");
65
- if (this.options.tracing) {
66
- this.tracer = otelApi.trace.getTracer(this.options.serviceName, this.options.serviceVersion);
67
- log.info(`Tracing enabled for ${this.options.serviceName}`);
68
- }
69
- if (this.options.metrics) {
70
- this.meter = otelApi.metrics.getMeter(this.options.serviceName, this.options.serviceVersion);
71
- this.requestCounter = this.meter.createCounter("http.server.request.count", { description: "Total number of HTTP requests" });
72
- this.requestDuration = this.meter.createHistogram("http.server.request.duration", {
73
- description: "HTTP request duration in milliseconds",
74
- unit: "ms"
75
- });
76
- log.info("Metrics enabled — http.server.request.count, http.server.request.duration");
77
- }
78
- } catch {
79
- log.warn("OpenTelemetry API not found. Install @opentelemetry/api to enable tracing and metrics.");
80
- }
81
- }
82
- middleware() {
83
- return [{
84
- handler: (req, res, next) => {
85
- if (this.shouldIgnore(req.path)) return next();
86
- const startTime = performance.now();
87
- let span = null;
88
- if (this.tracer) {
89
- const otelApi = __require("@opentelemetry/api");
90
- span = this.tracer.startSpan(`${req.method} ${req.route?.path ?? req.path}`, { attributes: this.applyRedaction({
91
- "http.method": req.method,
92
- "http.url": req.originalUrl,
93
- "http.target": req.path,
94
- "http.user_agent": req.get("user-agent") ?? "",
95
- "net.host.name": req.hostname,
96
- ...this.options.customAttributes?.(req) ?? {}
97
- }) });
98
- const ctx = otelApi.trace.setSpan(otelApi.context.active(), span);
99
- otelApi.context.with(ctx, () => {
100
- this.onFinish(req, res, startTime, span);
101
- next();
63
+ const shouldIgnore = (path) => {
64
+ if (!options.ignoreRoutes) return false;
65
+ return options.ignoreRoutes.some((pattern) => {
66
+ if (pattern.endsWith("*")) return path.startsWith(pattern.slice(0, -1));
67
+ return path === pattern;
68
+ });
69
+ };
70
+ const onFinish = (req, res, startTime, span) => {
71
+ res.on("finish", () => {
72
+ const duration = performance.now() - startTime;
73
+ const route = req.route?.path ?? req.path;
74
+ const attributes = {
75
+ "http.method": req.method,
76
+ "http.route": route,
77
+ "http.status_code": res.statusCode
78
+ };
79
+ if (span) {
80
+ span.setAttributes(applyRedaction({
81
+ "http.status_code": res.statusCode,
82
+ "http.route": route
83
+ }));
84
+ if (res.statusCode >= 400) span.setStatus({
85
+ code: 2,
86
+ message: `HTTP ${res.statusCode}`
102
87
  });
103
- return;
88
+ span.end();
89
+ }
90
+ if (requestCounter) requestCounter.add(1, attributes);
91
+ if (requestDuration) requestDuration.record(duration, attributes);
92
+ });
93
+ };
94
+ return {
95
+ applyRedaction,
96
+ beforeStart() {
97
+ try {
98
+ const otelApi = __require("@opentelemetry/api");
99
+ if (options.tracing) {
100
+ tracer = otelApi.trace.getTracer(options.serviceName, options.serviceVersion);
101
+ log.info(`Tracing enabled for ${options.serviceName}`);
102
+ }
103
+ if (options.metrics) {
104
+ meter = otelApi.metrics.getMeter(options.serviceName, options.serviceVersion);
105
+ requestCounter = meter.createCounter("http.server.request.count", { description: "Total number of HTTP requests" });
106
+ requestDuration = meter.createHistogram("http.server.request.duration", {
107
+ description: "HTTP request duration in milliseconds",
108
+ unit: "ms"
109
+ });
110
+ log.info("Metrics enabled — http.server.request.count, http.server.request.duration");
111
+ }
112
+ } catch {
113
+ log.warn("OpenTelemetry API not found. Install @opentelemetry/api to enable tracing and metrics.");
104
114
  }
105
- this.onFinish(req, res, startTime, null);
106
- next();
107
115
  },
108
- phase: "beforeGlobal"
109
- }];
110
- }
111
- onFinish(req, res, startTime, span) {
112
- res.on("finish", () => {
113
- const duration = performance.now() - startTime;
114
- const route = req.route?.path ?? req.path;
115
- const attributes = {
116
- "http.method": req.method,
117
- "http.route": route,
118
- "http.status_code": res.statusCode
119
- };
120
- if (span) {
121
- span.setAttributes(this.applyRedaction({
122
- "http.status_code": res.statusCode,
123
- "http.route": route
124
- }));
125
- if (res.statusCode >= 400) span.setStatus({
126
- code: 2,
127
- message: `HTTP ${res.statusCode}`
128
- });
129
- span.end();
116
+ middleware() {
117
+ return [{
118
+ handler: (req, res, next) => {
119
+ if (shouldIgnore(req.path)) return next();
120
+ const startTime = performance.now();
121
+ let span = null;
122
+ if (tracer) {
123
+ const otelApi = __require("@opentelemetry/api");
124
+ span = tracer.startSpan(`${req.method} ${req.route?.path ?? req.path}`, { attributes: applyRedaction({
125
+ "http.method": req.method,
126
+ "http.url": req.originalUrl,
127
+ "http.target": req.path,
128
+ "http.user_agent": req.get("user-agent") ?? "",
129
+ "net.host.name": req.hostname,
130
+ ...options.customAttributes?.(req) ?? {}
131
+ }) });
132
+ const ctx = otelApi.trace.setSpan(otelApi.context.active(), span);
133
+ otelApi.context.with(ctx, () => {
134
+ onFinish(req, res, startTime, span);
135
+ next();
136
+ });
137
+ return;
138
+ }
139
+ onFinish(req, res, startTime, null);
140
+ next();
141
+ },
142
+ phase: "beforeGlobal"
143
+ }];
144
+ },
145
+ async shutdown() {
146
+ log.info("OTel adapter shutdown");
130
147
  }
131
- if (this.requestCounter) this.requestCounter.add(1, attributes);
132
- if (this.requestDuration) this.requestDuration.record(duration, attributes);
133
- });
134
- }
135
- shouldIgnore(path) {
136
- if (!this.options.ignoreRoutes) return false;
137
- return this.options.ignoreRoutes.some((pattern) => {
138
- if (pattern.endsWith("*")) return path.startsWith(pattern.slice(0, -1));
139
- return path === pattern;
140
- });
141
- }
142
- /**
143
- * Run the configured redactor over an attribute bag. Exposed so
144
- * downstream code that adds attributes directly on a span
145
- * (`span.setAttributes(...)`) can share the same redaction contract.
146
- */
147
- applyRedaction(attrs) {
148
- const out = {};
149
- for (const key of Object.keys(attrs)) out[key] = this.redact(key, attrs[key]);
150
- return out;
151
- }
152
- async shutdown() {
153
- log.info("OTel adapter shutdown");
148
+ };
154
149
  }
155
- };
150
+ });
156
151
  /**
157
152
  * Build an attribute redactor from `sensitiveKeys` + optional
158
153
  * `redactAttribute` override. String keys match case-insensitively
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../src/otel.adapter.ts"],"sourcesContent":["import {\n Logger,\n type AppAdapter,\n type AdapterContext,\n type AdapterMiddleware,\n} from '@forinda/kickjs'\nimport type { Request, Response, NextFunction } from 'express'\nimport type { OtelAdapterOptions } from './types'\n\nconst log = Logger.for('OtelAdapter')\n\n/**\n * OpenTelemetry adapter for KickJS — automatic tracing and metrics.\n *\n * Creates spans for each HTTP request with route, method, status code,\n * and duration. Optionally records request count and latency histograms.\n *\n * Works with any OTel-compatible backend: Jaeger, Grafana Tempo, Datadog,\n * Honeycomb, etc. Configure exporters via the OTel SDK before bootstrapping.\n *\n * @example\n * ```ts\n * import { OtelAdapter } from '@forinda/kickjs-otel'\n *\n * // Set up OTel SDK (e.g., with Jaeger exporter) before bootstrap\n * bootstrap({\n * modules,\n * adapters: [\n * new OtelAdapter({\n * serviceName: 'my-api',\n * serviceVersion: '1.0.0',\n * ignoreRoutes: ['/health', '/_debug/*'],\n * }),\n * ],\n * })\n * ```\n */\nexport class OtelAdapter implements AppAdapter {\n name = 'OtelAdapter'\n private options: Required<\n Pick<OtelAdapterOptions, 'serviceName' | 'serviceVersion' | 'tracing' | 'metrics'>\n > &\n OtelAdapterOptions\n private tracer: any = null\n private meter: any = null\n private requestCounter: any = null\n private requestDuration: any = null\n private readonly redact: (key: string, value: unknown) => unknown\n\n constructor(options: OtelAdapterOptions = {}) {\n this.options = {\n serviceName: options.serviceName ?? 'kickjs-app',\n serviceVersion: options.serviceVersion ?? '0.0.0',\n tracing: options.tracing ?? true,\n metrics: options.metrics ?? true,\n ...options,\n }\n this.redact = buildRedactor(this.options.sensitiveKeys, this.options.redactAttribute)\n }\n\n beforeStart({}: AdapterContext): void {\n try {\n // Dynamically import OTel API — it's a peer dependency\n const otelApi = require('@opentelemetry/api')\n\n if (this.options.tracing) {\n this.tracer = otelApi.trace.getTracer(this.options.serviceName, this.options.serviceVersion)\n log.info(`Tracing enabled for ${this.options.serviceName}`)\n }\n\n if (this.options.metrics) {\n this.meter = otelApi.metrics.getMeter(this.options.serviceName, this.options.serviceVersion)\n\n this.requestCounter = this.meter.createCounter('http.server.request.count', {\n description: 'Total number of HTTP requests',\n })\n\n this.requestDuration = this.meter.createHistogram('http.server.request.duration', {\n description: 'HTTP request duration in milliseconds',\n unit: 'ms',\n })\n\n log.info('Metrics enabled — http.server.request.count, http.server.request.duration')\n }\n } catch {\n log.warn(\n 'OpenTelemetry API not found. Install @opentelemetry/api to enable tracing and metrics.',\n )\n }\n }\n\n middleware(): AdapterMiddleware[] {\n return [\n {\n handler: (req: Request, res: Response, next: NextFunction) => {\n // Skip ignored routes\n if (this.shouldIgnore(req.path)) {\n return next()\n }\n\n const startTime = performance.now()\n\n // Start a span if tracing is enabled\n let span: any = null\n if (this.tracer) {\n const otelApi = require('@opentelemetry/api')\n span = this.tracer.startSpan(`${req.method} ${req.route?.path ?? req.path}`, {\n attributes: this.applyRedaction({\n 'http.method': req.method,\n 'http.url': req.originalUrl,\n 'http.target': req.path,\n 'http.user_agent': req.get('user-agent') ?? '',\n 'net.host.name': req.hostname,\n ...(this.options.customAttributes?.(req) ?? {}),\n }),\n })\n\n // Set span on context so downstream code can add attributes\n const ctx = otelApi.trace.setSpan(otelApi.context.active(), span)\n otelApi.context.with(ctx, () => {\n this.onFinish(req, res, startTime, span)\n next()\n })\n return\n }\n\n this.onFinish(req, res, startTime, null)\n next()\n },\n phase: 'beforeGlobal',\n },\n ]\n }\n\n private onFinish(req: Request, res: Response, startTime: number, span: any): void {\n res.on('finish', () => {\n const duration = performance.now() - startTime\n const route = (req as any).route?.path ?? req.path\n const attributes = {\n 'http.method': req.method,\n 'http.route': route,\n 'http.status_code': res.statusCode,\n }\n\n // End span\n if (span) {\n span.setAttributes(\n this.applyRedaction({\n 'http.status_code': res.statusCode,\n 'http.route': route,\n }),\n )\n if (res.statusCode >= 400) {\n span.setStatus({ code: 2, message: `HTTP ${res.statusCode}` })\n }\n span.end()\n }\n\n // Record metrics\n if (this.requestCounter) {\n this.requestCounter.add(1, attributes)\n }\n if (this.requestDuration) {\n this.requestDuration.record(duration, attributes)\n }\n })\n }\n\n private shouldIgnore(path: string): boolean {\n if (!this.options.ignoreRoutes) return false\n return this.options.ignoreRoutes.some((pattern) => {\n if (pattern.endsWith('*')) {\n return path.startsWith(pattern.slice(0, -1))\n }\n return path === pattern\n })\n }\n\n /**\n * Run the configured redactor over an attribute bag. Exposed so\n * downstream code that adds attributes directly on a span\n * (`span.setAttributes(...)`) can share the same redaction contract.\n */\n applyRedaction<T extends Record<string, unknown>>(attrs: T): Record<string, unknown> {\n const out: Record<string, unknown> = {}\n for (const key of Object.keys(attrs)) {\n out[key] = this.redact(key, attrs[key])\n }\n return out\n }\n\n async shutdown(): Promise<void> {\n log.info('OTel adapter shutdown')\n }\n}\n\n/**\n * Build an attribute redactor from `sensitiveKeys` + optional\n * `redactAttribute` override. String keys match case-insensitively\n * against the attribute name; `RegExp` entries are matched verbatim.\n * A custom `redactAttribute` runs after the key-based mask so users\n * can inspect values too.\n */\nfunction buildRedactor(\n sensitiveKeys: (string | RegExp)[] | undefined,\n custom: ((key: string, value: unknown) => unknown) | undefined,\n): (key: string, value: unknown) => unknown {\n if (custom) return custom\n if (!sensitiveKeys || sensitiveKeys.length === 0) return (_k, v) => v\n\n const lowered = new Set<string>()\n const patterns: RegExp[] = []\n for (const entry of sensitiveKeys) {\n if (typeof entry === 'string') lowered.add(entry.toLowerCase())\n else patterns.push(entry)\n }\n\n return (key: string, value: unknown) => {\n const lower = key.toLowerCase()\n if (lowered.has(lower)) return '[REDACTED]'\n for (const p of patterns) {\n if (p.test(key)) return '[REDACTED]'\n }\n return value\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/otel.adapter.ts"],"sourcesContent":["import { Logger, defineAdapter, type AdapterMiddleware } from '@forinda/kickjs'\nimport type { Request, Response, NextFunction } from 'express'\nimport type { OtelAdapterOptions } from './types'\n\nconst log = Logger.for('OtelAdapter')\n\n/**\n * Public extension methods exposed by an OtelAdapter instance beyond\n * the standard {@link AppAdapter} contract. Surfaced via `TExtra` on\n * `defineAdapter` so peer adapters / tests / downstream code that\n * adds attributes directly on a span can share the redaction contract.\n */\nexport interface OtelAdapterExtensions {\n /**\n * Run the configured redactor over an attribute bag. Useful for code\n * that adds attributes directly on a span (`span.setAttributes(...)`)\n * outside the request middleware path — applies the same key matching\n * (case-insensitive strings, regex patterns) declared on the adapter.\n */\n applyRedaction<T extends Record<string, unknown>>(attrs: T): Record<string, unknown>\n}\n\n/**\n * OpenTelemetry adapter for KickJS — automatic tracing and metrics.\n *\n * Creates spans for each HTTP request with route, method, status code,\n * and duration. Optionally records request count and latency histograms.\n *\n * Works with any OTel-compatible backend: Jaeger, Grafana Tempo, Datadog,\n * Honeycomb, etc. Configure exporters via the OTel SDK before bootstrapping.\n *\n * @example\n * ```ts\n * import { OtelAdapter } from '@forinda/kickjs-otel'\n *\n * // Set up OTel SDK (e.g., with Jaeger exporter) before bootstrap\n * bootstrap({\n * modules,\n * adapters: [\n * OtelAdapter({\n * serviceName: 'my-api',\n * serviceVersion: '1.0.0',\n * ignoreRoutes: ['/health', '/_debug/*'],\n * }),\n * ],\n * })\n * ```\n */\nexport const OtelAdapter = defineAdapter<OtelAdapterOptions, OtelAdapterExtensions>({\n name: 'OtelAdapter',\n defaults: {\n serviceName: 'kickjs-app',\n serviceVersion: '0.0.0',\n tracing: true,\n metrics: true,\n },\n build: (options) => {\n const redact = buildRedactor(options.sensitiveKeys, options.redactAttribute)\n\n let tracer: any = null\n let meter: any = null\n let requestCounter: any = null\n let requestDuration: any = null\n\n const applyRedaction = <T extends Record<string, unknown>>(\n attrs: T,\n ): Record<string, unknown> => {\n const out: Record<string, unknown> = {}\n for (const key of Object.keys(attrs)) {\n out[key] = redact(key, attrs[key])\n }\n return out\n }\n\n const shouldIgnore = (path: string): boolean => {\n if (!options.ignoreRoutes) return false\n return options.ignoreRoutes.some((pattern) => {\n if (pattern.endsWith('*')) {\n return path.startsWith(pattern.slice(0, -1))\n }\n return path === pattern\n })\n }\n\n const onFinish = (req: Request, res: Response, startTime: number, span: any): void => {\n res.on('finish', () => {\n const duration = performance.now() - startTime\n const route = (req as any).route?.path ?? req.path\n const attributes = {\n 'http.method': req.method,\n 'http.route': route,\n 'http.status_code': res.statusCode,\n }\n\n if (span) {\n span.setAttributes(\n applyRedaction({\n 'http.status_code': res.statusCode,\n 'http.route': route,\n }),\n )\n if (res.statusCode >= 400) {\n span.setStatus({ code: 2, message: `HTTP ${res.statusCode}` })\n }\n span.end()\n }\n\n if (requestCounter) {\n requestCounter.add(1, attributes)\n }\n if (requestDuration) {\n requestDuration.record(duration, attributes)\n }\n })\n }\n\n return {\n applyRedaction,\n\n beforeStart() {\n try {\n // Dynamically import OTel API — it's a peer dependency\n const otelApi = require('@opentelemetry/api')\n\n if (options.tracing) {\n tracer = otelApi.trace.getTracer(options.serviceName!, options.serviceVersion!)\n log.info(`Tracing enabled for ${options.serviceName}`)\n }\n\n if (options.metrics) {\n meter = otelApi.metrics.getMeter(options.serviceName!, options.serviceVersion!)\n\n requestCounter = meter.createCounter('http.server.request.count', {\n description: 'Total number of HTTP requests',\n })\n\n requestDuration = meter.createHistogram('http.server.request.duration', {\n description: 'HTTP request duration in milliseconds',\n unit: 'ms',\n })\n\n log.info('Metrics enabled — http.server.request.count, http.server.request.duration')\n }\n } catch {\n log.warn(\n 'OpenTelemetry API not found. Install @opentelemetry/api to enable tracing and metrics.',\n )\n }\n },\n\n middleware(): AdapterMiddleware[] {\n return [\n {\n handler: (req: Request, res: Response, next: NextFunction) => {\n if (shouldIgnore(req.path)) {\n return next()\n }\n\n const startTime = performance.now()\n\n let span: any = null\n if (tracer) {\n const otelApi = require('@opentelemetry/api')\n span = tracer.startSpan(`${req.method} ${req.route?.path ?? req.path}`, {\n attributes: applyRedaction({\n 'http.method': req.method,\n 'http.url': req.originalUrl,\n 'http.target': req.path,\n 'http.user_agent': req.get('user-agent') ?? '',\n 'net.host.name': req.hostname,\n ...(options.customAttributes?.(req) ?? {}),\n }),\n })\n\n const ctx = otelApi.trace.setSpan(otelApi.context.active(), span)\n otelApi.context.with(ctx, () => {\n onFinish(req, res, startTime, span)\n next()\n })\n return\n }\n\n onFinish(req, res, startTime, null)\n next()\n },\n phase: 'beforeGlobal',\n },\n ]\n },\n\n async shutdown() {\n log.info('OTel adapter shutdown')\n },\n }\n },\n})\n\n/**\n * Build an attribute redactor from `sensitiveKeys` + optional\n * `redactAttribute` override. String keys match case-insensitively\n * against the attribute name; `RegExp` entries are matched verbatim.\n * A custom `redactAttribute` runs after the key-based mask so users\n * can inspect values too.\n */\nfunction buildRedactor(\n sensitiveKeys: (string | RegExp)[] | undefined,\n custom: ((key: string, value: unknown) => unknown) | undefined,\n): (key: string, value: unknown) => unknown {\n if (custom) return custom\n if (!sensitiveKeys || sensitiveKeys.length === 0) return (_k, v) => v\n\n const lowered = new Set<string>()\n const patterns: RegExp[] = []\n for (const entry of sensitiveKeys) {\n if (typeof entry === 'string') lowered.add(entry.toLowerCase())\n else patterns.push(entry)\n }\n\n return (key: string, value: unknown) => {\n const lower = key.toLowerCase()\n if (lowered.has(lower)) return '[REDACTED]'\n for (const p of patterns) {\n if (p.test(key)) return '[REDACTED]'\n }\n return value\n }\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forinda/kickjs-otel",
3
- "version": "3.1.3",
3
+ "version": "4.0.0",
4
4
  "description": "OpenTelemetry adapter for KickJS — automatic tracing, metrics, and export to any OTel backend",
5
5
  "keywords": [
6
6
  "kickjs",
@@ -24,13 +24,10 @@
24
24
  "@forinda/kickjs",
25
25
  "@forinda/kickjs-auth",
26
26
  "@forinda/kickjs-cli",
27
- "@forinda/kickjs-config",
28
- "@forinda/kickjs-core",
29
27
  "@forinda/kickjs-cron",
30
28
  "@forinda/kickjs-devtools",
31
29
  "@forinda/kickjs-drizzle",
32
30
  "@forinda/kickjs-graphql",
33
- "@forinda/kickjs-http",
34
31
  "@forinda/kickjs-mailer",
35
32
  "@forinda/kickjs-multi-tenant",
36
33
  "@forinda/kickjs-notifications",
@@ -66,10 +63,7 @@
66
63
  "output": [
67
64
  "dist/**"
68
65
  ],
69
- "dependencies": [
70
- "../core:build",
71
- "../http:build"
72
- ]
66
+ "dependencies": []
73
67
  }
74
68
  },
75
69
  "dependencies": {
@@ -96,14 +90,14 @@
96
90
  },
97
91
  "devDependencies": {
98
92
  "@opentelemetry/api": "^1.9.1",
99
- "@opentelemetry/sdk-metrics": "^2.6.1",
100
- "@opentelemetry/sdk-trace-base": "^2.6.1",
93
+ "@opentelemetry/sdk-metrics": "^2.7.0",
94
+ "@opentelemetry/sdk-trace-base": "^2.7.0",
101
95
  "@opentelemetry/semantic-conventions": "^1.4.0",
102
- "@types/node": "^25.0.0",
103
- "typescript": "^5.9.2",
96
+ "@types/node": "^25.6.0",
97
+ "typescript": "^6.0.3",
104
98
  "unplugin-swc": "^1.5.9",
105
- "vitest": "^4.1.2",
106
- "@forinda/kickjs": "3.1.3"
99
+ "vitest": "^4.1.5",
100
+ "@forinda/kickjs": "4.0.0"
107
101
  },
108
102
  "publishConfig": {
109
103
  "access": "public"