@schlessera/brain-ui-server 0.15.0 → 0.16.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 +3 -0
- package/dist/agent/backend.d.ts.map +1 -1
- package/dist/agent/backend.js +6 -2
- package/dist/agent/backend.js.map +1 -1
- package/dist/app.d.ts +9 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +37 -6
- package/dist/app.js.map +1 -1
- package/dist/config/env.d.ts +8 -0
- package/dist/config/env.d.ts.map +1 -1
- package/dist/config/env.js +48 -0
- package/dist/config/env.js.map +1 -1
- package/dist/cron/scheduler.d.ts +3 -0
- package/dist/cron/scheduler.d.ts.map +1 -1
- package/dist/cron/scheduler.js +11 -3
- package/dist/cron/scheduler.js.map +1 -1
- package/dist/db/client.d.ts +10 -1
- package/dist/db/client.d.ts.map +1 -1
- package/dist/db/client.js +5 -14
- package/dist/db/client.js.map +1 -1
- package/dist/db/settings.d.ts +2 -1
- package/dist/db/settings.d.ts.map +1 -1
- package/dist/db/settings.js +6 -2
- package/dist/db/settings.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/middleware/auth.d.ts +3 -2
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +28 -9
- package/dist/middleware/auth.js.map +1 -1
- package/dist/middleware/passkeys.d.ts +10 -0
- package/dist/middleware/passkeys.d.ts.map +1 -1
- package/dist/middleware/passkeys.js +19 -3
- package/dist/middleware/passkeys.js.map +1 -1
- package/dist/observability/index.d.ts +73 -0
- package/dist/observability/index.d.ts.map +1 -0
- package/dist/observability/index.js +74 -0
- package/dist/observability/index.js.map +1 -0
- package/dist/observability/loggers.d.ts +62 -0
- package/dist/observability/loggers.d.ts.map +1 -0
- package/dist/observability/loggers.js +109 -0
- package/dist/observability/loggers.js.map +1 -0
- package/dist/observability/meter.d.ts +39 -0
- package/dist/observability/meter.d.ts.map +1 -0
- package/dist/observability/meter.js +95 -0
- package/dist/observability/meter.js.map +1 -0
- package/dist/observability/types.d.ts +67 -0
- package/dist/observability/types.d.ts.map +1 -0
- package/dist/observability/types.js +44 -0
- package/dist/observability/types.js.map +1 -0
- package/dist/routes/brain.d.ts.map +1 -1
- package/dist/routes/brain.js +9 -1
- package/dist/routes/brain.js.map +1 -1
- package/dist/routes/files.d.ts +2 -0
- package/dist/routes/files.d.ts.map +1 -1
- package/dist/routes/files.js +7 -7
- package/dist/routes/files.js.map +1 -1
- package/dist/routes/graph.d.ts +2 -0
- package/dist/routes/graph.d.ts.map +1 -1
- package/dist/routes/graph.js +7 -7
- package/dist/routes/graph.js.map +1 -1
- package/dist/routes/health.d.ts +3 -0
- package/dist/routes/health.d.ts.map +1 -1
- package/dist/routes/health.js +3 -0
- package/dist/routes/health.js.map +1 -1
- package/dist/routes/models.d.ts +3 -0
- package/dist/routes/models.d.ts.map +1 -1
- package/dist/routes/models.js +5 -1
- package/dist/routes/models.js.map +1 -1
- package/dist/routes/render.d.ts +2 -1
- package/dist/routes/render.d.ts.map +1 -1
- package/dist/routes/render.js +2 -2
- package/dist/routes/render.js.map +1 -1
- package/dist/routes/share.d.ts +3 -0
- package/dist/routes/share.d.ts.map +1 -1
- package/dist/routes/share.js +3 -3
- package/dist/routes/share.js.map +1 -1
- package/dist/share/staging.d.ts +3 -2
- package/dist/share/staging.d.ts.map +1 -1
- package/dist/share/staging.js +12 -4
- package/dist/share/staging.js.map +1 -1
- package/dist/voice/keyterm-builder.d.ts +4 -1
- package/dist/voice/keyterm-builder.d.ts.map +1 -1
- package/dist/voice/keyterm-builder.js +13 -6
- package/dist/voice/keyterm-builder.js.map +1 -1
- package/dist/ws/bridge.d.ts.map +1 -1
- package/dist/ws/bridge.js +6 -1
- package/dist/ws/bridge.js.map +1 -1
- package/dist/ws/connection.d.ts +15 -0
- package/dist/ws/connection.d.ts.map +1 -1
- package/dist/ws/connection.js +55 -8
- package/dist/ws/connection.js.map +1 -1
- package/dist/ws/dispatch.d.ts +22 -1
- package/dist/ws/dispatch.d.ts.map +1 -1
- package/dist/ws/dispatch.js +33 -15
- package/dist/ws/dispatch.js.map +1 -1
- package/dist/ws/host.d.ts +37 -0
- package/dist/ws/host.d.ts.map +1 -1
- package/dist/ws/host.js +37 -0
- package/dist/ws/host.js.map +1 -1
- package/dist/ws/rate-limit.d.ts +58 -0
- package/dist/ws/rate-limit.d.ts.map +1 -0
- package/dist/ws/rate-limit.js +62 -0
- package/dist/ws/rate-limit.js.map +1 -0
- package/dist/ws/run-session.d.ts +3 -0
- package/dist/ws/run-session.d.ts.map +1 -1
- package/dist/ws/run-session.js +23 -5
- package/dist/ws/run-session.js.map +1 -1
- package/dist/ws/turns.d.ts +13 -0
- package/dist/ws/turns.d.ts.map +1 -1
- package/dist/ws/turns.js +11 -1
- package/dist/ws/turns.js.map +1 -1
- package/package.json +5 -3
- package/src/agent/backend.ts +8 -6
- package/src/app.ts +46 -6
- package/src/config/env.ts +57 -0
- package/src/cron/scheduler.ts +14 -3
- package/src/db/client.ts +16 -5
- package/src/db/settings.ts +7 -2
- package/src/index.ts +37 -2
- package/src/middleware/auth.ts +30 -14
- package/src/middleware/passkeys.ts +29 -5
- package/src/observability/index.ts +150 -0
- package/src/observability/loggers.ts +174 -0
- package/src/observability/meter.ts +190 -0
- package/src/observability/types.ts +101 -0
- package/src/routes/brain.ts +10 -1
- package/src/routes/files.ts +9 -8
- package/src/routes/graph.ts +9 -8
- package/src/routes/health.ts +7 -0
- package/src/routes/models.ts +8 -5
- package/src/routes/render.ts +3 -2
- package/src/routes/share.ts +6 -3
- package/src/share/staging.ts +18 -4
- package/src/voice/keyterm-builder.ts +17 -8
- package/src/ws/bridge.ts +5 -1
- package/src/ws/connection.ts +63 -12
- package/src/ws/dispatch.ts +44 -15
- package/src/ws/host.ts +54 -0
- package/src/ws/rate-limit.ts +86 -0
- package/src/ws/run-session.ts +26 -7
- package/src/ws/turns.ts +19 -3
package/src/middleware/auth.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Logger } from "@opentelemetry/api-logs";
|
|
1
2
|
import { Hono } from "hono";
|
|
2
3
|
import type { Context, MiddlewareHandler } from "hono";
|
|
3
4
|
import { getSignedCookie, setSignedCookie, deleteCookie } from "hono/cookie";
|
|
@@ -48,12 +49,14 @@ const LOGIN_RATE_WINDOW_MS = 60_000; // per minute
|
|
|
48
49
|
// attacker could weaponize to deny the owner access.
|
|
49
50
|
export const GLOBAL_LOGIN_RATE_LIMIT = 20; // attempts per window, all IPs combined
|
|
50
51
|
|
|
51
|
-
export function resolveAuthMode(auth: AuthRuntime): AuthMode {
|
|
52
|
+
export function resolveAuthMode(auth: AuthRuntime, log?: Logger): AuthMode {
|
|
52
53
|
if (auth.mode) return auth.mode;
|
|
53
54
|
if (auth.invalidMode) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
log?.emit({
|
|
56
|
+
severityText: "WARN",
|
|
57
|
+
body: "unknown AUTH_MODE; auto-detecting instead",
|
|
58
|
+
attributes: { requested: auth.invalidMode },
|
|
59
|
+
});
|
|
57
60
|
}
|
|
58
61
|
if (auth.passwordHash) return "password";
|
|
59
62
|
return "tailscale";
|
|
@@ -63,7 +66,7 @@ export function resolveAuthMode(auth: AuthRuntime): AuthMode {
|
|
|
63
66
|
* Validate the auth configuration at startup. Throws (refusing to boot) on an
|
|
64
67
|
* unsafe or unusable configuration.
|
|
65
68
|
*/
|
|
66
|
-
export function assertAuthConfig(mode: AuthMode, auth: AuthRuntime): void {
|
|
69
|
+
export function assertAuthConfig(mode: AuthMode, auth: AuthRuntime, log?: Logger): void {
|
|
67
70
|
const host = auth.host;
|
|
68
71
|
const loopback = host === "127.0.0.1" || host === "::1" || host === "localhost";
|
|
69
72
|
|
|
@@ -87,11 +90,17 @@ export function assertAuthConfig(mode: AuthMode, auth: AuthRuntime): void {
|
|
|
87
90
|
// exposed to the network".
|
|
88
91
|
if (mode === "none" && !loopback) {
|
|
89
92
|
if (auth.dangerouslyDisableAuth) {
|
|
90
|
-
|
|
91
|
-
|
|
93
|
+
// ERROR, not WARN: this is a safety that has been deliberately switched
|
|
94
|
+
// off, and the severity must survive any sane BRAIN_UI_LOG_LEVEL. A log
|
|
95
|
+
// threshold must never be the reason nobody saw this.
|
|
96
|
+
log?.emit({
|
|
97
|
+
severityText: "ERROR",
|
|
98
|
+
body:
|
|
99
|
+
"AUTH_MODE=none on a non-loopback host, allowed by " +
|
|
92
100
|
"BRAIN_UI_DANGEROUSLY_DISABLE_AUTH=1 — every network peer has full " +
|
|
93
|
-
"agent access. Do not run this on anything but a trusted network."
|
|
94
|
-
|
|
101
|
+
"agent access. Do not run this on anything but a trusted network.",
|
|
102
|
+
attributes: { "auth.mode": "none", host },
|
|
103
|
+
});
|
|
95
104
|
} else {
|
|
96
105
|
throw new Error(
|
|
97
106
|
"AUTH_MODE=none refuses to start unless HOST is loopback " +
|
|
@@ -112,13 +121,20 @@ export function assertAuthConfig(mode: AuthMode, auth: AuthRuntime): void {
|
|
|
112
121
|
"any client-supplied copy. Set TRUST_PROXY=1 once that holds."
|
|
113
122
|
);
|
|
114
123
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
124
|
+
log?.emit({
|
|
125
|
+
severityText: "INFO",
|
|
126
|
+
body:
|
|
127
|
+
"auth mode resolved: proxy — ensure the upstream proxy sets this header " +
|
|
128
|
+
"and strips any client-supplied copy",
|
|
129
|
+
attributes: { "auth.mode": "proxy", header: auth.proxyAuthHeader },
|
|
130
|
+
});
|
|
119
131
|
return;
|
|
120
132
|
}
|
|
121
|
-
|
|
133
|
+
log?.emit({
|
|
134
|
+
severityText: "INFO",
|
|
135
|
+
body: "auth mode resolved",
|
|
136
|
+
attributes: { "auth.mode": mode },
|
|
137
|
+
});
|
|
122
138
|
}
|
|
123
139
|
|
|
124
140
|
/** Middleware guarding /api/* according to the resolved mode. */
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { Logger } from "@opentelemetry/api-logs";
|
|
2
|
+
import type { Counter } from "@opentelemetry/api";
|
|
1
3
|
import { Hono } from "hono";
|
|
2
4
|
import type { Context } from "hono";
|
|
3
5
|
import type { Database } from "bun:sqlite";
|
|
@@ -45,6 +47,14 @@ export interface PasskeyContext {
|
|
|
45
47
|
auth: AuthRuntime;
|
|
46
48
|
/** ALLOWED_ORIGINS — split-topology origins also valid for ceremonies. */
|
|
47
49
|
allowedOrigins: string[];
|
|
50
|
+
/** Where ceremony failures are reported; absent means silence. */
|
|
51
|
+
log?: Logger;
|
|
52
|
+
/**
|
|
53
|
+
* Counts failed ceremonies, split by reason. A rate of these is the signal
|
|
54
|
+
* that distinguishes one fumbled login from someone working through a list,
|
|
55
|
+
* and it is not recoverable from a log line nobody is tailing.
|
|
56
|
+
*/
|
|
57
|
+
failures?: Counter;
|
|
48
58
|
}
|
|
49
59
|
|
|
50
60
|
const CHALLENGE_TTL_MS = 120_000; // > the 60s ceremony timeout
|
|
@@ -362,11 +372,20 @@ export function passkeyPublicRoutes(
|
|
|
362
372
|
// credential. Cloud passkeys legitimately sit at 0, so warn, don't
|
|
363
373
|
// revoke.
|
|
364
374
|
if (/counter/i.test(message)) {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
375
|
+
ctx.failures?.add(1, { reason: "counter_regression", ceremony: "authentication" });
|
|
376
|
+
ctx.log?.emit({
|
|
377
|
+
severityText: "WARN",
|
|
378
|
+
body: "counter regression — possible cloned credential",
|
|
379
|
+
attributes: { "credential.id": row.id },
|
|
380
|
+
});
|
|
368
381
|
} else {
|
|
369
|
-
|
|
382
|
+
ctx.failures?.add(1, { reason: "verification_failed", ceremony: "authentication" });
|
|
383
|
+
// The message is the library's, never the assertion payload.
|
|
384
|
+
ctx.log?.emit({
|
|
385
|
+
severityText: "WARN",
|
|
386
|
+
body: "passkey authentication failed",
|
|
387
|
+
attributes: { error: message },
|
|
388
|
+
});
|
|
370
389
|
}
|
|
371
390
|
return fail();
|
|
372
391
|
}
|
|
@@ -471,7 +490,12 @@ export function passkeyManagementRoutes(
|
|
|
471
490
|
return c.json({ ok: true, credential: row ? toSummary(row) : null });
|
|
472
491
|
} catch (err) {
|
|
473
492
|
const message = err instanceof Error ? err.message : String(err);
|
|
474
|
-
|
|
493
|
+
ctx.failures?.add(1, { reason: "verification_failed", ceremony: "registration" });
|
|
494
|
+
ctx.log?.emit({
|
|
495
|
+
severityText: "WARN",
|
|
496
|
+
body: "passkey registration failed",
|
|
497
|
+
attributes: { error: message },
|
|
498
|
+
});
|
|
475
499
|
return c.json({ error: "Passkey registration failed" }, 400);
|
|
476
500
|
}
|
|
477
501
|
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observability for the chat-UI server: what it reports, and who receives it.
|
|
3
|
+
*
|
|
4
|
+
* The split is deliberate and is the whole design. The PRODUCING side is the
|
|
5
|
+
* standard OpenTelemetry API — `logger.emit(...)`, `counter.add(...)` — so
|
|
6
|
+
* instrumentation is portable and a real SDK can take over later without a
|
|
7
|
+
* single call site changing. The CONSUMING side is ours: a console consumer in
|
|
8
|
+
* production, a recording consumer in tests, an in-memory meter feeding
|
|
9
|
+
* `/api/status`. That is what keeps the dependency surface to two packages and
|
|
10
|
+
* makes "what did the server report?" answerable as data.
|
|
11
|
+
*
|
|
12
|
+
* An `Observability` is a value you construct and own, like the app's database
|
|
13
|
+
* handle or a `ScrapeClient`'s rate limiter — NOT a process-global. The
|
|
14
|
+
* OpenTelemetry globals are supported (`installGlobally`) for instrumentation
|
|
15
|
+
* that cannot be reached with an argument, but nothing in this package
|
|
16
|
+
* requires them, so two differently-configured apps can share a process and a
|
|
17
|
+
* test never inherits the previous test's sinks.
|
|
18
|
+
*/
|
|
19
|
+
import { metrics, type Meter, type MeterProvider } from "@opentelemetry/api";
|
|
20
|
+
import { logs, type Logger, type LoggerProvider } from "@opentelemetry/api-logs";
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
createConsoleLoggerProvider,
|
|
24
|
+
createRecordingLoggerProvider,
|
|
25
|
+
createSilentLoggerProvider,
|
|
26
|
+
type LogReader,
|
|
27
|
+
type RecordingLoggerProvider,
|
|
28
|
+
} from "./loggers.js";
|
|
29
|
+
import { createInMemoryMeterProvider, type InMemoryMeterProvider, type MetricsReader } from "./meter.js";
|
|
30
|
+
import type { Severity } from "./types.js";
|
|
31
|
+
|
|
32
|
+
export interface ObservabilityOptions {
|
|
33
|
+
/** Where logs go. Default: the console consumer at `minSeverity`. */
|
|
34
|
+
loggerProvider?: LoggerProvider;
|
|
35
|
+
/** Where metrics go. Default: an in-memory recorder (what /api/status reads). */
|
|
36
|
+
meterProvider?: MeterProvider;
|
|
37
|
+
/** Threshold for the DEFAULT console consumer; ignored if one is passed in. */
|
|
38
|
+
minSeverity?: Severity;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What the app holds, and hands to anything that reports. */
|
|
42
|
+
export interface Observability {
|
|
43
|
+
/** A logger for one instrumentation scope — the `[ws]` / `[auth]` prefix. */
|
|
44
|
+
logger(scope: string): Logger;
|
|
45
|
+
/** A meter for one instrumentation scope. */
|
|
46
|
+
meter(scope: string): Meter;
|
|
47
|
+
/** Recorded metrics, when the meter provider supports reading. */
|
|
48
|
+
metrics?: MetricsReader;
|
|
49
|
+
/** Captured logs, when the logger provider supports reading (tests). */
|
|
50
|
+
logs?: LogReader;
|
|
51
|
+
/**
|
|
52
|
+
* Register these providers as the OpenTelemetry globals, so code that cannot
|
|
53
|
+
* be handed an argument still reports somewhere. Returns a function that
|
|
54
|
+
* restores whatever was there before — a test that installs globally must be
|
|
55
|
+
* able to put the process back.
|
|
56
|
+
*/
|
|
57
|
+
installGlobally(): () => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function hasMetricsReader(p: MeterProvider): p is MeterProvider & MetricsReader {
|
|
61
|
+
return typeof (p as Partial<MetricsReader>).snapshot === "function";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function hasLogReader(p: LoggerProvider): p is LoggerProvider & LogReader {
|
|
65
|
+
return typeof (p as Partial<LogReader>).records === "function";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createObservability(options: ObservabilityOptions = {}): Observability {
|
|
69
|
+
const loggerProvider =
|
|
70
|
+
options.loggerProvider ?? createConsoleLoggerProvider({ minSeverity: options.minSeverity });
|
|
71
|
+
const meterProvider = options.meterProvider ?? createInMemoryMeterProvider();
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
logger: (scope) => loggerProvider.getLogger(scope),
|
|
75
|
+
meter: (scope) => meterProvider.getMeter(scope),
|
|
76
|
+
metrics: hasMetricsReader(meterProvider) ? meterProvider : undefined,
|
|
77
|
+
logs: hasLogReader(loggerProvider) ? loggerProvider : undefined,
|
|
78
|
+
installGlobally() {
|
|
79
|
+
// The API exposes no "read the current provider object" that round-trips
|
|
80
|
+
// through disable(), so restoration is a disable rather than a swap-back.
|
|
81
|
+
// Good enough for the one case that needs it — a test cleaning up.
|
|
82
|
+
logs.setGlobalLoggerProvider(loggerProvider);
|
|
83
|
+
metrics.setGlobalMeterProvider(meterProvider);
|
|
84
|
+
return () => {
|
|
85
|
+
logs.disable();
|
|
86
|
+
metrics.disable();
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The test flavour: recording consumers on both signals, with the read APIs
|
|
94
|
+
* guaranteed present so a test does not have to narrow them.
|
|
95
|
+
*
|
|
96
|
+
* Same code path as production — only the sink differs — so an assertion here
|
|
97
|
+
* is an assertion about the real emission, not about a double.
|
|
98
|
+
*/
|
|
99
|
+
export interface RecordingObservability extends Observability {
|
|
100
|
+
metrics: InMemoryMeterProvider;
|
|
101
|
+
logs: RecordingLoggerProvider;
|
|
102
|
+
/** Forget everything captured so far. */
|
|
103
|
+
reset(): void;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function createRecordingObservability(): RecordingObservability {
|
|
107
|
+
const loggerProvider = createRecordingLoggerProvider();
|
|
108
|
+
const meterProvider = createInMemoryMeterProvider();
|
|
109
|
+
const base = createObservability({ loggerProvider, meterProvider });
|
|
110
|
+
return {
|
|
111
|
+
...base,
|
|
112
|
+
metrics: meterProvider,
|
|
113
|
+
logs: loggerProvider,
|
|
114
|
+
reset() {
|
|
115
|
+
loggerProvider.clear();
|
|
116
|
+
meterProvider.reset();
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** An observability that reports nowhere — for a consumer that wants silence. */
|
|
122
|
+
export function createSilentObservability(): Observability {
|
|
123
|
+
return createObservability({
|
|
124
|
+
loggerProvider: createSilentLoggerProvider(),
|
|
125
|
+
meterProvider: createInMemoryMeterProvider(),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export {
|
|
130
|
+
createConsoleLoggerProvider,
|
|
131
|
+
createRecordingLoggerProvider,
|
|
132
|
+
createSilentLoggerProvider,
|
|
133
|
+
} from "./loggers.js";
|
|
134
|
+
export type {
|
|
135
|
+
ConsoleLoggerProviderOptions,
|
|
136
|
+
RecordingLoggerProviderOptions,
|
|
137
|
+
RecordingLoggerProvider,
|
|
138
|
+
LogReader,
|
|
139
|
+
LogWriter,
|
|
140
|
+
} from "./loggers.js";
|
|
141
|
+
export { createInMemoryMeterProvider } from "./meter.js";
|
|
142
|
+
export type { InMemoryMeterProvider, MetricsReader } from "./meter.js";
|
|
143
|
+
export { SEVERITIES, severityRank, seriesKey } from "./types.js";
|
|
144
|
+
export type {
|
|
145
|
+
Severity,
|
|
146
|
+
CapturedLog,
|
|
147
|
+
LogQuery,
|
|
148
|
+
MetricPoint,
|
|
149
|
+
MetricSnapshot,
|
|
150
|
+
} from "./types.js";
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two log consumers: one that writes, one that remembers.
|
|
3
|
+
*
|
|
4
|
+
* Both implement `LoggerProvider` from `@opentelemetry/api-logs`, so they are
|
|
5
|
+
* interchangeable at the one call site that installs them, and so a real
|
|
6
|
+
* OpenTelemetry SDK is a third option that needs no change to any
|
|
7
|
+
* instrumentation.
|
|
8
|
+
*
|
|
9
|
+
* Why `@opentelemetry/api-logs` is pinned to an EXACT version in package.json
|
|
10
|
+
* rather than caretted: the logs API is still 0.x. That was checked rather
|
|
11
|
+
* than assumed — the producing surface used here (`getLogger().emit()`) is
|
|
12
|
+
* unchanged across the 0.57 → 0.221 range, and the global registration
|
|
13
|
+
* handshake is keyed on a backwards-compatibility constant that has stayed at
|
|
14
|
+
* 1 throughout, so two copies at different versions still interoperate. The
|
|
15
|
+
* churn that does exist is in `@opentelemetry/sdk-logs`, whose
|
|
16
|
+
* `SimpleLogRecordProcessor` changed constructor shape — which is precisely
|
|
17
|
+
* why the consumers below are ours and that package is not a dependency. If
|
|
18
|
+
* upstream ever breaks the producing surface, the whole API is ~137 lines and
|
|
19
|
+
* forking it is the stated fallback.
|
|
20
|
+
*/
|
|
21
|
+
import type { Logger, LoggerProvider, LogRecord } from "@opentelemetry/api-logs";
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
bodyToString,
|
|
25
|
+
flattenAttributes,
|
|
26
|
+
severityRank,
|
|
27
|
+
type CapturedLog,
|
|
28
|
+
type LogQuery,
|
|
29
|
+
type Severity,
|
|
30
|
+
} from "./types.js";
|
|
31
|
+
|
|
32
|
+
/** Where a console consumer sends a formatted line. Injected so tests capture it. */
|
|
33
|
+
export interface LogWriter {
|
|
34
|
+
(severity: Severity, line: string): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const defaultWriter: LogWriter = (severity, line) => {
|
|
38
|
+
if (severity === "ERROR" || severity === "FATAL") console.error(line);
|
|
39
|
+
else if (severity === "WARN") console.warn(line);
|
|
40
|
+
else console.log(line);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export interface ConsoleLoggerProviderOptions {
|
|
44
|
+
/** Records below this severity are dropped before formatting. Default INFO. */
|
|
45
|
+
minSeverity?: Severity;
|
|
46
|
+
write?: LogWriter;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Formats to the `[scope] message key=value` shape this repo already logs in,
|
|
51
|
+
* so adopting structured emission does not churn every existing log line's
|
|
52
|
+
* appearance at the same time.
|
|
53
|
+
*/
|
|
54
|
+
function formatLine(record: CapturedLog): string {
|
|
55
|
+
const attrs = Object.entries(record.attributes)
|
|
56
|
+
.filter(([, v]) => v !== undefined)
|
|
57
|
+
.map(([k, v]) => `${k}=${String(v)}`);
|
|
58
|
+
return `[${record.scope}] ${record.body}${attrs.length ? ` ${attrs.join(" ")}` : ""}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function toCaptured(scope: string, record: LogRecord): CapturedLog {
|
|
62
|
+
return {
|
|
63
|
+
scope,
|
|
64
|
+
severity: (record.severityText ?? "INFO") as Severity,
|
|
65
|
+
body: bodyToString(record.body),
|
|
66
|
+
attributes: flattenAttributes(record.attributes),
|
|
67
|
+
timestamp: Date.now(),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The production consumer: severity-filtered lines on stdout/stderr. */
|
|
72
|
+
export function createConsoleLoggerProvider(
|
|
73
|
+
options: ConsoleLoggerProviderOptions = {}
|
|
74
|
+
): LoggerProvider {
|
|
75
|
+
const threshold = severityRank(options.minSeverity ?? "INFO");
|
|
76
|
+
const write = options.write ?? defaultWriter;
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
getLogger(name: string): Logger {
|
|
80
|
+
return {
|
|
81
|
+
emit(record: LogRecord) {
|
|
82
|
+
if (severityRank(record.severityText) < threshold) return;
|
|
83
|
+
const captured = toCaptured(name, record);
|
|
84
|
+
write(captured.severity, formatLine(captured));
|
|
85
|
+
},
|
|
86
|
+
enabled(opts?: { severityNumber?: number }) {
|
|
87
|
+
void opts;
|
|
88
|
+
return true;
|
|
89
|
+
},
|
|
90
|
+
} as Logger;
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Read side of the recording consumer. */
|
|
96
|
+
export interface LogReader {
|
|
97
|
+
/** Everything captured, in emit order. */
|
|
98
|
+
records(): CapturedLog[];
|
|
99
|
+
/** Records matching every field of the query. */
|
|
100
|
+
find(query?: LogQuery): CapturedLog[];
|
|
101
|
+
/** How many records match — the common assertion. */
|
|
102
|
+
count(query?: LogQuery): number;
|
|
103
|
+
clear(): void;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface RecordingLoggerProvider extends LoggerProvider, LogReader {}
|
|
107
|
+
|
|
108
|
+
function matches(record: CapturedLog, query: LogQuery): boolean {
|
|
109
|
+
if (query.scope !== undefined && record.scope !== query.scope) return false;
|
|
110
|
+
if (query.severity !== undefined && record.severity !== query.severity) return false;
|
|
111
|
+
if (query.minSeverity !== undefined && severityRank(record.severity) < severityRank(query.minSeverity)) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
if (query.body !== undefined && !record.body.includes(query.body)) return false;
|
|
115
|
+
for (const [key, value] of Object.entries(query.attributes ?? {})) {
|
|
116
|
+
if (record.attributes[key] !== value) return false;
|
|
117
|
+
}
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface RecordingLoggerProviderOptions {
|
|
122
|
+
/**
|
|
123
|
+
* Cap on retained records. A test asserts on a handful; a long-lived process
|
|
124
|
+
* that installed this by accident should not grow without bound. Oldest are
|
|
125
|
+
* dropped first. Default 1000.
|
|
126
|
+
*/
|
|
127
|
+
limit?: number;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The consumer a test installs. Because it implements the same interface the
|
|
132
|
+
* production consumer does, a test asserts against the real emission path —
|
|
133
|
+
* the instrumentation under test cannot tell the difference.
|
|
134
|
+
*/
|
|
135
|
+
export function createRecordingLoggerProvider(
|
|
136
|
+
options: RecordingLoggerProviderOptions = {}
|
|
137
|
+
): RecordingLoggerProvider {
|
|
138
|
+
const limit = options.limit ?? 1000;
|
|
139
|
+
const captured: CapturedLog[] = [];
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
getLogger(name: string): Logger {
|
|
143
|
+
return {
|
|
144
|
+
emit(record: LogRecord) {
|
|
145
|
+
captured.push(toCaptured(name, record));
|
|
146
|
+
if (captured.length > limit) captured.splice(0, captured.length - limit);
|
|
147
|
+
},
|
|
148
|
+
enabled() {
|
|
149
|
+
return true;
|
|
150
|
+
},
|
|
151
|
+
} as Logger;
|
|
152
|
+
},
|
|
153
|
+
records: () => [...captured],
|
|
154
|
+
find: (query = {}) => captured.filter((r) => matches(r, query)),
|
|
155
|
+
count: (query = {}) => captured.reduce((n, r) => n + (matches(r, query) ? 1 : 0), 0),
|
|
156
|
+
clear: () => {
|
|
157
|
+
captured.length = 0;
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** A consumer that discards everything — the default when nothing is installed. */
|
|
163
|
+
export function createSilentLoggerProvider(): LoggerProvider {
|
|
164
|
+
return {
|
|
165
|
+
getLogger(): Logger {
|
|
166
|
+
return {
|
|
167
|
+
emit() {},
|
|
168
|
+
enabled() {
|
|
169
|
+
return false;
|
|
170
|
+
},
|
|
171
|
+
} as Logger;
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An in-process `MeterProvider` — the counters behind `/api/status`, and the
|
|
3
|
+
* assertion surface for tests.
|
|
4
|
+
*
|
|
5
|
+
* One implementation serves both on purpose. A test double that is a different
|
|
6
|
+
* implementation from the production one only proves the double works; this
|
|
7
|
+
* way, the thing a test asserts against is the thing that actually counts in
|
|
8
|
+
* production.
|
|
9
|
+
*
|
|
10
|
+
* It is NOT `@opentelemetry/sdk-metrics`. The producing side is the standard
|
|
11
|
+
* API, so instrumentation is portable and a real SDK can replace this without
|
|
12
|
+
* touching a single `counter.add()` call — but the consuming side is ours,
|
|
13
|
+
* which is what keeps the dependency surface to one stable package.
|
|
14
|
+
*
|
|
15
|
+
* Synchronous instruments (counter, up-down counter, histogram, gauge) record.
|
|
16
|
+
* The observable/async instruments satisfy the interface and do nothing: they
|
|
17
|
+
* require a collection cycle to be meaningful, and nothing here runs one. A
|
|
18
|
+
* caller reaching for one is a signal to bring in a real SDK, not to grow this.
|
|
19
|
+
*/
|
|
20
|
+
import type {
|
|
21
|
+
Attributes,
|
|
22
|
+
BatchObservableCallback,
|
|
23
|
+
Counter,
|
|
24
|
+
Gauge,
|
|
25
|
+
Histogram,
|
|
26
|
+
Meter,
|
|
27
|
+
MeterOptions,
|
|
28
|
+
MeterProvider,
|
|
29
|
+
MetricOptions,
|
|
30
|
+
Observable,
|
|
31
|
+
ObservableCounter,
|
|
32
|
+
ObservableGauge,
|
|
33
|
+
ObservableUpDownCounter,
|
|
34
|
+
UpDownCounter,
|
|
35
|
+
} from "@opentelemetry/api";
|
|
36
|
+
|
|
37
|
+
import {
|
|
38
|
+
flattenAttributes,
|
|
39
|
+
seriesKey,
|
|
40
|
+
type MetricPoint,
|
|
41
|
+
type MetricSnapshot,
|
|
42
|
+
} from "./types.js";
|
|
43
|
+
|
|
44
|
+
/** Read side of the recorded metrics. */
|
|
45
|
+
export interface MetricsReader {
|
|
46
|
+
/** Every recorded series, sorted by name then attribute key. */
|
|
47
|
+
snapshot(): MetricSnapshot;
|
|
48
|
+
/** One series' value, or undefined when nothing recorded it. */
|
|
49
|
+
value(name: string, attributes?: Attributes): number | undefined;
|
|
50
|
+
/** Summed across every attribute combination of an instrument. */
|
|
51
|
+
total(name: string): number;
|
|
52
|
+
reset(): void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface Series extends MetricPoint {
|
|
56
|
+
key: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
class Recorder {
|
|
60
|
+
private readonly series = new Map<string, Series>();
|
|
61
|
+
|
|
62
|
+
record(
|
|
63
|
+
name: string,
|
|
64
|
+
kind: MetricPoint["kind"],
|
|
65
|
+
value: number,
|
|
66
|
+
attributes: Attributes | undefined
|
|
67
|
+
): void {
|
|
68
|
+
const key = seriesKey(name, attributes);
|
|
69
|
+
const existing = this.series.get(key);
|
|
70
|
+
if (existing) {
|
|
71
|
+
// A gauge is a level, not an accumulation.
|
|
72
|
+
existing.value = kind === "gauge" ? value : existing.value + value;
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
this.series.set(key, { key, name, kind, value, attributes: flattenAttributes(attributes) });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
snapshot(): MetricSnapshot {
|
|
79
|
+
return [...this.series.values()]
|
|
80
|
+
.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
|
|
81
|
+
.map(({ name, attributes, value, kind }) => ({ name, attributes, value, kind }));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
value(name: string, attributes?: Attributes): number | undefined {
|
|
85
|
+
return this.series.get(seriesKey(name, attributes))?.value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
total(name: string): number {
|
|
89
|
+
let sum = 0;
|
|
90
|
+
for (const s of this.series.values()) if (s.name === name) sum += s.value;
|
|
91
|
+
return sum;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
reset(): void {
|
|
95
|
+
this.series.clear();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Instruments that do nothing, for the async half of the Meter interface. */
|
|
100
|
+
function noopObservable<T>(): T {
|
|
101
|
+
return {
|
|
102
|
+
addCallback() {},
|
|
103
|
+
removeCallback() {},
|
|
104
|
+
} as T;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
class InMemoryMeter implements Meter {
|
|
108
|
+
constructor(private readonly recorder: Recorder) {}
|
|
109
|
+
|
|
110
|
+
createCounter<A extends Attributes = Attributes>(
|
|
111
|
+
name: string,
|
|
112
|
+
_options?: MetricOptions
|
|
113
|
+
): Counter<A> {
|
|
114
|
+
return {
|
|
115
|
+
add: (value: number, attributes?: A) =>
|
|
116
|
+
this.recorder.record(name, "counter", value, attributes),
|
|
117
|
+
} as Counter<A>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
createUpDownCounter<A extends Attributes = Attributes>(
|
|
121
|
+
name: string,
|
|
122
|
+
_options?: MetricOptions
|
|
123
|
+
): UpDownCounter<A> {
|
|
124
|
+
return {
|
|
125
|
+
add: (value: number, attributes?: A) =>
|
|
126
|
+
this.recorder.record(name, "updowncounter", value, attributes),
|
|
127
|
+
} as UpDownCounter<A>;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
createHistogram<A extends Attributes = Attributes>(
|
|
131
|
+
name: string,
|
|
132
|
+
_options?: MetricOptions
|
|
133
|
+
): Histogram<A> {
|
|
134
|
+
return {
|
|
135
|
+
// Count of observations, not the distribution: a real SDK owns buckets.
|
|
136
|
+
record: (_value: number, attributes?: A) =>
|
|
137
|
+
this.recorder.record(name, "histogram", 1, attributes),
|
|
138
|
+
} as Histogram<A>;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
createGauge<A extends Attributes = Attributes>(
|
|
142
|
+
name: string,
|
|
143
|
+
_options?: MetricOptions
|
|
144
|
+
): Gauge<A> {
|
|
145
|
+
return {
|
|
146
|
+
record: (value: number, attributes?: A) =>
|
|
147
|
+
this.recorder.record(name, "gauge", value, attributes),
|
|
148
|
+
} as Gauge<A>;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
createObservableGauge<A extends Attributes = Attributes>(): ObservableGauge<A> {
|
|
152
|
+
return noopObservable<ObservableGauge<A>>();
|
|
153
|
+
}
|
|
154
|
+
createObservableCounter<A extends Attributes = Attributes>(): ObservableCounter<A> {
|
|
155
|
+
return noopObservable<ObservableCounter<A>>();
|
|
156
|
+
}
|
|
157
|
+
createObservableUpDownCounter<
|
|
158
|
+
A extends Attributes = Attributes,
|
|
159
|
+
>(): ObservableUpDownCounter<A> {
|
|
160
|
+
return noopObservable<ObservableUpDownCounter<A>>();
|
|
161
|
+
}
|
|
162
|
+
addBatchObservableCallback<A extends Attributes = Attributes>(
|
|
163
|
+
_callback: BatchObservableCallback<A>,
|
|
164
|
+
_observables: Observable<A>[]
|
|
165
|
+
): void {}
|
|
166
|
+
removeBatchObservableCallback<A extends Attributes = Attributes>(
|
|
167
|
+
_callback: BatchObservableCallback<A>,
|
|
168
|
+
_observables: Observable<A>[]
|
|
169
|
+
): void {}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface InMemoryMeterProvider extends MeterProvider, MetricsReader {}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* A provider whose meters all write into one recorder, so a snapshot spans
|
|
176
|
+
* every instrumentation scope in the process.
|
|
177
|
+
*/
|
|
178
|
+
export function createInMemoryMeterProvider(): InMemoryMeterProvider {
|
|
179
|
+
const recorder = new Recorder();
|
|
180
|
+
const meter = new InMemoryMeter(recorder);
|
|
181
|
+
return {
|
|
182
|
+
getMeter(_name: string, _version?: string, _options?: MeterOptions): Meter {
|
|
183
|
+
return meter;
|
|
184
|
+
},
|
|
185
|
+
snapshot: () => recorder.snapshot(),
|
|
186
|
+
value: (name, attributes) => recorder.value(name, attributes),
|
|
187
|
+
total: (name) => recorder.total(name),
|
|
188
|
+
reset: () => recorder.reset(),
|
|
189
|
+
};
|
|
190
|
+
}
|