@nixxie-cms/telemetry 1.0.1
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/LICENSE +23 -0
- package/README.md +36 -0
- package/dist/declarations/src/index.d.ts +39 -0
- package/dist/declarations/src/index.d.ts.map +1 -0
- package/dist/declarations/src/metrics.d.ts +27 -0
- package/dist/declarations/src/metrics.d.ts.map +1 -0
- package/dist/declarations/src/types.d.ts +13 -0
- package/dist/declarations/src/types.d.ts.map +1 -0
- package/dist/nixxie-cms-telemetry.cjs.d.ts +2 -0
- package/dist/nixxie-cms-telemetry.cjs.js +221 -0
- package/dist/nixxie-cms-telemetry.esm.js +215 -0
- package/package.json +37 -0
- package/src/index.ts +115 -0
- package/src/metrics.ts +134 -0
- package/src/types.ts +11 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nixxie International DMCC
|
|
4
|
+
Portions Copyright (c) 2023 Thinkmill Labs Pty Ltd and contributors
|
|
5
|
+
(this software is derived from the KeystoneJS project, https://keystonejs.com)
|
|
6
|
+
|
|
7
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
8
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
9
|
+
in the Software without restriction, including without limitation the rights
|
|
10
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
11
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
12
|
+
furnished to do so, subject to the following conditions:
|
|
13
|
+
|
|
14
|
+
The above copyright notice and this permission notice shall be included in all
|
|
15
|
+
copies or substantial portions of the Software.
|
|
16
|
+
|
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
18
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
19
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
20
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
21
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
22
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
23
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# @nixxie-cms/telemetry
|
|
2
|
+
|
|
3
|
+
Observability for Nixxie CMS: a zero-dependency **Prometheus** metrics registry plus optional
|
|
4
|
+
**OpenTelemetry** tracing. Complements `@nixxie-cms/health`.
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
import { createTelemetry } from '@nixxie-cms/telemetry'
|
|
8
|
+
|
|
9
|
+
export const telemetry = createTelemetry({
|
|
10
|
+
serviceName: 'my-app',
|
|
11
|
+
tracing: { otlpEndpoint: process.env.OTLP_ENDPOINT }, // optional
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
export default config({
|
|
15
|
+
server: {
|
|
16
|
+
extendExpressApp: app => {
|
|
17
|
+
app.use(telemetry.httpMiddleware())
|
|
18
|
+
app.get('/metrics', telemetry.metricsHandler())
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
})
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Custom metrics:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
const published = telemetry.metrics.counter('posts_published_total', 'Posts published')
|
|
28
|
+
published.inc({ author: 'alice' })
|
|
29
|
+
|
|
30
|
+
await telemetry.span('rebuild-search-index', async () => {
|
|
31
|
+
// traced when OpenTelemetry is enabled, a plain call otherwise
|
|
32
|
+
})
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Tracing requires `@opentelemetry/sdk-node` and `@opentelemetry/api` (loaded lazily). Metrics need
|
|
36
|
+
nothing extra.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { MetricsRegistry } from "./metrics.js";
|
|
2
|
+
import type { MetricLabels, TelemetryConfig } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Observability for Nixxie CMS: a zero-dependency Prometheus metrics registry plus optional
|
|
5
|
+
* OpenTelemetry tracing (loaded lazily when `@opentelemetry/*` is installed).
|
|
6
|
+
*
|
|
7
|
+
* Telemetry is set up at process start and wired into Express yourself — it is intentionally not
|
|
8
|
+
* placed on `context.services` (that slot would collide with the core `telemetry` boolean flag).
|
|
9
|
+
*/
|
|
10
|
+
export declare class Telemetry {
|
|
11
|
+
readonly metrics: MetricsRegistry;
|
|
12
|
+
readonly serviceName: string;
|
|
13
|
+
private config;
|
|
14
|
+
private sdk;
|
|
15
|
+
private tracer;
|
|
16
|
+
constructor(config?: TelemetryConfig);
|
|
17
|
+
/** Initialise tracing (if enabled). Safe to call once at startup. */
|
|
18
|
+
start(): Promise<void>;
|
|
19
|
+
/** Run `fn` inside a span. No-ops (just runs `fn`) when tracing is disabled. */
|
|
20
|
+
span<T>(name: string, fn: () => Promise<T> | T, attributes?: MetricLabels): Promise<T>;
|
|
21
|
+
/** Prometheus text exposition of all metrics. */
|
|
22
|
+
exposeMetrics(): string;
|
|
23
|
+
/**
|
|
24
|
+
* Express handler serving metrics. Mount in `extendExpressApp`:
|
|
25
|
+
* `app.get('/metrics', telemetry.metricsHandler())`
|
|
26
|
+
*/
|
|
27
|
+
metricsHandler(): (req: unknown, res: any) => void;
|
|
28
|
+
/**
|
|
29
|
+
* Express middleware recording request count + duration histograms, labelled by method/route/status.
|
|
30
|
+
*/
|
|
31
|
+
httpMiddleware(): (req: any, res: any, next: () => void) => void;
|
|
32
|
+
/** Shut tracing down cleanly. */
|
|
33
|
+
shutdown(): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
export declare function createTelemetry(config?: TelemetryConfig): Telemetry;
|
|
36
|
+
export { MetricsRegistry } from "./metrics.js";
|
|
37
|
+
export type { Metric } from "./metrics.js";
|
|
38
|
+
export type { TelemetryConfig, MetricLabels } from "./types.js";
|
|
39
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"../../../src","sources":["index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,qBAAiB;AAC3C,OAAO,KAAK,EAAE,YAAY,EAAE,eAAe,EAAE,mBAAe;AAE5D;;;;;;GAMG;AACH,qBAAa,SAAS;IACpB,QAAQ,CAAC,OAAO,kBAAwB;IACxC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,OAAO,CAAC,MAAM,CAAiB;IAC/B,OAAO,CAAC,GAAG,CAAK;IAChB,OAAO,CAAC,MAAM,CAAK;gBAEP,MAAM,GAAE,eAAoB;IAKxC,qEAAqE;IAC/D,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAsB5B,gFAAgF;IAC1E,IAAI,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,UAAU,CAAC,EAAE,YAAY,GAAG,OAAO,CAAC,CAAC,CAAC;IAgB5F,iDAAiD;IACjD,aAAa,IAAI,MAAM;IAIvB;;;OAGG;IACH,cAAc,IAAI,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI;IAOlD;;OAEG;IACH,cAAc,IAAI,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,IAAI,KAAK,IAAI;IAqBhE,iCAAiC;IAC3B,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;CAGhC;AAED,wBAAgB,eAAe,CAAC,MAAM,CAAC,EAAE,eAAe,GAAG,SAAS,CAEnE;AAED,OAAO,EAAE,eAAe,EAAE,qBAAiB;AAC3C,YAAY,EAAE,MAAM,EAAE,qBAAiB;AACvC,YAAY,EAAE,eAAe,EAAE,YAAY,EAAE,mBAAe"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { MetricLabels } from "./types.js";
|
|
2
|
+
type MetricKind = 'counter' | 'gauge' | 'histogram';
|
|
3
|
+
declare class Metric {
|
|
4
|
+
readonly name: string;
|
|
5
|
+
readonly help: string;
|
|
6
|
+
readonly kind: MetricKind;
|
|
7
|
+
private buckets;
|
|
8
|
+
private series;
|
|
9
|
+
constructor(name: string, kind: MetricKind, help: string, buckets?: number[]);
|
|
10
|
+
private seriesFor;
|
|
11
|
+
inc(labels?: MetricLabels, amount?: number): void;
|
|
12
|
+
set(value: number, labels?: MetricLabels): void;
|
|
13
|
+
observe(value: number, labels?: MetricLabels): void;
|
|
14
|
+
expose(): string;
|
|
15
|
+
}
|
|
16
|
+
/** Minimal Prometheus-compatible metrics registry (no dependencies). */
|
|
17
|
+
export declare class MetricsRegistry {
|
|
18
|
+
private metrics;
|
|
19
|
+
counter(name: string, help?: string): Metric;
|
|
20
|
+
gauge(name: string, help?: string): Metric;
|
|
21
|
+
histogram(name: string, help?: string, buckets?: number[]): Metric;
|
|
22
|
+
private getOrCreate;
|
|
23
|
+
/** Render the full registry in Prometheus text exposition format. */
|
|
24
|
+
expose(): string;
|
|
25
|
+
}
|
|
26
|
+
export type { Metric };
|
|
27
|
+
//# sourceMappingURL=metrics.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"metrics.d.ts","sourceRoot":"../../../src","sources":["metrics.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,mBAAe;AAE3C,KAAK,UAAU,GAAG,SAAS,GAAG,OAAO,GAAG,WAAW,CAAA;AAsBnD,cAAM,MAAM;IACV,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAA;IACzB,OAAO,CAAC,OAAO,CAAU;IACzB,OAAO,CAAC,MAAM,CAA4B;gBAE9B,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,EAAE;IAO5E,OAAO,CAAC,SAAS;IAoBjB,GAAG,CAAC,MAAM,CAAC,EAAE,YAAY,EAAE,MAAM,SAAI,GAAG,IAAI;IAI5C,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,YAAY,GAAG,IAAI;IAI/C,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,YAAY,GAAG,IAAI;IASnD,MAAM,IAAI,MAAM;CAmBjB;AAED,wEAAwE;AACxE,qBAAa,eAAe;IAC1B,OAAO,CAAC,OAAO,CAA4B;IAE3C,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,SAAO,GAAG,MAAM;IAI1C,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,SAAO,GAAG,MAAM;IAIxC,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,SAAO,EAAE,OAAO,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM;IAIhE,OAAO,CAAC,WAAW;IAenB,qEAAqE;IACrE,MAAM,IAAI,MAAM;CAGjB;AAED,YAAY,EAAE,MAAM,EAAE,CAAA"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type TelemetryConfig = {
|
|
2
|
+
/** Logical service name attached to traces and the `service` metric label. */
|
|
3
|
+
serviceName?: string;
|
|
4
|
+
/**
|
|
5
|
+
* Enable OpenTelemetry tracing. Requires `@opentelemetry/api` and `@opentelemetry/sdk-node`.
|
|
6
|
+
* Provide an OTLP endpoint to export spans, or `true` for a no-op/console setup.
|
|
7
|
+
*/
|
|
8
|
+
tracing?: boolean | {
|
|
9
|
+
otlpEndpoint?: string;
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
export type MetricLabels = Record<string, string | number>;
|
|
13
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"../../../src","sources":["types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,eAAe,GAAG;IAC5B,8EAA8E;IAC9E,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,GAAG;QAAE,YAAY,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAC9C,CAAA;AAED,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAAA"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export * from "./declarations/src/index.js";
|
|
2
|
+
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoibml4eGllLWNtcy10ZWxlbWV0cnkuY2pzLmQudHMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuL2RlY2xhcmF0aW9ucy9zcmMvaW5kZXguZC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSJ9
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
+
|
|
5
|
+
var _defineProperty = require('@babel/runtime/helpers/defineProperty');
|
|
6
|
+
|
|
7
|
+
const DEFAULT_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];
|
|
8
|
+
function labelKey(labels) {
|
|
9
|
+
if (!labels) return '';
|
|
10
|
+
return Object.keys(labels).sort().map(k => `${k}="${String(labels[k]).replace(/"/g, '\\"')}"`).join(',');
|
|
11
|
+
}
|
|
12
|
+
class Metric {
|
|
13
|
+
constructor(name, kind, help, buckets) {
|
|
14
|
+
_defineProperty(this, "series", new Map());
|
|
15
|
+
this.name = name;
|
|
16
|
+
this.kind = kind;
|
|
17
|
+
this.help = help;
|
|
18
|
+
this.buckets = buckets !== null && buckets !== void 0 ? buckets : DEFAULT_BUCKETS;
|
|
19
|
+
}
|
|
20
|
+
seriesFor(labels) {
|
|
21
|
+
const key = labelKey(labels);
|
|
22
|
+
let s = this.series.get(key);
|
|
23
|
+
if (!s) {
|
|
24
|
+
s = this.kind === 'histogram' ? {
|
|
25
|
+
labels,
|
|
26
|
+
value: 0,
|
|
27
|
+
buckets: this.buckets,
|
|
28
|
+
bucketCounts: new Array(this.buckets.length).fill(0),
|
|
29
|
+
sum: 0,
|
|
30
|
+
count: 0
|
|
31
|
+
} : {
|
|
32
|
+
labels,
|
|
33
|
+
value: 0
|
|
34
|
+
};
|
|
35
|
+
this.series.set(key, s);
|
|
36
|
+
}
|
|
37
|
+
return s;
|
|
38
|
+
}
|
|
39
|
+
inc(labels, amount = 1) {
|
|
40
|
+
this.seriesFor(labels).value += amount;
|
|
41
|
+
}
|
|
42
|
+
set(value, labels) {
|
|
43
|
+
this.seriesFor(labels).value = value;
|
|
44
|
+
}
|
|
45
|
+
observe(value, labels) {
|
|
46
|
+
var _s$sum, _s$count;
|
|
47
|
+
const s = this.seriesFor(labels);
|
|
48
|
+
s.sum = ((_s$sum = s.sum) !== null && _s$sum !== void 0 ? _s$sum : 0) + value;
|
|
49
|
+
s.count = ((_s$count = s.count) !== null && _s$count !== void 0 ? _s$count : 0) + 1;
|
|
50
|
+
this.buckets.forEach((b, i) => {
|
|
51
|
+
if (value <= b) s.bucketCounts[i] += 1;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
expose() {
|
|
55
|
+
const lines = [`# HELP ${this.name} ${this.help}`, `# TYPE ${this.name} ${this.kind}`];
|
|
56
|
+
for (const s of this.series.values()) {
|
|
57
|
+
const base = labelKey(s.labels);
|
|
58
|
+
if (this.kind === 'histogram') {
|
|
59
|
+
this.buckets.forEach((b, i) => {
|
|
60
|
+
const l = [base, `le="${b}"`].filter(Boolean).join(',');
|
|
61
|
+
lines.push(`${this.name}_bucket{${l}} ${s.bucketCounts[i]}`);
|
|
62
|
+
});
|
|
63
|
+
const inf = [base, 'le="+Inf"'].filter(Boolean).join(',');
|
|
64
|
+
lines.push(`${this.name}_bucket{${inf}} ${s.count}`);
|
|
65
|
+
lines.push(`${this.name}_sum${base ? `{${base}}` : ''} ${s.sum}`);
|
|
66
|
+
lines.push(`${this.name}_count${base ? `{${base}}` : ''} ${s.count}`);
|
|
67
|
+
} else {
|
|
68
|
+
lines.push(`${this.name}${base ? `{${base}}` : ''} ${s.value}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return lines.join('\n');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Minimal Prometheus-compatible metrics registry (no dependencies). */
|
|
76
|
+
class MetricsRegistry {
|
|
77
|
+
constructor() {
|
|
78
|
+
_defineProperty(this, "metrics", new Map());
|
|
79
|
+
}
|
|
80
|
+
counter(name, help = name) {
|
|
81
|
+
return this.getOrCreate(name, 'counter', help);
|
|
82
|
+
}
|
|
83
|
+
gauge(name, help = name) {
|
|
84
|
+
return this.getOrCreate(name, 'gauge', help);
|
|
85
|
+
}
|
|
86
|
+
histogram(name, help = name, buckets) {
|
|
87
|
+
return this.getOrCreate(name, 'histogram', help, buckets);
|
|
88
|
+
}
|
|
89
|
+
getOrCreate(name, kind, help, buckets) {
|
|
90
|
+
let m = this.metrics.get(name);
|
|
91
|
+
if (!m) {
|
|
92
|
+
m = new Metric(name, kind, help, buckets);
|
|
93
|
+
this.metrics.set(name, m);
|
|
94
|
+
} else if (m.kind !== kind) {
|
|
95
|
+
// Returning a counter when a histogram was requested (or vice-versa) produces malformed
|
|
96
|
+
// Prometheus output (NaN/undefined bucket lines). Fail fast on the conflicting registration.
|
|
97
|
+
throw new Error(`Metric "${name}" is already registered as a ${m.kind}; cannot redefine it as a ${kind}`);
|
|
98
|
+
}
|
|
99
|
+
return m;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Render the full registry in Prometheus text exposition format. */
|
|
103
|
+
expose() {
|
|
104
|
+
return [...this.metrics.values()].map(m => m.expose()).join('\n\n') + '\n';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Observability for Nixxie CMS: a zero-dependency Prometheus metrics registry plus optional
|
|
110
|
+
* OpenTelemetry tracing (loaded lazily when `@opentelemetry/*` is installed).
|
|
111
|
+
*
|
|
112
|
+
* Telemetry is set up at process start and wired into Express yourself — it is intentionally not
|
|
113
|
+
* placed on `context.services` (that slot would collide with the core `telemetry` boolean flag).
|
|
114
|
+
*/
|
|
115
|
+
class Telemetry {
|
|
116
|
+
constructor(config = {}) {
|
|
117
|
+
var _config$serviceName;
|
|
118
|
+
_defineProperty(this, "metrics", new MetricsRegistry());
|
|
119
|
+
this.config = config;
|
|
120
|
+
this.serviceName = (_config$serviceName = config.serviceName) !== null && _config$serviceName !== void 0 ? _config$serviceName : 'nixxie';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Initialise tracing (if enabled). Safe to call once at startup. */
|
|
124
|
+
async start() {
|
|
125
|
+
if (!this.config.tracing) return;
|
|
126
|
+
try {
|
|
127
|
+
const {
|
|
128
|
+
NodeSDK
|
|
129
|
+
} = require('@opentelemetry/sdk-node');
|
|
130
|
+
const api = require('@opentelemetry/api');
|
|
131
|
+
let traceExporter;
|
|
132
|
+
const endpoint = typeof this.config.tracing === 'object' ? this.config.tracing.otlpEndpoint : undefined;
|
|
133
|
+
if (endpoint) {
|
|
134
|
+
const {
|
|
135
|
+
OTLPTraceExporter
|
|
136
|
+
} = require('@opentelemetry/exporter-trace-otlp-http');
|
|
137
|
+
traceExporter = new OTLPTraceExporter({
|
|
138
|
+
url: endpoint
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
this.sdk = new NodeSDK({
|
|
142
|
+
serviceName: this.serviceName,
|
|
143
|
+
traceExporter
|
|
144
|
+
});
|
|
145
|
+
this.sdk.start();
|
|
146
|
+
this.tracer = api.trace.getTracer(this.serviceName);
|
|
147
|
+
} catch {
|
|
148
|
+
throw new Error('Tracing requires @opentelemetry/sdk-node and @opentelemetry/api. Run: npm install @opentelemetry/sdk-node @opentelemetry/api');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Run `fn` inside a span. No-ops (just runs `fn`) when tracing is disabled. */
|
|
153
|
+
async span(name, fn, attributes) {
|
|
154
|
+
if (!this.tracer) return await fn();
|
|
155
|
+
return this.tracer.startActiveSpan(name, async span => {
|
|
156
|
+
try {
|
|
157
|
+
if (attributes) span.setAttributes(attributes);
|
|
158
|
+
return await fn();
|
|
159
|
+
} catch (err) {
|
|
160
|
+
span.recordException(err);
|
|
161
|
+
span.setStatus({
|
|
162
|
+
code: 2,
|
|
163
|
+
message: err === null || err === void 0 ? void 0 : err.message
|
|
164
|
+
});
|
|
165
|
+
throw err;
|
|
166
|
+
} finally {
|
|
167
|
+
span.end();
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Prometheus text exposition of all metrics. */
|
|
173
|
+
exposeMetrics() {
|
|
174
|
+
return this.metrics.expose();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Express handler serving metrics. Mount in `extendExpressApp`:
|
|
179
|
+
* `app.get('/metrics', telemetry.metricsHandler())`
|
|
180
|
+
*/
|
|
181
|
+
metricsHandler() {
|
|
182
|
+
return (_req, res) => {
|
|
183
|
+
res.setHeader('Content-Type', 'text/plain; version=0.0.4');
|
|
184
|
+
res.end(this.exposeMetrics());
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Express middleware recording request count + duration histograms, labelled by method/route/status.
|
|
190
|
+
*/
|
|
191
|
+
httpMiddleware() {
|
|
192
|
+
const requests = this.metrics.counter('http_requests_total', 'Total HTTP requests');
|
|
193
|
+
const duration = this.metrics.histogram('http_request_duration_seconds', 'HTTP request duration in seconds');
|
|
194
|
+
return (req, res, next) => {
|
|
195
|
+
const start = process.hrtime.bigint();
|
|
196
|
+
res.on('finish', () => {
|
|
197
|
+
var _ref, _req$route$path, _req$route;
|
|
198
|
+
const labels = {
|
|
199
|
+
method: req.method,
|
|
200
|
+
route: (_ref = (_req$route$path = (_req$route = req.route) === null || _req$route === void 0 ? void 0 : _req$route.path) !== null && _req$route$path !== void 0 ? _req$route$path : req.path) !== null && _ref !== void 0 ? _ref : 'unknown',
|
|
201
|
+
status: res.statusCode
|
|
202
|
+
};
|
|
203
|
+
requests.inc(labels);
|
|
204
|
+
duration.observe(Number(process.hrtime.bigint() - start) / 1e9, labels);
|
|
205
|
+
});
|
|
206
|
+
next();
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Shut tracing down cleanly. */
|
|
211
|
+
async shutdown() {
|
|
212
|
+
if (this.sdk) await this.sdk.shutdown();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function createTelemetry(config) {
|
|
216
|
+
return new Telemetry(config);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
exports.MetricsRegistry = MetricsRegistry;
|
|
220
|
+
exports.Telemetry = Telemetry;
|
|
221
|
+
exports.createTelemetry = createTelemetry;
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import _defineProperty from '@babel/runtime/helpers/esm/defineProperty';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];
|
|
4
|
+
function labelKey(labels) {
|
|
5
|
+
if (!labels) return '';
|
|
6
|
+
return Object.keys(labels).sort().map(k => `${k}="${String(labels[k]).replace(/"/g, '\\"')}"`).join(',');
|
|
7
|
+
}
|
|
8
|
+
class Metric {
|
|
9
|
+
constructor(name, kind, help, buckets) {
|
|
10
|
+
_defineProperty(this, "series", new Map());
|
|
11
|
+
this.name = name;
|
|
12
|
+
this.kind = kind;
|
|
13
|
+
this.help = help;
|
|
14
|
+
this.buckets = buckets !== null && buckets !== void 0 ? buckets : DEFAULT_BUCKETS;
|
|
15
|
+
}
|
|
16
|
+
seriesFor(labels) {
|
|
17
|
+
const key = labelKey(labels);
|
|
18
|
+
let s = this.series.get(key);
|
|
19
|
+
if (!s) {
|
|
20
|
+
s = this.kind === 'histogram' ? {
|
|
21
|
+
labels,
|
|
22
|
+
value: 0,
|
|
23
|
+
buckets: this.buckets,
|
|
24
|
+
bucketCounts: new Array(this.buckets.length).fill(0),
|
|
25
|
+
sum: 0,
|
|
26
|
+
count: 0
|
|
27
|
+
} : {
|
|
28
|
+
labels,
|
|
29
|
+
value: 0
|
|
30
|
+
};
|
|
31
|
+
this.series.set(key, s);
|
|
32
|
+
}
|
|
33
|
+
return s;
|
|
34
|
+
}
|
|
35
|
+
inc(labels, amount = 1) {
|
|
36
|
+
this.seriesFor(labels).value += amount;
|
|
37
|
+
}
|
|
38
|
+
set(value, labels) {
|
|
39
|
+
this.seriesFor(labels).value = value;
|
|
40
|
+
}
|
|
41
|
+
observe(value, labels) {
|
|
42
|
+
var _s$sum, _s$count;
|
|
43
|
+
const s = this.seriesFor(labels);
|
|
44
|
+
s.sum = ((_s$sum = s.sum) !== null && _s$sum !== void 0 ? _s$sum : 0) + value;
|
|
45
|
+
s.count = ((_s$count = s.count) !== null && _s$count !== void 0 ? _s$count : 0) + 1;
|
|
46
|
+
this.buckets.forEach((b, i) => {
|
|
47
|
+
if (value <= b) s.bucketCounts[i] += 1;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
expose() {
|
|
51
|
+
const lines = [`# HELP ${this.name} ${this.help}`, `# TYPE ${this.name} ${this.kind}`];
|
|
52
|
+
for (const s of this.series.values()) {
|
|
53
|
+
const base = labelKey(s.labels);
|
|
54
|
+
if (this.kind === 'histogram') {
|
|
55
|
+
this.buckets.forEach((b, i) => {
|
|
56
|
+
const l = [base, `le="${b}"`].filter(Boolean).join(',');
|
|
57
|
+
lines.push(`${this.name}_bucket{${l}} ${s.bucketCounts[i]}`);
|
|
58
|
+
});
|
|
59
|
+
const inf = [base, 'le="+Inf"'].filter(Boolean).join(',');
|
|
60
|
+
lines.push(`${this.name}_bucket{${inf}} ${s.count}`);
|
|
61
|
+
lines.push(`${this.name}_sum${base ? `{${base}}` : ''} ${s.sum}`);
|
|
62
|
+
lines.push(`${this.name}_count${base ? `{${base}}` : ''} ${s.count}`);
|
|
63
|
+
} else {
|
|
64
|
+
lines.push(`${this.name}${base ? `{${base}}` : ''} ${s.value}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return lines.join('\n');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Minimal Prometheus-compatible metrics registry (no dependencies). */
|
|
72
|
+
class MetricsRegistry {
|
|
73
|
+
constructor() {
|
|
74
|
+
_defineProperty(this, "metrics", new Map());
|
|
75
|
+
}
|
|
76
|
+
counter(name, help = name) {
|
|
77
|
+
return this.getOrCreate(name, 'counter', help);
|
|
78
|
+
}
|
|
79
|
+
gauge(name, help = name) {
|
|
80
|
+
return this.getOrCreate(name, 'gauge', help);
|
|
81
|
+
}
|
|
82
|
+
histogram(name, help = name, buckets) {
|
|
83
|
+
return this.getOrCreate(name, 'histogram', help, buckets);
|
|
84
|
+
}
|
|
85
|
+
getOrCreate(name, kind, help, buckets) {
|
|
86
|
+
let m = this.metrics.get(name);
|
|
87
|
+
if (!m) {
|
|
88
|
+
m = new Metric(name, kind, help, buckets);
|
|
89
|
+
this.metrics.set(name, m);
|
|
90
|
+
} else if (m.kind !== kind) {
|
|
91
|
+
// Returning a counter when a histogram was requested (or vice-versa) produces malformed
|
|
92
|
+
// Prometheus output (NaN/undefined bucket lines). Fail fast on the conflicting registration.
|
|
93
|
+
throw new Error(`Metric "${name}" is already registered as a ${m.kind}; cannot redefine it as a ${kind}`);
|
|
94
|
+
}
|
|
95
|
+
return m;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Render the full registry in Prometheus text exposition format. */
|
|
99
|
+
expose() {
|
|
100
|
+
return [...this.metrics.values()].map(m => m.expose()).join('\n\n') + '\n';
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Observability for Nixxie CMS: a zero-dependency Prometheus metrics registry plus optional
|
|
106
|
+
* OpenTelemetry tracing (loaded lazily when `@opentelemetry/*` is installed).
|
|
107
|
+
*
|
|
108
|
+
* Telemetry is set up at process start and wired into Express yourself — it is intentionally not
|
|
109
|
+
* placed on `context.services` (that slot would collide with the core `telemetry` boolean flag).
|
|
110
|
+
*/
|
|
111
|
+
class Telemetry {
|
|
112
|
+
constructor(config = {}) {
|
|
113
|
+
var _config$serviceName;
|
|
114
|
+
_defineProperty(this, "metrics", new MetricsRegistry());
|
|
115
|
+
this.config = config;
|
|
116
|
+
this.serviceName = (_config$serviceName = config.serviceName) !== null && _config$serviceName !== void 0 ? _config$serviceName : 'nixxie';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Initialise tracing (if enabled). Safe to call once at startup. */
|
|
120
|
+
async start() {
|
|
121
|
+
if (!this.config.tracing) return;
|
|
122
|
+
try {
|
|
123
|
+
const {
|
|
124
|
+
NodeSDK
|
|
125
|
+
} = require('@opentelemetry/sdk-node');
|
|
126
|
+
const api = require('@opentelemetry/api');
|
|
127
|
+
let traceExporter;
|
|
128
|
+
const endpoint = typeof this.config.tracing === 'object' ? this.config.tracing.otlpEndpoint : undefined;
|
|
129
|
+
if (endpoint) {
|
|
130
|
+
const {
|
|
131
|
+
OTLPTraceExporter
|
|
132
|
+
} = require('@opentelemetry/exporter-trace-otlp-http');
|
|
133
|
+
traceExporter = new OTLPTraceExporter({
|
|
134
|
+
url: endpoint
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
this.sdk = new NodeSDK({
|
|
138
|
+
serviceName: this.serviceName,
|
|
139
|
+
traceExporter
|
|
140
|
+
});
|
|
141
|
+
this.sdk.start();
|
|
142
|
+
this.tracer = api.trace.getTracer(this.serviceName);
|
|
143
|
+
} catch {
|
|
144
|
+
throw new Error('Tracing requires @opentelemetry/sdk-node and @opentelemetry/api. Run: npm install @opentelemetry/sdk-node @opentelemetry/api');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Run `fn` inside a span. No-ops (just runs `fn`) when tracing is disabled. */
|
|
149
|
+
async span(name, fn, attributes) {
|
|
150
|
+
if (!this.tracer) return await fn();
|
|
151
|
+
return this.tracer.startActiveSpan(name, async span => {
|
|
152
|
+
try {
|
|
153
|
+
if (attributes) span.setAttributes(attributes);
|
|
154
|
+
return await fn();
|
|
155
|
+
} catch (err) {
|
|
156
|
+
span.recordException(err);
|
|
157
|
+
span.setStatus({
|
|
158
|
+
code: 2,
|
|
159
|
+
message: err === null || err === void 0 ? void 0 : err.message
|
|
160
|
+
});
|
|
161
|
+
throw err;
|
|
162
|
+
} finally {
|
|
163
|
+
span.end();
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Prometheus text exposition of all metrics. */
|
|
169
|
+
exposeMetrics() {
|
|
170
|
+
return this.metrics.expose();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Express handler serving metrics. Mount in `extendExpressApp`:
|
|
175
|
+
* `app.get('/metrics', telemetry.metricsHandler())`
|
|
176
|
+
*/
|
|
177
|
+
metricsHandler() {
|
|
178
|
+
return (_req, res) => {
|
|
179
|
+
res.setHeader('Content-Type', 'text/plain; version=0.0.4');
|
|
180
|
+
res.end(this.exposeMetrics());
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Express middleware recording request count + duration histograms, labelled by method/route/status.
|
|
186
|
+
*/
|
|
187
|
+
httpMiddleware() {
|
|
188
|
+
const requests = this.metrics.counter('http_requests_total', 'Total HTTP requests');
|
|
189
|
+
const duration = this.metrics.histogram('http_request_duration_seconds', 'HTTP request duration in seconds');
|
|
190
|
+
return (req, res, next) => {
|
|
191
|
+
const start = process.hrtime.bigint();
|
|
192
|
+
res.on('finish', () => {
|
|
193
|
+
var _ref, _req$route$path, _req$route;
|
|
194
|
+
const labels = {
|
|
195
|
+
method: req.method,
|
|
196
|
+
route: (_ref = (_req$route$path = (_req$route = req.route) === null || _req$route === void 0 ? void 0 : _req$route.path) !== null && _req$route$path !== void 0 ? _req$route$path : req.path) !== null && _ref !== void 0 ? _ref : 'unknown',
|
|
197
|
+
status: res.statusCode
|
|
198
|
+
};
|
|
199
|
+
requests.inc(labels);
|
|
200
|
+
duration.observe(Number(process.hrtime.bigint() - start) / 1e9, labels);
|
|
201
|
+
});
|
|
202
|
+
next();
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Shut tracing down cleanly. */
|
|
207
|
+
async shutdown() {
|
|
208
|
+
if (this.sdk) await this.sdk.shutdown();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function createTelemetry(config) {
|
|
212
|
+
return new Telemetry(config);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export { MetricsRegistry, Telemetry, createTelemetry };
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nixxie-cms/telemetry",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"main": "dist/nixxie-cms-telemetry.cjs.js",
|
|
6
|
+
"module": "dist/nixxie-cms-telemetry.esm.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/nixxie-cms-telemetry.cjs.js",
|
|
10
|
+
"module": "./dist/nixxie-cms-telemetry.esm.js",
|
|
11
|
+
"default": "./dist/nixxie-cms-telemetry.cjs.js"
|
|
12
|
+
},
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@babel/runtime": "^7.24.7"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@nixxie-cms/core": "^1.0.1"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"@nixxie-cms/core": "^1.0.1"
|
|
23
|
+
},
|
|
24
|
+
"optionalDependencies": {
|
|
25
|
+
"@opentelemetry/api": "^1.9.0",
|
|
26
|
+
"@opentelemetry/sdk-node": "^0.57.0"
|
|
27
|
+
},
|
|
28
|
+
"preconstruct": {
|
|
29
|
+
"entrypoints": [
|
|
30
|
+
"index.ts"
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "https://github.com/nixxiecms/nixxie/tree/main/packages/telemetry"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { MetricsRegistry } from './metrics'
|
|
2
|
+
import type { MetricLabels, TelemetryConfig } from './types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Observability for Nixxie CMS: a zero-dependency Prometheus metrics registry plus optional
|
|
6
|
+
* OpenTelemetry tracing (loaded lazily when `@opentelemetry/*` is installed).
|
|
7
|
+
*
|
|
8
|
+
* Telemetry is set up at process start and wired into Express yourself — it is intentionally not
|
|
9
|
+
* placed on `context.services` (that slot would collide with the core `telemetry` boolean flag).
|
|
10
|
+
*/
|
|
11
|
+
export class Telemetry {
|
|
12
|
+
readonly metrics = new MetricsRegistry()
|
|
13
|
+
readonly serviceName: string
|
|
14
|
+
private config: TelemetryConfig
|
|
15
|
+
private sdk: any
|
|
16
|
+
private tracer: any
|
|
17
|
+
|
|
18
|
+
constructor(config: TelemetryConfig = {}) {
|
|
19
|
+
this.config = config
|
|
20
|
+
this.serviceName = config.serviceName ?? 'nixxie'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Initialise tracing (if enabled). Safe to call once at startup. */
|
|
24
|
+
async start(): Promise<void> {
|
|
25
|
+
if (!this.config.tracing) return
|
|
26
|
+
try {
|
|
27
|
+
const { NodeSDK } = require('@opentelemetry/sdk-node')
|
|
28
|
+
const api = require('@opentelemetry/api')
|
|
29
|
+
let traceExporter: any
|
|
30
|
+
const endpoint =
|
|
31
|
+
typeof this.config.tracing === 'object' ? this.config.tracing.otlpEndpoint : undefined
|
|
32
|
+
if (endpoint) {
|
|
33
|
+
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http')
|
|
34
|
+
traceExporter = new OTLPTraceExporter({ url: endpoint })
|
|
35
|
+
}
|
|
36
|
+
this.sdk = new NodeSDK({ serviceName: this.serviceName, traceExporter })
|
|
37
|
+
this.sdk.start()
|
|
38
|
+
this.tracer = api.trace.getTracer(this.serviceName)
|
|
39
|
+
} catch {
|
|
40
|
+
throw new Error(
|
|
41
|
+
'Tracing requires @opentelemetry/sdk-node and @opentelemetry/api. Run: npm install @opentelemetry/sdk-node @opentelemetry/api'
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Run `fn` inside a span. No-ops (just runs `fn`) when tracing is disabled. */
|
|
47
|
+
async span<T>(name: string, fn: () => Promise<T> | T, attributes?: MetricLabels): Promise<T> {
|
|
48
|
+
if (!this.tracer) return await fn()
|
|
49
|
+
return this.tracer.startActiveSpan(name, async (span: any) => {
|
|
50
|
+
try {
|
|
51
|
+
if (attributes) span.setAttributes(attributes)
|
|
52
|
+
return await fn()
|
|
53
|
+
} catch (err: any) {
|
|
54
|
+
span.recordException(err)
|
|
55
|
+
span.setStatus({ code: 2, message: err?.message })
|
|
56
|
+
throw err
|
|
57
|
+
} finally {
|
|
58
|
+
span.end()
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Prometheus text exposition of all metrics. */
|
|
64
|
+
exposeMetrics(): string {
|
|
65
|
+
return this.metrics.expose()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Express handler serving metrics. Mount in `extendExpressApp`:
|
|
70
|
+
* `app.get('/metrics', telemetry.metricsHandler())`
|
|
71
|
+
*/
|
|
72
|
+
metricsHandler(): (req: unknown, res: any) => void {
|
|
73
|
+
return (_req, res) => {
|
|
74
|
+
res.setHeader('Content-Type', 'text/plain; version=0.0.4')
|
|
75
|
+
res.end(this.exposeMetrics())
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Express middleware recording request count + duration histograms, labelled by method/route/status.
|
|
81
|
+
*/
|
|
82
|
+
httpMiddleware(): (req: any, res: any, next: () => void) => void {
|
|
83
|
+
const requests = this.metrics.counter('http_requests_total', 'Total HTTP requests')
|
|
84
|
+
const duration = this.metrics.histogram(
|
|
85
|
+
'http_request_duration_seconds',
|
|
86
|
+
'HTTP request duration in seconds'
|
|
87
|
+
)
|
|
88
|
+
return (req, res, next) => {
|
|
89
|
+
const start = process.hrtime.bigint()
|
|
90
|
+
res.on('finish', () => {
|
|
91
|
+
const labels: MetricLabels = {
|
|
92
|
+
method: req.method,
|
|
93
|
+
route: req.route?.path ?? req.path ?? 'unknown',
|
|
94
|
+
status: res.statusCode,
|
|
95
|
+
}
|
|
96
|
+
requests.inc(labels)
|
|
97
|
+
duration.observe(Number(process.hrtime.bigint() - start) / 1e9, labels)
|
|
98
|
+
})
|
|
99
|
+
next()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Shut tracing down cleanly. */
|
|
104
|
+
async shutdown(): Promise<void> {
|
|
105
|
+
if (this.sdk) await this.sdk.shutdown()
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function createTelemetry(config?: TelemetryConfig): Telemetry {
|
|
110
|
+
return new Telemetry(config)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export { MetricsRegistry } from './metrics'
|
|
114
|
+
export type { Metric } from './metrics'
|
|
115
|
+
export type { TelemetryConfig, MetricLabels } from './types'
|
package/src/metrics.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { MetricLabels } from './types'
|
|
2
|
+
|
|
3
|
+
type MetricKind = 'counter' | 'gauge' | 'histogram'
|
|
4
|
+
|
|
5
|
+
const DEFAULT_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]
|
|
6
|
+
|
|
7
|
+
function labelKey(labels?: MetricLabels): string {
|
|
8
|
+
if (!labels) return ''
|
|
9
|
+
return Object.keys(labels)
|
|
10
|
+
.sort()
|
|
11
|
+
.map(k => `${k}="${String(labels[k]).replace(/"/g, '\\"')}"`)
|
|
12
|
+
.join(',')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type Series = {
|
|
16
|
+
labels: MetricLabels | undefined
|
|
17
|
+
value: number
|
|
18
|
+
// histogram-only
|
|
19
|
+
buckets?: number[]
|
|
20
|
+
bucketCounts?: number[]
|
|
21
|
+
sum?: number
|
|
22
|
+
count?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class Metric {
|
|
26
|
+
readonly name: string
|
|
27
|
+
readonly help: string
|
|
28
|
+
readonly kind: MetricKind
|
|
29
|
+
private buckets: number[]
|
|
30
|
+
private series = new Map<string, Series>()
|
|
31
|
+
|
|
32
|
+
constructor(name: string, kind: MetricKind, help: string, buckets?: number[]) {
|
|
33
|
+
this.name = name
|
|
34
|
+
this.kind = kind
|
|
35
|
+
this.help = help
|
|
36
|
+
this.buckets = buckets ?? DEFAULT_BUCKETS
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private seriesFor(labels?: MetricLabels): Series {
|
|
40
|
+
const key = labelKey(labels)
|
|
41
|
+
let s = this.series.get(key)
|
|
42
|
+
if (!s) {
|
|
43
|
+
s =
|
|
44
|
+
this.kind === 'histogram'
|
|
45
|
+
? {
|
|
46
|
+
labels,
|
|
47
|
+
value: 0,
|
|
48
|
+
buckets: this.buckets,
|
|
49
|
+
bucketCounts: new Array(this.buckets.length).fill(0),
|
|
50
|
+
sum: 0,
|
|
51
|
+
count: 0,
|
|
52
|
+
}
|
|
53
|
+
: { labels, value: 0 }
|
|
54
|
+
this.series.set(key, s)
|
|
55
|
+
}
|
|
56
|
+
return s
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
inc(labels?: MetricLabels, amount = 1): void {
|
|
60
|
+
this.seriesFor(labels).value += amount
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
set(value: number, labels?: MetricLabels): void {
|
|
64
|
+
this.seriesFor(labels).value = value
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
observe(value: number, labels?: MetricLabels): void {
|
|
68
|
+
const s = this.seriesFor(labels)
|
|
69
|
+
s.sum = (s.sum ?? 0) + value
|
|
70
|
+
s.count = (s.count ?? 0) + 1
|
|
71
|
+
this.buckets.forEach((b, i) => {
|
|
72
|
+
if (value <= b) s.bucketCounts![i] += 1
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
expose(): string {
|
|
77
|
+
const lines: string[] = [`# HELP ${this.name} ${this.help}`, `# TYPE ${this.name} ${this.kind}`]
|
|
78
|
+
for (const s of this.series.values()) {
|
|
79
|
+
const base = labelKey(s.labels)
|
|
80
|
+
if (this.kind === 'histogram') {
|
|
81
|
+
this.buckets.forEach((b, i) => {
|
|
82
|
+
const l = [base, `le="${b}"`].filter(Boolean).join(',')
|
|
83
|
+
lines.push(`${this.name}_bucket{${l}} ${s.bucketCounts![i]}`)
|
|
84
|
+
})
|
|
85
|
+
const inf = [base, 'le="+Inf"'].filter(Boolean).join(',')
|
|
86
|
+
lines.push(`${this.name}_bucket{${inf}} ${s.count}`)
|
|
87
|
+
lines.push(`${this.name}_sum${base ? `{${base}}` : ''} ${s.sum}`)
|
|
88
|
+
lines.push(`${this.name}_count${base ? `{${base}}` : ''} ${s.count}`)
|
|
89
|
+
} else {
|
|
90
|
+
lines.push(`${this.name}${base ? `{${base}}` : ''} ${s.value}`)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return lines.join('\n')
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Minimal Prometheus-compatible metrics registry (no dependencies). */
|
|
98
|
+
export class MetricsRegistry {
|
|
99
|
+
private metrics = new Map<string, Metric>()
|
|
100
|
+
|
|
101
|
+
counter(name: string, help = name): Metric {
|
|
102
|
+
return this.getOrCreate(name, 'counter', help)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
gauge(name: string, help = name): Metric {
|
|
106
|
+
return this.getOrCreate(name, 'gauge', help)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
histogram(name: string, help = name, buckets?: number[]): Metric {
|
|
110
|
+
return this.getOrCreate(name, 'histogram', help, buckets)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private getOrCreate(name: string, kind: MetricKind, help: string, buckets?: number[]): Metric {
|
|
114
|
+
let m = this.metrics.get(name)
|
|
115
|
+
if (!m) {
|
|
116
|
+
m = new Metric(name, kind, help, buckets)
|
|
117
|
+
this.metrics.set(name, m)
|
|
118
|
+
} else if (m.kind !== kind) {
|
|
119
|
+
// Returning a counter when a histogram was requested (or vice-versa) produces malformed
|
|
120
|
+
// Prometheus output (NaN/undefined bucket lines). Fail fast on the conflicting registration.
|
|
121
|
+
throw new Error(
|
|
122
|
+
`Metric "${name}" is already registered as a ${m.kind}; cannot redefine it as a ${kind}`
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
return m
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Render the full registry in Prometheus text exposition format. */
|
|
129
|
+
expose(): string {
|
|
130
|
+
return [...this.metrics.values()].map(m => m.expose()).join('\n\n') + '\n'
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export type { Metric }
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type TelemetryConfig = {
|
|
2
|
+
/** Logical service name attached to traces and the `service` metric label. */
|
|
3
|
+
serviceName?: string
|
|
4
|
+
/**
|
|
5
|
+
* Enable OpenTelemetry tracing. Requires `@opentelemetry/api` and `@opentelemetry/sdk-node`.
|
|
6
|
+
* Provide an OTLP endpoint to export spans, or `true` for a no-op/console setup.
|
|
7
|
+
*/
|
|
8
|
+
tracing?: boolean | { otlpEndpoint?: string }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type MetricLabels = Record<string, string | number>
|