@bhooai/nexus-telemetry 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/package.json +20 -0
- package/src/index.ts +3 -0
- package/src/logger/Logger.ts +178 -0
- package/src/metrics/Metrics.ts +123 -0
- package/src/trace.ts +28 -0
- package/tests/telemetry.test.ts +83 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +9 -0
- package/vitest.config.ts.timestamp-1786095205351-8114075f69fe9.mjs +13 -0
package/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# @bhooai/nexus-telemetry
|
|
2
|
+
|
|
3
|
+
Structured logging, metrics, and trace/request-ID propagation.
|
|
4
|
+
|
|
5
|
+
## Exports
|
|
6
|
+
|
|
7
|
+
- **Logger** — JSON or pretty output, child loggers, levels, redaction of
|
|
8
|
+
sensitive fields (`password`, `secret`, `authorization`), console + rotating
|
|
9
|
+
file transports.
|
|
10
|
+
- **MetricsRegistry** — counters and histograms; `counter(name, help)` registers
|
|
11
|
+
a counter, `inc(name, value, labels)` increments it, `toJSON()` snapshots.
|
|
12
|
+
- **trace** — request/trace-ID helpers for propagation across Node → WS → Python.
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { Logger, MetricsRegistry } from '@bhooai/nexus-telemetry';
|
|
18
|
+
|
|
19
|
+
const log = new Logger({ level: 'info', format: 'pretty', console: true, redact: ['password'] });
|
|
20
|
+
log.info('listening', { port: 4000 });
|
|
21
|
+
|
|
22
|
+
const metrics = new MetricsRegistry();
|
|
23
|
+
metrics.counter('http_requests_total', 'Total HTTP requests');
|
|
24
|
+
metrics.inc('http_requests_total', 1, { method: 'GET' });
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
> `counter()` returns the registered counter state; it does **not** chain `.inc()`.
|
|
28
|
+
> Increment via the registry's `inc(name, …)`.
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bhooai/nexus-telemetry",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"publishConfig": { "access": "public" },
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsc -p tsconfig.json",
|
|
10
|
+
"test": "vitest run"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@bhooai/nexus-core": "^0.1.0"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/node": "^22.5.0",
|
|
17
|
+
"typescript": "^5.6.2",
|
|
18
|
+
"vitest": "^2.1.1"
|
|
19
|
+
}
|
|
20
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { createWriteStream, existsSync, mkdirSync, renameSync, statSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { EOL } from 'node:os';
|
|
4
|
+
|
|
5
|
+
export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal';
|
|
6
|
+
export const LEVEL_ORDER: Record<LogLevel, number> = {
|
|
7
|
+
trace: 10,
|
|
8
|
+
debug: 20,
|
|
9
|
+
info: 30,
|
|
10
|
+
warn: 40,
|
|
11
|
+
error: 50,
|
|
12
|
+
fatal: 60,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export interface LogRecord {
|
|
16
|
+
level: LogLevel;
|
|
17
|
+
time: number;
|
|
18
|
+
msg: string;
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface LoggerOptions {
|
|
23
|
+
level?: LogLevel;
|
|
24
|
+
format?: 'json' | 'pretty';
|
|
25
|
+
console?: boolean;
|
|
26
|
+
/** File transport settings. */
|
|
27
|
+
file?: { dir: string; maxFileSize: number; maxFiles: number };
|
|
28
|
+
/** Field names to redact (replaced with [REDACTED]). */
|
|
29
|
+
redact?: string[];
|
|
30
|
+
/** Default bindings applied to every record. */
|
|
31
|
+
bindings?: Record<string, unknown>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const COLORS: Record<LogLevel, string> = {
|
|
35
|
+
trace: '\x1b[90m',
|
|
36
|
+
debug: '\x1b[36m',
|
|
37
|
+
info: '\x1b[32m',
|
|
38
|
+
warn: '\x1b[33m',
|
|
39
|
+
error: '\x1b[31m',
|
|
40
|
+
fatal: '\x1b[35m',
|
|
41
|
+
};
|
|
42
|
+
const RESET = '\x1b[0m';
|
|
43
|
+
|
|
44
|
+
/** Inbuilt structured logger with JSON/pretty formatting, redaction, and rotating file transport. */
|
|
45
|
+
export class Logger {
|
|
46
|
+
private opts: Required<Pick<LoggerOptions, 'level' | 'format' | 'console' | 'redact'>> & {
|
|
47
|
+
file?: NonNullable<LoggerOptions['file']>;
|
|
48
|
+
bindings: Record<string, unknown>;
|
|
49
|
+
};
|
|
50
|
+
private stream: NodeJS.WritableStream | null = null;
|
|
51
|
+
private currentSize = 0;
|
|
52
|
+
|
|
53
|
+
constructor(opts: LoggerOptions = {}) {
|
|
54
|
+
this.opts = {
|
|
55
|
+
level: opts.level ?? 'info',
|
|
56
|
+
format: opts.format ?? 'pretty',
|
|
57
|
+
console: opts.console ?? true,
|
|
58
|
+
redact: opts.redact ?? [],
|
|
59
|
+
file: opts.file,
|
|
60
|
+
bindings: opts.bindings ?? {},
|
|
61
|
+
};
|
|
62
|
+
if (this.opts.file) this.openStream(this.opts.file);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private openStream(file: NonNullable<LoggerOptions['file']>): void {
|
|
66
|
+
if (!existsSync(file.dir)) mkdirSync(file.dir, { recursive: true });
|
|
67
|
+
const target = join(file.dir, 'nexus.log');
|
|
68
|
+
try {
|
|
69
|
+
this.currentSize = existsSync(target) ? statSync(target).size : 0;
|
|
70
|
+
} catch {
|
|
71
|
+
this.currentSize = 0;
|
|
72
|
+
}
|
|
73
|
+
this.stream = createWriteStream(target, { flags: 'a' });
|
|
74
|
+
this.stream.on('error', () => {
|
|
75
|
+
this.stream = null;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
child(bindings: Record<string, unknown>): Logger {
|
|
80
|
+
return new Logger({
|
|
81
|
+
level: this.opts.level,
|
|
82
|
+
format: this.opts.format,
|
|
83
|
+
console: this.opts.console,
|
|
84
|
+
file: this.opts.file,
|
|
85
|
+
redact: this.opts.redact,
|
|
86
|
+
bindings: { ...this.opts.bindings, ...bindings },
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
setLevel(level: LogLevel): void {
|
|
91
|
+
this.opts.level = level;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private shouldLog(level: LogLevel): boolean {
|
|
95
|
+
return LEVEL_ORDER[level] >= LEVEL_ORDER[this.opts.level];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
log(level: LogLevel, msg: string, data?: Record<string, unknown>): void {
|
|
99
|
+
if (!this.shouldLog(level)) return;
|
|
100
|
+
const record: LogRecord = {
|
|
101
|
+
level,
|
|
102
|
+
time: Date.now(),
|
|
103
|
+
msg,
|
|
104
|
+
...this.opts.bindings,
|
|
105
|
+
...(data ?? {}),
|
|
106
|
+
};
|
|
107
|
+
const safe = this.redactFields(record);
|
|
108
|
+
const line = this.opts.format === 'json' ? JSON.stringify(safe) : this.pretty(safe);
|
|
109
|
+
this.write(line + EOL, level);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private redactFields(record: LogRecord): LogRecord {
|
|
113
|
+
if (this.opts.redact.length === 0) return record;
|
|
114
|
+
const out: LogRecord = { ...record };
|
|
115
|
+
for (const key of this.opts.redact) {
|
|
116
|
+
if (key in out) out[key] = '[REDACTED]';
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private pretty(rec: LogRecord): string {
|
|
122
|
+
const time = new Date(rec.time).toISOString();
|
|
123
|
+
const levelTag = `${COLORS[rec.level]}${rec.level.toUpperCase().padEnd(5)}${RESET}`;
|
|
124
|
+
const base = Object.fromEntries(
|
|
125
|
+
Object.entries(rec).filter(([k]) => k !== 'level' && k !== 'time' && k !== 'msg'),
|
|
126
|
+
);
|
|
127
|
+
const payload = Object.keys(base).length ? ` ${JSON.stringify(base)}` : '';
|
|
128
|
+
return `${time} ${levelTag} ${rec.msg}${payload}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private write(line: string, level: LogLevel): void {
|
|
132
|
+
if (this.opts.console) {
|
|
133
|
+
if (level === 'error' || level === 'fatal') process.stderr.write(line);
|
|
134
|
+
else process.stdout.write(line);
|
|
135
|
+
}
|
|
136
|
+
if (this.stream && this.opts.file) {
|
|
137
|
+
this.currentSize += Buffer.byteLength(line);
|
|
138
|
+
this.stream.write(line);
|
|
139
|
+
if (this.currentSize >= this.opts.file.maxFileSize) this.rotate();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private rotate(): void {
|
|
144
|
+
const file = this.opts.file!;
|
|
145
|
+
const target = join(file.dir, 'nexus.log');
|
|
146
|
+
if (!existsSync(target)) return;
|
|
147
|
+
this.stream?.end();
|
|
148
|
+
for (let i = file.maxFiles - 1; i > 0; i--) {
|
|
149
|
+
const from = join(file.dir, `nexus.${i - 1}.log`);
|
|
150
|
+
const to = join(file.dir, `nexus.${i}.log`);
|
|
151
|
+
if (existsSync(from)) renameSync(from, to);
|
|
152
|
+
}
|
|
153
|
+
renameSync(target, join(file.dir, 'nexus.0.log'));
|
|
154
|
+
this.currentSize = 0;
|
|
155
|
+
this.stream = createWriteStream(target, { flags: 'a' });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Convenience level methods
|
|
159
|
+
trace(msg: string, data?: Record<string, unknown>): void { this.log('trace', msg, data); }
|
|
160
|
+
debug(msg: string, data?: Record<string, unknown>): void { this.log('debug', msg, data); }
|
|
161
|
+
info(msg: string, data?: Record<string, unknown>): void { this.log('info', msg, data); }
|
|
162
|
+
warn(msg: string, data?: Record<string, unknown>): void { this.log('warn', msg, data); }
|
|
163
|
+
error(msg: string, data?: Record<string, unknown>): void { this.log('error', msg, data); }
|
|
164
|
+
fatal(msg: string, data?: Record<string, unknown>): void { this.log('fatal', msg, data); }
|
|
165
|
+
|
|
166
|
+
/** Flush file transport. */
|
|
167
|
+
close(): Promise<void> {
|
|
168
|
+
return new Promise((resolve) => {
|
|
169
|
+
if (this.stream) this.stream.end(() => resolve());
|
|
170
|
+
else resolve();
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A no-op file-less logger for tests / library contexts. */
|
|
176
|
+
export function createLogger(opts?: LoggerOptions): Logger {
|
|
177
|
+
return new Logger({ console: false, ...opts });
|
|
178
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal inbuilt metrics registry: counters and histograms with labels.
|
|
3
|
+
* Exposes a JSON snapshot for an admin `/metrics` endpoint and a Prometheus
|
|
4
|
+
* text format for scraper compatibility.
|
|
5
|
+
*/
|
|
6
|
+
interface CounterState {
|
|
7
|
+
help: string;
|
|
8
|
+
values: Map<string, number>;
|
|
9
|
+
}
|
|
10
|
+
interface HistogramState {
|
|
11
|
+
help: string;
|
|
12
|
+
buckets: number[];
|
|
13
|
+
counts: Map<string, number[]>; // per-label bucket counts
|
|
14
|
+
sums: Map<string, number>;
|
|
15
|
+
totals: Map<string, number>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class MetricsRegistry {
|
|
19
|
+
private counters = new Map<string, CounterState>();
|
|
20
|
+
private histograms = new Map<string, HistogramState>();
|
|
21
|
+
|
|
22
|
+
counter(name: string, help = ''): CounterState {
|
|
23
|
+
let c = this.counters.get(name);
|
|
24
|
+
if (!c) {
|
|
25
|
+
c = { help, values: new Map() };
|
|
26
|
+
this.counters.set(name, c);
|
|
27
|
+
}
|
|
28
|
+
return c;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
histogram(name: string, buckets: number[] = [0.005, 0.01, 0.05, 0.1, 0.5, 1, 5, 10], help = ''): HistogramState {
|
|
32
|
+
let h = this.histograms.get(name);
|
|
33
|
+
if (!h) {
|
|
34
|
+
h = { help, buckets, counts: new Map(), sums: new Map(), totals: new Map() };
|
|
35
|
+
this.histograms.set(name, h);
|
|
36
|
+
}
|
|
37
|
+
return h;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
inc(name: string, value = 1, labels: Record<string, string> = {}): void {
|
|
41
|
+
const key = labelKey(labels);
|
|
42
|
+
const c = this.counter(name);
|
|
43
|
+
c.values.set(key, (c.values.get(key) ?? 0) + value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
observe(name: string, value: number, labels: Record<string, string> = {}): void {
|
|
47
|
+
const key = labelKey(labels);
|
|
48
|
+
const h = this.histogram(name);
|
|
49
|
+
let counts = h.counts.get(key);
|
|
50
|
+
if (!counts) {
|
|
51
|
+
counts = new Array(h.buckets.length).fill(0);
|
|
52
|
+
h.counts.set(key, counts);
|
|
53
|
+
}
|
|
54
|
+
for (let i = 0; i < h.buckets.length; i++) if (value <= h.buckets[i]!) counts[i]!++;
|
|
55
|
+
h.sums.set(key, (h.sums.get(key) ?? 0) + value);
|
|
56
|
+
h.totals.set(key, (h.totals.get(key) ?? 0) + 1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** JSON snapshot for an admin endpoint. */
|
|
60
|
+
toJSON(): Record<string, unknown> {
|
|
61
|
+
const out: Record<string, unknown> = {};
|
|
62
|
+
for (const [name, c] of this.counters) {
|
|
63
|
+
out[name] = { type: 'counter', help: c.help, values: Object.fromEntries(c.values) };
|
|
64
|
+
}
|
|
65
|
+
for (const [name, h] of this.histograms) {
|
|
66
|
+
out[name] = {
|
|
67
|
+
type: 'histogram',
|
|
68
|
+
help: h.help,
|
|
69
|
+
buckets: h.buckets,
|
|
70
|
+
counts: Object.fromEntries(h.counts),
|
|
71
|
+
sums: Object.fromEntries(h.sums),
|
|
72
|
+
totals: Object.fromEntries(h.totals),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Prometheus text exposition format. */
|
|
79
|
+
toPrometheus(): string {
|
|
80
|
+
const lines: string[] = [];
|
|
81
|
+
for (const [name, c] of this.counters) {
|
|
82
|
+
if (c.help) lines.push(`# HELP ${name} ${c.help}`);
|
|
83
|
+
lines.push(`# TYPE ${name} counter`);
|
|
84
|
+
for (const [labels, val] of c.values) lines.push(`${name}${labels} ${val}`);
|
|
85
|
+
}
|
|
86
|
+
for (const [name, h] of this.histograms) {
|
|
87
|
+
if (h.help) lines.push(`# HELP ${name} ${h.help}`);
|
|
88
|
+
lines.push(`# TYPE ${name} histogram`);
|
|
89
|
+
for (const [labels, counts] of h.counts) {
|
|
90
|
+
let cumulative = 0;
|
|
91
|
+
for (let i = 0; i < h.buckets.length; i++) {
|
|
92
|
+
cumulative += counts[i]!;
|
|
93
|
+
const le = h.buckets[i]!;
|
|
94
|
+
lines.push(`${name}_bucket${withLabel(labels, 'le', String(le))} ${cumulative}`);
|
|
95
|
+
}
|
|
96
|
+
lines.push(`${name}_bucket${withLabel(labels, 'le', '+Inf')} ${h.totals.get(labels) ?? 0}`);
|
|
97
|
+
lines.push(`${name}_sum${labels} ${h.sums.get(labels) ?? 0}`);
|
|
98
|
+
lines.push(`${name}_count${labels} ${h.totals.get(labels) ?? 0}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return lines.join('\n') + '\n';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
reset(): void {
|
|
105
|
+
this.counters.clear();
|
|
106
|
+
this.histograms.clear();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function labelKey(labels: Record<string, string>): string {
|
|
111
|
+
const keys = Object.keys(labels).sort();
|
|
112
|
+
if (keys.length === 0) return '';
|
|
113
|
+
return `{${keys.map((k) => `${k}="${escapeLabel(labels[k]!)}"`).join(',')}}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function withLabel(existing: string, name: string, value: string): string {
|
|
117
|
+
if (!existing) return `{${name}="${escapeLabel(value)}"}`;
|
|
118
|
+
return existing.slice(0, -1) + `,${name}="${escapeLabel(value)}"}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function escapeLabel(v: string): string {
|
|
122
|
+
return v.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
|
123
|
+
}
|
package/src/trace.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
|
+
|
|
4
|
+
export interface TraceContext {
|
|
5
|
+
requestId: string;
|
|
6
|
+
traceId: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const traceStorage = new AsyncLocalStorage<TraceContext>();
|
|
10
|
+
|
|
11
|
+
/** Start a trace context for the lifetime of `fn`, propagating request/trace ids. */
|
|
12
|
+
export function withTrace<R>(fn: () => R, existing?: Partial<TraceContext>): R {
|
|
13
|
+
const ctx: TraceContext = {
|
|
14
|
+
requestId: existing?.requestId ?? randomUUID(),
|
|
15
|
+
traceId: existing?.traceId ?? randomUUID(),
|
|
16
|
+
};
|
|
17
|
+
return traceStorage.run(ctx, fn);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Current trace context, if any. */
|
|
21
|
+
export function currentTrace(): TraceContext | undefined {
|
|
22
|
+
return traceStorage.getStore();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Generate a standalone id (for headers). */
|
|
26
|
+
export function newRequestId(): string {
|
|
27
|
+
return randomUUID();
|
|
28
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { Logger, createLogger, MetricsRegistry, withTrace, currentTrace } from '../src/index.js';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs';
|
|
6
|
+
|
|
7
|
+
describe('Logger', () => {
|
|
8
|
+
it('respects level filtering (silent below threshold)', () => {
|
|
9
|
+
const sink: string[] = [];
|
|
10
|
+
const orig = process.stdout.write.bind(process.stdout);
|
|
11
|
+
process.stdout.write = (chunk: string | Uint8Array) => { sink.push(String(chunk)); return true; };
|
|
12
|
+
try {
|
|
13
|
+
const log = createLogger({ level: 'warn', console: true });
|
|
14
|
+
log.info('should-not-appear');
|
|
15
|
+
log.warn('should-appear');
|
|
16
|
+
} finally {
|
|
17
|
+
process.stdout.write = orig;
|
|
18
|
+
}
|
|
19
|
+
expect(sink.join('')).not.toContain('should-not-appear');
|
|
20
|
+
expect(sink.join('')).toContain('should-appear');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('redacts configured fields', () => {
|
|
24
|
+
const sink: string[] = [];
|
|
25
|
+
const orig = process.stdout.write.bind(process.stdout);
|
|
26
|
+
process.stdout.write = (chunk: string | Uint8Array) => { sink.push(String(chunk)); return true; };
|
|
27
|
+
try {
|
|
28
|
+
const log = createLogger({ level: 'debug', console: true, redact: ['password'], format: 'json' });
|
|
29
|
+
log.info('login', { password: 'secret', user: 'alice' });
|
|
30
|
+
} finally {
|
|
31
|
+
process.stdout.write = orig;
|
|
32
|
+
}
|
|
33
|
+
const line = sink.join('');
|
|
34
|
+
expect(line).toContain('[REDACTED]');
|
|
35
|
+
expect(line).not.toContain('secret');
|
|
36
|
+
expect(line).toContain('alice');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('writes to a rotating file transport', async () => {
|
|
40
|
+
const dir = mkdtempSync(join(tmpdir(), 'nexus-log-'));
|
|
41
|
+
try {
|
|
42
|
+
const log = new Logger({ level: 'info', console: false, file: { dir, maxFileSize: 1_000_000, maxFiles: 3 } });
|
|
43
|
+
log.info('file-message', { n: 1 });
|
|
44
|
+
await log.close();
|
|
45
|
+
// give the OS a tick to flush the final write
|
|
46
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
47
|
+
const content = readFileSync(join(dir, 'nexus.log'), 'utf8');
|
|
48
|
+
expect(content).toContain('file-message');
|
|
49
|
+
} finally {
|
|
50
|
+
rmSync(dir, { recursive: true, force: true });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('MetricsRegistry', () => {
|
|
56
|
+
let m: MetricsRegistry;
|
|
57
|
+
beforeEach(() => { m = new MetricsRegistry(); });
|
|
58
|
+
|
|
59
|
+
it('increments counters with labels', () => {
|
|
60
|
+
m.inc('http_requests', 1, { route: '/health', method: 'GET' });
|
|
61
|
+
m.inc('http_requests', 1, { route: '/health', method: 'GET' });
|
|
62
|
+
const json = m.toJSON()['http_requests'] as { values: Record<string, number> };
|
|
63
|
+
expect(json.values['{method="GET",route="/health"}']).toBe(2);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('observes histograms into buckets', () => {
|
|
67
|
+
m.observe('latency', 0.02, { route: '/x' });
|
|
68
|
+
m.observe('latency', 0.5, { route: '/x' });
|
|
69
|
+
const prom = m.toPrometheus();
|
|
70
|
+
expect(prom).toContain('latency_bucket');
|
|
71
|
+
expect(prom).toContain('latency_sum');
|
|
72
|
+
expect(prom).toContain('latency_count');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('trace', () => {
|
|
77
|
+
it('propagates context through async locals', () => {
|
|
78
|
+
withTrace(() => {
|
|
79
|
+
expect(currentTrace()?.requestId).toMatch(/^[0-9a-f-]{36}$/i);
|
|
80
|
+
});
|
|
81
|
+
expect(currentTrace()).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
});
|
package/tsconfig.json
ADDED
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// packages/nexus-telemetry/vitest.config.ts
|
|
2
|
+
import { defineProject } from "file:///C:/server/BhooAI/BhooAI-Nexus/BhooAI-Nexus/bhooai-nexus/node_modules/vitest/dist/config.js";
|
|
3
|
+
var vitest_config_default = defineProject({
|
|
4
|
+
test: {
|
|
5
|
+
environment: "node",
|
|
6
|
+
include: ["tests/**/*.test.ts"],
|
|
7
|
+
globals: false
|
|
8
|
+
}
|
|
9
|
+
});
|
|
10
|
+
export {
|
|
11
|
+
vitest_config_default as default
|
|
12
|
+
};
|
|
13
|
+
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsicGFja2FnZXMvbmV4dXMtdGVsZW1ldHJ5L3ZpdGVzdC5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCJDOlxcXFxzZXJ2ZXJcXFxcQmhvb0FJXFxcXEJob29BSS1OZXh1c1xcXFxCaG9vQUktTmV4dXNcXFxcYmhvb2FpLW5leHVzXFxcXHBhY2thZ2VzXFxcXG5leHVzLXRlbGVtZXRyeVwiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9maWxlbmFtZSA9IFwiQzpcXFxcc2VydmVyXFxcXEJob29BSVxcXFxCaG9vQUktTmV4dXNcXFxcQmhvb0FJLU5leHVzXFxcXGJob29haS1uZXh1c1xcXFxwYWNrYWdlc1xcXFxuZXh1cy10ZWxlbWV0cnlcXFxcdml0ZXN0LmNvbmZpZy50c1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vQzovc2VydmVyL0Job29BSS9CaG9vQUktTmV4dXMvQmhvb0FJLU5leHVzL2Job29haS1uZXh1cy9wYWNrYWdlcy9uZXh1cy10ZWxlbWV0cnkvdml0ZXN0LmNvbmZpZy50c1wiO2ltcG9ydCB7IGRlZmluZVByb2plY3QgfSBmcm9tICd2aXRlc3QvY29uZmlnJztcblxuZXhwb3J0IGRlZmF1bHQgZGVmaW5lUHJvamVjdCh7XG4gIHRlc3Q6IHtcbiAgICBlbnZpcm9ubWVudDogJ25vZGUnLFxuICAgIGluY2x1ZGU6IFsndGVzdHMvKiovKi50ZXN0LnRzJ10sXG4gICAgZ2xvYmFsczogZmFsc2UsXG4gIH0sXG59KTsiXSwKICAibWFwcGluZ3MiOiAiO0FBQXNiLFNBQVMscUJBQXFCO0FBRXBkLElBQU8sd0JBQVEsY0FBYztBQUFBLEVBQzNCLE1BQU07QUFBQSxJQUNKLGFBQWE7QUFBQSxJQUNiLFNBQVMsQ0FBQyxvQkFBb0I7QUFBQSxJQUM5QixTQUFTO0FBQUEsRUFDWDtBQUNGLENBQUM7IiwKICAibmFtZXMiOiBbXQp9Cg==
|