@graphorin/server 0.6.0 → 0.7.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/CHANGELOG.md +85 -0
- package/README.md +6 -6
- package/dist/app-audit-binding.d.ts +15 -0
- package/dist/app-audit-binding.d.ts.map +1 -0
- package/dist/app-audit-binding.js +136 -0
- package/dist/app-audit-binding.js.map +1 -0
- package/dist/app-daemons.d.ts +29 -0
- package/dist/app-daemons.d.ts.map +1 -0
- package/dist/app-daemons.js +27 -0
- package/dist/app-daemons.js.map +1 -0
- package/dist/app-lifecycle.js +272 -0
- package/dist/app-lifecycle.js.map +1 -0
- package/dist/app-metrics.js +79 -0
- package/dist/app-metrics.js.map +1 -0
- package/dist/app-middleware.js +92 -0
- package/dist/app-middleware.js.map +1 -0
- package/dist/app-routes.js +131 -0
- package/dist/app-routes.js.map +1 -0
- package/dist/app-ws.js +65 -0
- package/dist/app-ws.js.map +1 -0
- package/dist/app.d.ts +16 -16
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +40 -611
- package/dist/app.js.map +1 -1
- package/dist/commentary/built-in-patterns.d.ts +1 -1
- package/dist/commentary/built-in-patterns.js +1 -1
- package/dist/commentary/built-in-patterns.js.map +1 -1
- package/dist/config.d.ts +100 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +30 -2
- package/dist/config.js.map +1 -1
- package/dist/health/checks.d.ts +15 -1
- package/dist/health/checks.d.ts.map +1 -1
- package/dist/health/checks.js +21 -0
- package/dist/health/checks.js.map +1 -1
- package/dist/health/routes.js +1 -1
- package/dist/index.d.ts +9 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +22 -26
- package/dist/index.js.map +1 -1
- package/dist/internal/context.d.ts +2 -2
- package/dist/internal/wire-error.js +20 -0
- package/dist/internal/wire-error.js.map +1 -0
- package/dist/lifecycle/pre-bind.d.ts +1 -1
- package/dist/metrics/catalog.d.ts.map +1 -1
- package/dist/metrics/catalog.js.map +1 -1
- package/dist/metrics/tools-bridge.d.ts +15 -0
- package/dist/metrics/tools-bridge.d.ts.map +1 -0
- package/dist/metrics/tools-bridge.js +103 -0
- package/dist/metrics/tools-bridge.js.map +1 -0
- package/dist/middleware/audit.d.ts +1 -1
- package/dist/middleware/auth.js +1 -1
- package/dist/middleware/csrf.js +1 -1
- package/dist/middleware/index.js +1 -1
- package/dist/middleware/scope.d.ts.map +1 -1
- package/dist/middleware/scope.js +44 -4
- package/dist/middleware/scope.js.map +1 -1
- package/dist/package.js +6 -0
- package/dist/package.js.map +1 -0
- package/dist/registry/index.d.ts +33 -0
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/index.js.map +1 -1
- package/dist/replay/routes.d.ts +1 -1
- package/dist/replay/routes.js +2 -2
- package/dist/routes/agents.d.ts.map +1 -1
- package/dist/routes/agents.js +64 -18
- package/dist/routes/agents.js.map +1 -1
- package/dist/routes/auth.js +2 -2
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/sessions.js +5 -5
- package/dist/routes/sessions.js.map +1 -1
- package/dist/routes/tokens.d.ts +1 -1
- package/dist/routes/tokens.d.ts.map +1 -1
- package/dist/routes/tokens.js +21 -1
- package/dist/routes/tokens.js.map +1 -1
- package/dist/routes/workflows.d.ts.map +1 -1
- package/dist/routes/workflows.js +165 -19
- package/dist/routes/workflows.js.map +1 -1
- package/dist/runtime/retention.d.ts +105 -0
- package/dist/runtime/retention.d.ts.map +1 -0
- package/dist/runtime/retention.js +154 -0
- package/dist/runtime/retention.js.map +1 -0
- package/dist/runtime/run-state.d.ts +2 -0
- package/dist/runtime/run-state.d.ts.map +1 -1
- package/dist/runtime/run-state.js +34 -2
- package/dist/runtime/run-state.js.map +1 -1
- package/dist/sse/events.js +3 -4
- package/dist/sse/events.js.map +1 -1
- package/dist/tools-audit-bridge.d.ts +29 -0
- package/dist/tools-audit-bridge.d.ts.map +1 -0
- package/dist/tools-audit-bridge.js +103 -0
- package/dist/tools-audit-bridge.js.map +1 -0
- package/dist/workflows/timer-daemon.d.ts +69 -0
- package/dist/workflows/timer-daemon.d.ts.map +1 -0
- package/dist/workflows/timer-daemon.js +37 -0
- package/dist/workflows/timer-daemon.js.map +1 -0
- package/dist/ws/dispatcher.d.ts +1 -1
- package/dist/ws/dispatcher.d.ts.map +1 -1
- package/dist/ws/dispatcher.js +19 -12
- package/dist/ws/dispatcher.js.map +1 -1
- package/dist/ws/index.d.ts +2 -2
- package/dist/ws/index.js +3 -3
- package/dist/ws/replay-buffer.d.ts +35 -1
- package/dist/ws/replay-buffer.d.ts.map +1 -1
- package/dist/ws/replay-buffer.js +35 -3
- package/dist/ws/replay-buffer.js.map +1 -1
- package/dist/ws/upgrade.d.ts.map +1 -1
- package/dist/ws/upgrade.js +26 -19
- package/dist/ws/upgrade.js.map +1 -1
- package/package.json +31 -30
- package/src/app-audit-binding.ts +227 -0
- package/src/app-daemons.ts +73 -0
- package/src/app-lifecycle.ts +476 -0
- package/src/app-metrics.ts +144 -0
- package/src/app-middleware.ts +149 -0
- package/src/app-routes.ts +305 -0
- package/src/app-ws.ts +111 -0
- package/src/app.ts +317 -0
- package/src/commentary/audit-bridge.ts +135 -0
- package/src/commentary/built-in-patterns.ts +79 -0
- package/src/commentary/index.ts +32 -0
- package/src/commentary/sanitizer.ts +238 -0
- package/src/commentary/types.ts +130 -0
- package/src/config.ts +472 -0
- package/src/consolidator/daemon.ts +141 -0
- package/src/consolidator/index.ts +14 -0
- package/src/errors/index.ts +247 -0
- package/src/health/checks.ts +322 -0
- package/src/health/index.ts +34 -0
- package/src/health/routes.ts +154 -0
- package/src/index.ts +243 -0
- package/src/internal/context.ts +77 -0
- package/src/internal/ids.ts +17 -0
- package/src/internal/json.ts +47 -0
- package/src/internal/wire-error.ts +44 -0
- package/src/lifecycle/hooks.ts +66 -0
- package/src/lifecycle/index.ts +16 -0
- package/src/lifecycle/pre-bind.ts +138 -0
- package/src/metrics/catalog.ts +112 -0
- package/src/metrics/index.ts +12 -0
- package/src/metrics/registry.ts +337 -0
- package/src/metrics/tools-bridge.ts +124 -0
- package/src/middleware/audit.ts +114 -0
- package/src/middleware/auth.ts +195 -0
- package/src/middleware/cors.ts +72 -0
- package/src/middleware/csrf.ts +98 -0
- package/src/middleware/idempotency.ts +336 -0
- package/src/middleware/index.ts +38 -0
- package/src/middleware/rate-limit.ts +71 -0
- package/src/middleware/request-state.ts +79 -0
- package/src/middleware/scope.ts +161 -0
- package/src/registry/index.ts +278 -0
- package/src/replay/index.ts +14 -0
- package/src/replay/routes.ts +255 -0
- package/src/routes/agents.ts +363 -0
- package/src/routes/audit.ts +207 -0
- package/src/routes/auth.ts +80 -0
- package/src/routes/health.ts +42 -0
- package/src/routes/index.ts +16 -0
- package/src/routes/mcp.ts +97 -0
- package/src/routes/memory.ts +152 -0
- package/src/routes/sessions.ts +250 -0
- package/src/routes/skills.ts +91 -0
- package/src/routes/tokens.ts +166 -0
- package/src/routes/workflows.ts +616 -0
- package/src/runtime/retention.ts +284 -0
- package/src/runtime/run-state.ts +422 -0
- package/src/sse/events.ts +303 -0
- package/src/sse/index.ts +7 -0
- package/src/tools-audit-bridge.ts +120 -0
- package/src/triggers/daemon.ts +198 -0
- package/src/triggers/index.ts +16 -0
- package/src/triggers/routes.ts +139 -0
- package/src/workflows/timer-daemon.ts +102 -0
- package/src/ws/dispatcher.ts +537 -0
- package/src/ws/index.ts +45 -0
- package/src/ws/replay-buffer.ts +209 -0
- package/src/ws/subjects.ts +162 -0
- package/src/ws/ticket.ts +174 -0
- package/src/ws/upgrade.ts +507 -0
- package/dist/lifecycle/index.js +0 -3
- package/dist/routes/index.js +0 -12
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-bind validation. Runs *before* the HTTP listener attaches so a
|
|
3
|
+
* misconfigured server never accepts a single request.
|
|
4
|
+
*
|
|
5
|
+
* Steps (fail-fast on every failure):
|
|
6
|
+
*
|
|
7
|
+
* 1. Resolve every `*Ref` field on the config through the
|
|
8
|
+
* `@graphorin/security` resolver registry. Missing ref → exit 1.
|
|
9
|
+
* 2. Confirm the server pepper resolved to non-empty bytes.
|
|
10
|
+
* 3. Confirm the encryption peer is loadable when
|
|
11
|
+
* `storage.encryption.enabled === true` (or `audit.enabled === true`).
|
|
12
|
+
* 4. Run pending storage migrations - failures bubble up as
|
|
13
|
+
* `MigrationFailedError`.
|
|
14
|
+
*
|
|
15
|
+
* The function is idempotent - calling it more than once for the
|
|
16
|
+
* same {@link createSqliteStore} handle re-validates the secrets but
|
|
17
|
+
* leaves the underlying database untouched.
|
|
18
|
+
*
|
|
19
|
+
* @packageDocumentation
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { SecretValue } from '@graphorin/security';
|
|
23
|
+
|
|
24
|
+
import { resolveSecret } from '@graphorin/security/secrets';
|
|
25
|
+
import type { GraphorinSqliteStore } from '@graphorin/store-sqlite';
|
|
26
|
+
|
|
27
|
+
import type { ServerConfigSpec } from '../config.js';
|
|
28
|
+
import {
|
|
29
|
+
MigrationFailedError,
|
|
30
|
+
PrebindEncryptionPeerMissingError,
|
|
31
|
+
PrebindEncryptionRequiredError,
|
|
32
|
+
PrebindPepperMissingError,
|
|
33
|
+
PrebindSecretUnresolvableError,
|
|
34
|
+
} from '../errors/index.js';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Result returned by {@link runPreBind}. Consumers (`createServer`,
|
|
38
|
+
* tests) consume the resolved pepper + encryption decision when
|
|
39
|
+
* wiring the rest of the server.
|
|
40
|
+
*
|
|
41
|
+
* @stable
|
|
42
|
+
*/
|
|
43
|
+
export interface PreBindResult {
|
|
44
|
+
readonly pepper?: SecretValue;
|
|
45
|
+
readonly auditPath?: string;
|
|
46
|
+
readonly auditPassphrase?: SecretValue;
|
|
47
|
+
readonly storagePassphrase?: SecretValue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @stable
|
|
52
|
+
*/
|
|
53
|
+
export interface RunPreBindOptions {
|
|
54
|
+
readonly config: ServerConfigSpec;
|
|
55
|
+
readonly store: GraphorinSqliteStore;
|
|
56
|
+
/**
|
|
57
|
+
* Optional override for the cipher-peer probe. Tests inject a stub
|
|
58
|
+
* that signals "missing peer" without uninstalling the real one.
|
|
59
|
+
*/
|
|
60
|
+
readonly probeCipherPeer?: () => Promise<void>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function resolveRef(path: ReadonlyArray<string | number>, raw: string): Promise<SecretValue> {
|
|
64
|
+
try {
|
|
65
|
+
return await resolveSecret(raw);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
throw new PrebindSecretUnresolvableError(path, raw, err);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @stable
|
|
73
|
+
*/
|
|
74
|
+
export async function runPreBind(options: RunPreBindOptions): Promise<PreBindResult> {
|
|
75
|
+
const { config, store } = options;
|
|
76
|
+
const out: { -readonly [K in keyof PreBindResult]?: PreBindResult[K] } = {};
|
|
77
|
+
|
|
78
|
+
if (config.auth.kind === 'token') {
|
|
79
|
+
if (config.auth.pepperRef === undefined || config.auth.pepperRef.length === 0) {
|
|
80
|
+
throw new PrebindPepperMissingError();
|
|
81
|
+
}
|
|
82
|
+
const pepper = await resolveRef(['auth', 'pepperRef'], config.auth.pepperRef);
|
|
83
|
+
const buf = await pepper.useBuffer((b) => Buffer.from(b));
|
|
84
|
+
if (buf.length === 0) {
|
|
85
|
+
throw new PrebindPepperMissingError();
|
|
86
|
+
}
|
|
87
|
+
out.pepper = pepper;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (config.storage.encryption.enabled) {
|
|
91
|
+
if (config.storage.encryption.passphraseRef === undefined) {
|
|
92
|
+
throw new PrebindEncryptionRequiredError(
|
|
93
|
+
'storage.encryption.enabled is true but no passphraseRef is configured.',
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
out.storagePassphrase = await resolveRef(
|
|
97
|
+
['storage', 'encryption', 'passphraseRef'],
|
|
98
|
+
config.storage.encryption.passphraseRef,
|
|
99
|
+
);
|
|
100
|
+
if (options.probeCipherPeer !== undefined) {
|
|
101
|
+
try {
|
|
102
|
+
await options.probeCipherPeer();
|
|
103
|
+
} catch (err) {
|
|
104
|
+
throw new PrebindEncryptionPeerMissingError(err);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (config.audit.enabled) {
|
|
110
|
+
const passphraseRef = config.audit.passphraseRef ?? config.storage.encryption.passphraseRef;
|
|
111
|
+
if (passphraseRef === undefined) {
|
|
112
|
+
throw new PrebindEncryptionRequiredError(
|
|
113
|
+
'audit.enabled is true but no audit.passphraseRef (or storage.encryption.passphraseRef) is configured. Audit logs are mandatory-encrypted (DEC-124).',
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
out.auditPath = config.audit.path ?? deriveAuditPath(config.storage.path);
|
|
117
|
+
out.auditPassphrase = await resolveRef(['audit', 'passphraseRef'], passphraseRef);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
await store.init();
|
|
122
|
+
} catch (err) {
|
|
123
|
+
throw new MigrationFailedError(`Storage migrations failed: ${describeError(err)}`, err);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return Object.freeze(out as PreBindResult);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function deriveAuditPath(storagePath: string): string {
|
|
130
|
+
const idx = storagePath.lastIndexOf('/');
|
|
131
|
+
if (idx < 0) return 'audit.db';
|
|
132
|
+
return `${storagePath.slice(0, idx)}/audit.db`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function describeError(err: unknown): string {
|
|
136
|
+
if (err instanceof Error) return err.message;
|
|
137
|
+
return String(err);
|
|
138
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical metric inventory for the standalone server. Every metric
|
|
3
|
+
* the framework emits is registered here in one place so the
|
|
4
|
+
* exposition output is deterministic across builds.
|
|
5
|
+
*
|
|
6
|
+
* Metric naming follows the documented `graphorin_*` prefix
|
|
7
|
+
* discipline; label cardinality is bounded by construction (no PII
|
|
8
|
+
* labels - see DEC-141 cross-cut).
|
|
9
|
+
*
|
|
10
|
+
* @packageDocumentation
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { MetricRegistry } from './registry.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @stable
|
|
17
|
+
*/
|
|
18
|
+
// IP-15: the catalog lists only metrics the server actually moves. Five
|
|
19
|
+
// previously-registered series - `graphorin_tool_calls_total`,
|
|
20
|
+
// `graphorin_provider_tokens_total`, `graphorin_provider_cost_usd_total`,
|
|
21
|
+
// `graphorin_redaction_drops_total` and `graphorin_oauth_tokens_freshness_seconds`
|
|
22
|
+
// - had no producer anywhere in the monorepo (per-tool and provider usage live
|
|
23
|
+
// in the agent runtime, not the server; redaction drops in the observability
|
|
24
|
+
// layer; OAuth freshness needs an MCP token store the server does not own). A
|
|
25
|
+
// permanently-empty series is worse than an absent one for dashboards, so they
|
|
26
|
+
// are dropped until a real producer exists rather than advertised inert.
|
|
27
|
+
// W-051 update: tool/MCP telemetry NOW has a producer - the scrape-time
|
|
28
|
+
// `metrics/tools-bridge.ts` sync lazily registers
|
|
29
|
+
// `graphorin_tool_*` / `graphorin_mcp_*` series from the tools package's
|
|
30
|
+
// live counter snapshot, so only series that actually moved ever appear
|
|
31
|
+
// (consistent with IP-15: nothing advertised inert).
|
|
32
|
+
export const SERVER_METRIC_NAMES = Object.freeze({
|
|
33
|
+
agentRunsTotal: 'graphorin_agent_runs_total',
|
|
34
|
+
agentRunDuration: 'graphorin_agent_run_duration_seconds',
|
|
35
|
+
storageWalSize: 'graphorin_storage_wal_size_bytes',
|
|
36
|
+
idempotencyCacheHitRatio: 'graphorin_idempotency_cache_hit_ratio',
|
|
37
|
+
triggersFiresTotal: 'graphorin_triggers_fires_total',
|
|
38
|
+
consolidatorQueueDepth: 'graphorin_consolidator_queue_depth',
|
|
39
|
+
consolidatorDlqSize: 'graphorin_consolidator_dlq_size',
|
|
40
|
+
consolidatorBudgetRemainingUsd: 'graphorin_consolidator_budget_remaining_usd',
|
|
41
|
+
consolidatorBudgetRemainingTokens: 'graphorin_consolidator_budget_remaining_tokens',
|
|
42
|
+
replayBufferEvents: 'graphorin_replay_buffer_events',
|
|
43
|
+
inflightRuns: 'graphorin_inflight_runs',
|
|
44
|
+
serverUptime: 'graphorin_server_uptime_seconds',
|
|
45
|
+
buildInfo: 'graphorin_build_info',
|
|
46
|
+
} as const);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build a fully-registered {@link MetricRegistry} ready for the
|
|
50
|
+
* `/v1/metrics` exposition. The returned registry has every metric
|
|
51
|
+
* declared but no samples; the server runtime updates samples
|
|
52
|
+
* incrementally.
|
|
53
|
+
*
|
|
54
|
+
* @stable
|
|
55
|
+
*/
|
|
56
|
+
export function createServerMetricRegistry(): MetricRegistry {
|
|
57
|
+
const registry = new MetricRegistry();
|
|
58
|
+
|
|
59
|
+
registry.registerCounter(
|
|
60
|
+
SERVER_METRIC_NAMES.agentRunsTotal,
|
|
61
|
+
'Total agent runs by terminal status.',
|
|
62
|
+
['status'],
|
|
63
|
+
);
|
|
64
|
+
registry.registerSummary(
|
|
65
|
+
SERVER_METRIC_NAMES.agentRunDuration,
|
|
66
|
+
'Agent run duration in seconds.',
|
|
67
|
+
[],
|
|
68
|
+
);
|
|
69
|
+
registry.registerGauge(SERVER_METRIC_NAMES.storageWalSize, 'Current SQLite WAL size in bytes.');
|
|
70
|
+
registry.registerGauge(
|
|
71
|
+
SERVER_METRIC_NAMES.idempotencyCacheHitRatio,
|
|
72
|
+
'Idempotency LRU cache hit ratio in [0, 1].',
|
|
73
|
+
);
|
|
74
|
+
registry.registerCounter(
|
|
75
|
+
SERVER_METRIC_NAMES.triggersFiresTotal,
|
|
76
|
+
'Total trigger fires by trigger id and outcome.',
|
|
77
|
+
['trigger_id', 'status'],
|
|
78
|
+
);
|
|
79
|
+
registry.registerGauge(
|
|
80
|
+
SERVER_METRIC_NAMES.consolidatorQueueDepth,
|
|
81
|
+
'Pending consolidator items by phase.',
|
|
82
|
+
['phase'],
|
|
83
|
+
);
|
|
84
|
+
registry.registerGauge(
|
|
85
|
+
SERVER_METRIC_NAMES.consolidatorDlqSize,
|
|
86
|
+
'Number of consolidator runs sitting in the dead-letter queue.',
|
|
87
|
+
);
|
|
88
|
+
registry.registerGauge(
|
|
89
|
+
SERVER_METRIC_NAMES.consolidatorBudgetRemainingUsd,
|
|
90
|
+
'Consolidator USD budget remaining for the current period.',
|
|
91
|
+
);
|
|
92
|
+
registry.registerGauge(
|
|
93
|
+
SERVER_METRIC_NAMES.consolidatorBudgetRemainingTokens,
|
|
94
|
+
'Consolidator token budget remaining for the current period.',
|
|
95
|
+
);
|
|
96
|
+
registry.registerGauge(
|
|
97
|
+
SERVER_METRIC_NAMES.replayBufferEvents,
|
|
98
|
+
'Number of events currently buffered for WebSocket replay.',
|
|
99
|
+
);
|
|
100
|
+
registry.registerGauge(
|
|
101
|
+
SERVER_METRIC_NAMES.inflightRuns,
|
|
102
|
+
'Number of in-flight agent / workflow runs.',
|
|
103
|
+
);
|
|
104
|
+
registry.registerGauge(SERVER_METRIC_NAMES.serverUptime, 'Server process uptime in seconds.');
|
|
105
|
+
registry.registerGauge(
|
|
106
|
+
SERVER_METRIC_NAMES.buildInfo,
|
|
107
|
+
'Static `1` gauge carrying graphorin build labels.',
|
|
108
|
+
['version'],
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
return registry;
|
|
112
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@graphorin/server/metrics` - Phase 14c Prometheus exposition layer.
|
|
3
|
+
*
|
|
4
|
+
* @packageDocumentation
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { createServerMetricRegistry, SERVER_METRIC_NAMES } from './catalog.js';
|
|
8
|
+
export {
|
|
9
|
+
type LabelSet,
|
|
10
|
+
type MetricKind,
|
|
11
|
+
MetricRegistry,
|
|
12
|
+
} from './registry.js';
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny Prometheus exposition layer. The framework intentionally does
|
|
3
|
+
* not pull in `prom-client` so the bundle stays lean and so the
|
|
4
|
+
* exposition is byte-stable across processes (`prom-client` adds
|
|
5
|
+
* default counters that drift between Node releases).
|
|
6
|
+
*
|
|
7
|
+
* The supported metric kinds are the three the runtime spec calls
|
|
8
|
+
* out: `counter`, `gauge`, and `summary` (with hard-coded p50/p95
|
|
9
|
+
* quantiles). Sample rendering follows Prometheus text exposition
|
|
10
|
+
* format v0.0.4 - every metric block is preceded by `# HELP` + `# TYPE`
|
|
11
|
+
* and labels are quoted-escaped per the spec.
|
|
12
|
+
*
|
|
13
|
+
* @packageDocumentation
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** @stable */
|
|
17
|
+
export type MetricKind = 'counter' | 'gauge' | 'summary';
|
|
18
|
+
|
|
19
|
+
/** @stable */
|
|
20
|
+
export type LabelSet = Readonly<Record<string, string | number | boolean>>;
|
|
21
|
+
|
|
22
|
+
interface MetricDefinition {
|
|
23
|
+
readonly name: string;
|
|
24
|
+
readonly help: string;
|
|
25
|
+
readonly kind: MetricKind;
|
|
26
|
+
readonly labelNames: ReadonlyArray<string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface CounterEntry {
|
|
30
|
+
readonly labels: LabelSet;
|
|
31
|
+
value: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface GaugeEntry {
|
|
35
|
+
readonly labels: LabelSet;
|
|
36
|
+
value: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface SummaryEntry {
|
|
40
|
+
readonly labels: LabelSet;
|
|
41
|
+
count: number;
|
|
42
|
+
sum: number;
|
|
43
|
+
/** Recent samples (capped) used to compute quantiles. */
|
|
44
|
+
samples: number[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SUMMARY_SAMPLE_CAP = 1024;
|
|
48
|
+
const SUMMARY_QUANTILES = Object.freeze([0.5, 0.95]);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Lightweight Prometheus registry. Each instance owns its metric
|
|
52
|
+
* catalogue + per-label samples; `render()` emits the canonical text
|
|
53
|
+
* exposition block.
|
|
54
|
+
*
|
|
55
|
+
* @stable
|
|
56
|
+
*/
|
|
57
|
+
export class MetricRegistry {
|
|
58
|
+
readonly #definitions = new Map<string, MetricDefinition>();
|
|
59
|
+
readonly #counters = new Map<string, Map<string, CounterEntry>>();
|
|
60
|
+
readonly #gauges = new Map<string, Map<string, GaugeEntry>>();
|
|
61
|
+
readonly #summaries = new Map<string, Map<string, SummaryEntry>>();
|
|
62
|
+
|
|
63
|
+
registerCounter(name: string, help: string, labelNames: ReadonlyArray<string> = []): void {
|
|
64
|
+
this.#register(name, help, 'counter', labelNames);
|
|
65
|
+
if (!this.#counters.has(name)) this.#counters.set(name, new Map());
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
registerGauge(name: string, help: string, labelNames: ReadonlyArray<string> = []): void {
|
|
69
|
+
this.#register(name, help, 'gauge', labelNames);
|
|
70
|
+
if (!this.#gauges.has(name)) this.#gauges.set(name, new Map());
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
registerSummary(name: string, help: string, labelNames: ReadonlyArray<string> = []): void {
|
|
74
|
+
this.#register(name, help, 'summary', labelNames);
|
|
75
|
+
if (!this.#summaries.has(name)) this.#summaries.set(name, new Map());
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
inc(name: string, labels: LabelSet = {}, by = 1): void {
|
|
79
|
+
this.#assertKind(name, 'counter');
|
|
80
|
+
assertLabelNames(labels);
|
|
81
|
+
const bucket = this.#counters.get(name);
|
|
82
|
+
if (bucket === undefined) {
|
|
83
|
+
throw new Error(`MetricRegistry.inc: counter '${name}' is not registered`);
|
|
84
|
+
}
|
|
85
|
+
const key = serializeLabelKey(labels);
|
|
86
|
+
const entry = bucket.get(key) ?? { labels: { ...labels }, value: 0 };
|
|
87
|
+
if (!Number.isFinite(by)) {
|
|
88
|
+
throw new Error(`MetricRegistry.inc(${name}): by must be finite`);
|
|
89
|
+
}
|
|
90
|
+
if (by < 0) {
|
|
91
|
+
throw new Error(`MetricRegistry.inc(${name}): counters never decrement`);
|
|
92
|
+
}
|
|
93
|
+
entry.value += by;
|
|
94
|
+
bucket.set(key, entry);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
set(name: string, value: number, labels: LabelSet = {}): void {
|
|
98
|
+
this.#assertKind(name, 'gauge');
|
|
99
|
+
assertLabelNames(labels);
|
|
100
|
+
const bucket = this.#gauges.get(name);
|
|
101
|
+
if (bucket === undefined) {
|
|
102
|
+
throw new Error(`MetricRegistry.set: gauge '${name}' is not registered`);
|
|
103
|
+
}
|
|
104
|
+
const key = serializeLabelKey(labels);
|
|
105
|
+
bucket.set(key, { labels: { ...labels }, value });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
observe(name: string, value: number, labels: LabelSet = {}): void {
|
|
109
|
+
this.#assertKind(name, 'summary');
|
|
110
|
+
assertLabelNames(labels);
|
|
111
|
+
const bucket = this.#summaries.get(name);
|
|
112
|
+
if (bucket === undefined) {
|
|
113
|
+
throw new Error(`MetricRegistry.observe: summary '${name}' is not registered`);
|
|
114
|
+
}
|
|
115
|
+
const key = serializeLabelKey(labels);
|
|
116
|
+
const entry = bucket.get(key) ?? { labels: { ...labels }, count: 0, sum: 0, samples: [] };
|
|
117
|
+
entry.count += 1;
|
|
118
|
+
entry.sum += value;
|
|
119
|
+
entry.samples.push(value);
|
|
120
|
+
if (entry.samples.length > SUMMARY_SAMPLE_CAP) {
|
|
121
|
+
entry.samples.shift();
|
|
122
|
+
}
|
|
123
|
+
bucket.set(key, entry);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
reset(): void {
|
|
127
|
+
this.#counters.clear();
|
|
128
|
+
this.#gauges.clear();
|
|
129
|
+
this.#summaries.clear();
|
|
130
|
+
for (const [name, def] of this.#definitions) {
|
|
131
|
+
switch (def.kind) {
|
|
132
|
+
case 'counter':
|
|
133
|
+
this.#counters.set(name, new Map());
|
|
134
|
+
break;
|
|
135
|
+
case 'gauge':
|
|
136
|
+
this.#gauges.set(name, new Map());
|
|
137
|
+
break;
|
|
138
|
+
case 'summary':
|
|
139
|
+
this.#summaries.set(name, new Map());
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Render the current snapshot in Prometheus text exposition
|
|
147
|
+
* format (v0.0.4). Never throws - incomplete sample buckets are
|
|
148
|
+
* skipped instead of failing the scrape.
|
|
149
|
+
*/
|
|
150
|
+
render(): string {
|
|
151
|
+
const lines: string[] = [];
|
|
152
|
+
for (const def of this.#definitions.values()) {
|
|
153
|
+
lines.push(`# HELP ${def.name} ${escapeHelp(def.help)}`);
|
|
154
|
+
lines.push(`# TYPE ${def.name} ${def.kind}`);
|
|
155
|
+
switch (def.kind) {
|
|
156
|
+
case 'counter': {
|
|
157
|
+
const bucket = this.#counters.get(def.name);
|
|
158
|
+
if (bucket !== undefined) {
|
|
159
|
+
for (const entry of bucket.values()) {
|
|
160
|
+
lines.push(formatSample(def.name, entry.labels, entry.value));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
case 'gauge': {
|
|
166
|
+
const bucket = this.#gauges.get(def.name);
|
|
167
|
+
if (bucket !== undefined) {
|
|
168
|
+
for (const entry of bucket.values()) {
|
|
169
|
+
lines.push(formatSample(def.name, entry.labels, entry.value));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
case 'summary': {
|
|
175
|
+
const bucket = this.#summaries.get(def.name);
|
|
176
|
+
if (bucket !== undefined) {
|
|
177
|
+
for (const entry of bucket.values()) {
|
|
178
|
+
for (const q of SUMMARY_QUANTILES) {
|
|
179
|
+
const value = computeQuantile(entry.samples, q);
|
|
180
|
+
if (value === undefined) continue;
|
|
181
|
+
lines.push(
|
|
182
|
+
formatSample(def.name, { ...entry.labels, quantile: q.toString() }, value),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
lines.push(formatSample(`${def.name}_sum`, entry.labels, entry.sum));
|
|
186
|
+
lines.push(formatSample(`${def.name}_count`, entry.labels, entry.count));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return `${lines.join('\n')}\n`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
contentType(): string {
|
|
197
|
+
return 'text/plain; version=0.0.4; charset=utf-8';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Snapshot for tests / assertions. */
|
|
201
|
+
snapshot(): {
|
|
202
|
+
counters: Record<string, ReadonlyArray<{ labels: LabelSet; value: number }>>;
|
|
203
|
+
gauges: Record<string, ReadonlyArray<{ labels: LabelSet; value: number }>>;
|
|
204
|
+
summaries: Record<
|
|
205
|
+
string,
|
|
206
|
+
ReadonlyArray<{
|
|
207
|
+
labels: LabelSet;
|
|
208
|
+
count: number;
|
|
209
|
+
sum: number;
|
|
210
|
+
samples: ReadonlyArray<number>;
|
|
211
|
+
}>
|
|
212
|
+
>;
|
|
213
|
+
} {
|
|
214
|
+
const counters: Record<string, ReadonlyArray<{ labels: LabelSet; value: number }>> = {};
|
|
215
|
+
for (const [name, bucket] of this.#counters) {
|
|
216
|
+
counters[name] = [...bucket.values()].map((entry) => ({
|
|
217
|
+
labels: entry.labels,
|
|
218
|
+
value: entry.value,
|
|
219
|
+
}));
|
|
220
|
+
}
|
|
221
|
+
const gauges: Record<string, ReadonlyArray<{ labels: LabelSet; value: number }>> = {};
|
|
222
|
+
for (const [name, bucket] of this.#gauges) {
|
|
223
|
+
gauges[name] = [...bucket.values()].map((entry) => ({
|
|
224
|
+
labels: entry.labels,
|
|
225
|
+
value: entry.value,
|
|
226
|
+
}));
|
|
227
|
+
}
|
|
228
|
+
const summaries: Record<
|
|
229
|
+
string,
|
|
230
|
+
ReadonlyArray<{
|
|
231
|
+
labels: LabelSet;
|
|
232
|
+
count: number;
|
|
233
|
+
sum: number;
|
|
234
|
+
samples: ReadonlyArray<number>;
|
|
235
|
+
}>
|
|
236
|
+
> = {};
|
|
237
|
+
for (const [name, bucket] of this.#summaries) {
|
|
238
|
+
summaries[name] = [...bucket.values()].map((entry) => ({
|
|
239
|
+
labels: entry.labels,
|
|
240
|
+
count: entry.count,
|
|
241
|
+
sum: entry.sum,
|
|
242
|
+
samples: [...entry.samples],
|
|
243
|
+
}));
|
|
244
|
+
}
|
|
245
|
+
return { counters, gauges, summaries };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
#register(name: string, help: string, kind: MetricKind, labelNames: ReadonlyArray<string>): void {
|
|
249
|
+
if (!METRIC_NAME_RE.test(name)) {
|
|
250
|
+
throw new Error(`MetricRegistry: invalid metric name '${name}'`);
|
|
251
|
+
}
|
|
252
|
+
const existing = this.#definitions.get(name);
|
|
253
|
+
if (existing !== undefined) {
|
|
254
|
+
if (existing.kind !== kind) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`MetricRegistry: metric '${name}' already registered as '${existing.kind}', cannot redefine as '${kind}'`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
this.#definitions.set(name, {
|
|
262
|
+
name,
|
|
263
|
+
help,
|
|
264
|
+
kind,
|
|
265
|
+
labelNames: Object.freeze([...labelNames]),
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
#assertKind(name: string, kind: MetricKind): void {
|
|
270
|
+
const def = this.#definitions.get(name);
|
|
271
|
+
if (def === undefined) {
|
|
272
|
+
throw new Error(`MetricRegistry: unknown metric '${name}'`);
|
|
273
|
+
}
|
|
274
|
+
if (def.kind !== kind) {
|
|
275
|
+
throw new Error(`MetricRegistry: metric '${name}' is a ${def.kind}, not a ${kind}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const METRIC_NAME_RE = /^[a-zA-Z_:][a-zA-Z0-9_:]*$/;
|
|
281
|
+
const LABEL_NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
282
|
+
|
|
283
|
+
function assertLabelNames(labels: LabelSet): void {
|
|
284
|
+
for (const key of Object.keys(labels)) {
|
|
285
|
+
if (!LABEL_NAME_RE.test(key)) {
|
|
286
|
+
throw new Error(`MetricRegistry: invalid label name '${key}'`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function serializeLabelKey(labels: LabelSet): string {
|
|
292
|
+
const keys = Object.keys(labels).sort();
|
|
293
|
+
if (keys.length === 0) return '';
|
|
294
|
+
const parts: string[] = [];
|
|
295
|
+
for (const k of keys) parts.push(`${k}=${String(labels[k])}`);
|
|
296
|
+
return parts.join('|');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function formatSample(name: string, labels: LabelSet, value: number): string {
|
|
300
|
+
const labelStr = formatLabels(labels);
|
|
301
|
+
return `${name}${labelStr} ${formatNumber(value)}`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function formatLabels(labels: LabelSet): string {
|
|
305
|
+
const keys = Object.keys(labels).sort();
|
|
306
|
+
if (keys.length === 0) return '';
|
|
307
|
+
const parts: string[] = [];
|
|
308
|
+
for (const k of keys) {
|
|
309
|
+
if (!LABEL_NAME_RE.test(k)) {
|
|
310
|
+
throw new Error(`MetricRegistry: invalid label name '${k}'`);
|
|
311
|
+
}
|
|
312
|
+
parts.push(`${k}="${escapeLabelValue(String(labels[k]))}"`);
|
|
313
|
+
}
|
|
314
|
+
return `{${parts.join(',')}}`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function escapeLabelValue(value: string): string {
|
|
318
|
+
return value.replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/"/g, '\\"');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function escapeHelp(help: string): string {
|
|
322
|
+
return help.replace(/\\/g, '\\\\').replace(/\n/g, '\\n');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function formatNumber(value: number): string {
|
|
326
|
+
if (Number.isNaN(value)) return 'NaN';
|
|
327
|
+
if (!Number.isFinite(value)) return value > 0 ? '+Inf' : '-Inf';
|
|
328
|
+
if (Number.isInteger(value)) return String(value);
|
|
329
|
+
return value.toString();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function computeQuantile(samples: ReadonlyArray<number>, q: number): number | undefined {
|
|
333
|
+
if (samples.length === 0) return undefined;
|
|
334
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
335
|
+
const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil(q * sorted.length) - 1));
|
|
336
|
+
return sorted[idx];
|
|
337
|
+
}
|