@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 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>