@ferrule-io/ok-fine 0.3.13 → 0.3.14

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/dist/config.js CHANGED
@@ -88,6 +88,7 @@ const storageEnvShape = {
88
88
  MAX_FILE_BYTES: intEnv("MAX_FILE_BYTES", "1048576", 1),
89
89
  MAX_ARCHIVE_BYTES: intEnv("MAX_ARCHIVE_BYTES", "52428800", 1),
90
90
  DEFAULT_STALE_AFTER_DAYS: optionalIntEnv("DEFAULT_STALE_AFTER_DAYS", 1, 36500),
91
+ METRICS_RETENTION_DAYS: intEnv("METRICS_RETENTION_DAYS", "30", 1, 3650),
91
92
  PROJECT_ACCESS: z.string().optional(),
92
93
  PROJECT_ACCESS_FILE: z.string().optional(),
93
94
  };
@@ -346,6 +347,7 @@ function storageFromRaw(raw, env, options) {
346
347
  maxFileBytes: raw.MAX_FILE_BYTES,
347
348
  maxArchiveBytes: raw.MAX_ARCHIVE_BYTES,
348
349
  defaultStaleAfterDays: raw.DEFAULT_STALE_AFTER_DAYS,
350
+ metricsRetentionDays: raw.METRICS_RETENTION_DAYS,
349
351
  projectAccess: parseProjectAccess(raw.PROJECT_ACCESS, raw.PROJECT_ACCESS_FILE),
350
352
  };
351
353
  }
@@ -0,0 +1,11 @@
1
+ import { z } from "zod";
2
+ import { METRICS_WINDOWS } from "../metrics/types.js";
3
+ const metricsQuery = z.object({ window: z.enum(METRICS_WINDOWS).default("24h") });
4
+ /** Reads MetricsStore directly: metrics are not knowledge, and the service stays free of database access. */
5
+ export function registerMetricsRoutes(app, metrics) {
6
+ app.get("/api/v1/metrics", { config: { permission: "read" } }, async (req) => {
7
+ const { window } = metricsQuery.parse(req.query);
8
+ return metrics.report(window);
9
+ });
10
+ }
11
+ //# sourceMappingURL=metrics.js.map
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Inclusive upper bounds (ms) of the latency histogram buckets; one overflow bucket follows the last.
3
+ * Changing them changes the database columns: bump SCHEMA_VERSION in store.ts.
4
+ */
5
+ export const LATENCY_BUCKETS_MS = [
6
+ 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000,
7
+ ];
8
+ export const BUCKET_COUNT = LATENCY_BUCKETS_MS.length + 1;
9
+ export function bucketIndex(ms) {
10
+ const i = LATENCY_BUCKETS_MS.findIndex((upper) => ms <= upper);
11
+ return i === -1 ? BUCKET_COUNT - 1 : i;
12
+ }
13
+ /** Estimates quantile q (0..1) by linear interpolation inside the bucket holding the rank, clamped to [minMs, maxMs]. */
14
+ export function quantile(buckets, q, minMs, maxMs) {
15
+ let total = 0;
16
+ for (const c of buckets)
17
+ total += c;
18
+ if (total === 0)
19
+ return 0;
20
+ const rank = q * total;
21
+ let cum = 0;
22
+ for (let i = 0; i < buckets.length; i++) {
23
+ const c = buckets[i] ?? 0;
24
+ if (c === 0)
25
+ continue;
26
+ if (cum + c >= rank) {
27
+ const lower = i === 0 ? 0 : (LATENCY_BUCKETS_MS[i - 1] ?? 0);
28
+ const upper = i < LATENCY_BUCKETS_MS.length ? (LATENCY_BUCKETS_MS[i] ?? maxMs) : maxMs;
29
+ const value = lower + (upper - lower) * ((rank - cum) / c);
30
+ return Math.min(Math.max(value, minMs), maxMs);
31
+ }
32
+ cum += c;
33
+ }
34
+ return maxMs;
35
+ }
36
+ //# sourceMappingURL=histogram.js.map
@@ -0,0 +1,146 @@
1
+ import { performance } from "node:perf_hooks";
2
+ import { finished, Readable } from "node:stream";
3
+ import { OkfError } from "../errors.js";
4
+ /** The service reports client errors only as OkfError with a 4xx status; anything else is a server error. */
5
+ export function outcomeOf(err) {
6
+ return err instanceof OkfError && err.status < 500 ? "client_error" : "server_error";
7
+ }
8
+ async function time(sink, layer, op, fn) {
9
+ const startedAt = performance.now();
10
+ try {
11
+ const value = await fn();
12
+ sink.record(layer, op, startedAt, "ok");
13
+ return value;
14
+ }
15
+ catch (err) {
16
+ sink.record(layer, op, startedAt, outcomeOf(err));
17
+ throw err;
18
+ }
19
+ }
20
+ /** Records once the stream ends, errors, or is destroyed. */
21
+ function timeStream(sink, layer, op, startedAt, stream) {
22
+ finished(stream, (err) => sink.record(layer, op, startedAt, err ? outcomeOf(err) : "ok"));
23
+ return stream;
24
+ }
25
+ class InstrumentedTx {
26
+ inner;
27
+ sink;
28
+ constructor(inner, sink) {
29
+ this.inner = inner;
30
+ this.sink = sink;
31
+ }
32
+ writeFile(project, path, content) {
33
+ return time(this.sink, "storage", "tx.writeFile", () => this.inner.writeFile(project, path, content));
34
+ }
35
+ deleteFile(project, path) {
36
+ return time(this.sink, "storage", "tx.deleteFile", () => this.inner.deleteFile(project, path));
37
+ }
38
+ deleteProject(project) {
39
+ return time(this.sink, "storage", "tx.deleteProject", () => this.inner.deleteProject(project));
40
+ }
41
+ replaceProject(project, source) {
42
+ return time(this.sink, "storage", "tx.replaceProject", () => this.inner.replaceProject(project, source));
43
+ }
44
+ resolveConflict(project, id) {
45
+ return time(this.sink, "storage", "tx.resolveConflict", () => this.inner.resolveConflict(project, id));
46
+ }
47
+ }
48
+ /** Times every StorageBackend and StorageTx call under layer `storage`, op = method name. */
49
+ export class InstrumentedStorage {
50
+ inner;
51
+ sink;
52
+ constructor(inner, sink) {
53
+ this.inner = inner;
54
+ this.sink = sink;
55
+ }
56
+ projects() {
57
+ return time(this.sink, "storage", "projects", () => this.inner.projects());
58
+ }
59
+ tree(project) {
60
+ return time(this.sink, "storage", "tree", () => this.inner.tree(project));
61
+ }
62
+ readFile(project, path) {
63
+ return time(this.sink, "storage", "readFile", () => this.inner.readFile(project, path));
64
+ }
65
+ /** Duration includes lock wait, the work, commit, and push. */
66
+ transaction(spec, work) {
67
+ return time(this.sink, "storage", "transaction", () => this.inner.transaction(spec, (tx) => work(new InstrumentedTx(tx, this.sink))));
68
+ }
69
+ setResyncHandler(handler) {
70
+ this.inner.setResyncHandler((project, tx) => handler(project, new InstrumentedTx(tx, this.sink)));
71
+ }
72
+ history(project, path, limit) {
73
+ return time(this.sink, "storage", "history", () => this.inner.history(project, path, limit));
74
+ }
75
+ archive(project) {
76
+ const startedAt = performance.now();
77
+ let stream;
78
+ try {
79
+ stream = this.inner.archive(project);
80
+ }
81
+ catch (err) {
82
+ this.sink.record("storage", "archive", startedAt, outcomeOf(err));
83
+ throw err;
84
+ }
85
+ return timeStream(this.sink, "storage", "archive", startedAt, stream);
86
+ }
87
+ sync() {
88
+ return time(this.sink, "storage", "sync", () => this.inner.sync());
89
+ }
90
+ syncStatus() {
91
+ return time(this.sink, "storage", "syncStatus", () => this.inner.syncStatus());
92
+ }
93
+ conflicts(project) {
94
+ return time(this.sink, "storage", "conflicts", () => this.inner.conflicts(project));
95
+ }
96
+ readConflictFile(project, id, side, path) {
97
+ return time(this.sink, "storage", "readConflictFile", () => this.inner.readConflictFile(project, id, side, path));
98
+ }
99
+ close() {
100
+ return this.inner.close();
101
+ }
102
+ }
103
+ /**
104
+ * Times every KnowledgeService method call under layer `service`, op = method name. A Proxy covers methods added
105
+ * later; methods run bound to the raw service, so its internal `this.*` calls are not counted twice.
106
+ */
107
+ export function instrumentService(service, sink) {
108
+ const wrappers = new Map();
109
+ return new Proxy(service, {
110
+ get(target, prop) {
111
+ const value = Reflect.get(target, prop);
112
+ if (typeof prop !== "string" || prop === "constructor" || typeof value !== "function")
113
+ return value;
114
+ let wrapper = wrappers.get(prop);
115
+ if (!wrapper) {
116
+ wrapper = (...args) => {
117
+ const startedAt = performance.now();
118
+ let result;
119
+ try {
120
+ result = Reflect.apply(value, target, args);
121
+ }
122
+ catch (err) {
123
+ sink.record("service", prop, startedAt, outcomeOf(err));
124
+ throw err;
125
+ }
126
+ if (result instanceof Promise) {
127
+ return result.then((v) => {
128
+ sink.record("service", prop, startedAt, "ok");
129
+ return v;
130
+ }, (err) => {
131
+ sink.record("service", prop, startedAt, outcomeOf(err));
132
+ throw err;
133
+ });
134
+ }
135
+ if (result instanceof Readable)
136
+ return timeStream(sink, "service", prop, startedAt, result);
137
+ sink.record("service", prop, startedAt, "ok");
138
+ return result;
139
+ };
140
+ wrappers.set(prop, wrapper);
141
+ }
142
+ return wrapper;
143
+ },
144
+ });
145
+ }
146
+ //# sourceMappingURL=instrument.js.map
@@ -0,0 +1,298 @@
1
+ import { rmSync } from "node:fs";
2
+ import { performance } from "node:perf_hooks";
3
+ import { BUCKET_COUNT, bucketIndex, quantile } from "./histogram.js";
4
+ import { METRICS_LAYERS, } from "./types.js";
5
+ export const METRICS_DB_FILE = "metrics.sqlite";
6
+ /** Bump when the tables or LATENCY_BUCKETS_MS change; a mismatching database is rebuilt (metrics are disposable). */
7
+ const SCHEMA_VERSION = 1;
8
+ const FLUSH_INTERVAL_MS = 10_000;
9
+ const MINUTE_RETENTION_SECONDS = 172_800;
10
+ const PRUNE_INTERVAL_SECONDS = 3_600;
11
+ const WINDOWS = {
12
+ "1h": { seconds: 3_600, table: "metrics_minute", stepSeconds: 60 },
13
+ "24h": { seconds: 86_400, table: "metrics_minute", stepSeconds: 900 },
14
+ "7d": { seconds: 604_800, table: "metrics_hour", stepSeconds: 7_200 },
15
+ "30d": { seconds: 2_592_000, table: "metrics_hour", stepSeconds: 21_600 },
16
+ };
17
+ const TABLES = ["metrics_minute", "metrics_hour"];
18
+ const BUCKET_COLUMNS = Array.from({ length: BUCKET_COUNT }, (_, i) => `b${String(i).padStart(2, "0")}`);
19
+ const VALUE_COLUMNS = ["count", "client_errors", "server_errors", "sum_ms", "min_ms", "max_ms", ...BUCKET_COLUMNS];
20
+ const SUMS = [
21
+ "SUM(count) AS count",
22
+ "SUM(client_errors) AS client_errors",
23
+ "SUM(server_errors) AS server_errors",
24
+ "SUM(sum_ms) AS sum_ms",
25
+ "MIN(min_ms) AS min_ms",
26
+ "MAX(max_ms) AS max_ms",
27
+ ...BUCKET_COLUMNS.map((c) => `SUM(${c}) AS ${c}`),
28
+ ].join(", ");
29
+ function createSchema(db) {
30
+ const columns = [
31
+ "bucket_start INTEGER NOT NULL",
32
+ "layer TEXT NOT NULL",
33
+ "op TEXT NOT NULL",
34
+ "count INTEGER NOT NULL",
35
+ "client_errors INTEGER NOT NULL",
36
+ "server_errors INTEGER NOT NULL",
37
+ "sum_ms REAL NOT NULL",
38
+ "min_ms REAL NOT NULL",
39
+ "max_ms REAL NOT NULL",
40
+ ...BUCKET_COLUMNS.map((c) => `${c} INTEGER NOT NULL`),
41
+ "PRIMARY KEY (bucket_start, layer, op)",
42
+ ].join(", ");
43
+ for (const table of TABLES) {
44
+ db.exec(`DROP TABLE IF EXISTS ${table}`);
45
+ db.exec(`CREATE TABLE ${table} (${columns}) WITHOUT ROWID`);
46
+ }
47
+ db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
48
+ }
49
+ function prepare(db) {
50
+ const perTable = (sql) => ({
51
+ metrics_minute: db.prepare(sql("metrics_minute")),
52
+ metrics_hour: db.prepare(sql("metrics_hour")),
53
+ });
54
+ const insertColumns = ["bucket_start", "layer", "op", ...VALUE_COLUMNS];
55
+ const updates = VALUE_COLUMNS.map((c) => c === "min_ms" || c === "max_ms" ? `${c} = ${c.slice(0, 3)}(${c}, excluded.${c})` : `${c} = ${c} + excluded.${c}`).join(", ");
56
+ return {
57
+ upsert: perTable((t) => `INSERT INTO ${t} (${insertColumns.join(", ")}) VALUES (${insertColumns.map(() => "?").join(", ")}) ` +
58
+ `ON CONFLICT (bucket_start, layer, op) DO UPDATE SET ${updates}`),
59
+ prune: perTable((t) => `DELETE FROM ${t} WHERE bucket_start < ?`),
60
+ operations: perTable((t) => `SELECT layer, op, ${SUMS} FROM ${t} WHERE bucket_start >= ? GROUP BY layer, op`),
61
+ // `bucket_start - (bucket_start % ?)`, not `/ ? * ?`: node:sqlite binds JS numbers as REAL, so `/` would not truncate.
62
+ series: perTable((t) => `SELECT bucket_start - (bucket_start % ?) AS t, layer, ${SUMS} FROM ${t} WHERE bucket_start >= ? ` +
63
+ "GROUP BY t, layer ORDER BY t, layer"),
64
+ };
65
+ }
66
+ function num(row, key) {
67
+ const v = row[key];
68
+ return typeof v === "number" ? v : typeof v === "bigint" ? Number(v) : 0;
69
+ }
70
+ function str(row, key) {
71
+ const v = row[key];
72
+ return typeof v === "string" ? v : "";
73
+ }
74
+ function totalsOf(row) {
75
+ return {
76
+ count: num(row, "count"),
77
+ clientErrors: num(row, "client_errors"),
78
+ serverErrors: num(row, "server_errors"),
79
+ sumMs: num(row, "sum_ms"),
80
+ minMs: num(row, "min_ms"),
81
+ maxMs: num(row, "max_ms"),
82
+ buckets: BUCKET_COLUMNS.map((c) => num(row, c)),
83
+ };
84
+ }
85
+ function merge(into, from) {
86
+ into.count += from.count;
87
+ into.clientErrors += from.clientErrors;
88
+ into.serverErrors += from.serverErrors;
89
+ into.sumMs += from.sumMs;
90
+ into.minMs = Math.min(into.minMs, from.minMs);
91
+ into.maxMs = Math.max(into.maxMs, from.maxMs);
92
+ for (let i = 0; i < BUCKET_COUNT; i++)
93
+ into.buckets[i] = (into.buckets[i] ?? 0) + (from.buckets[i] ?? 0);
94
+ }
95
+ const round3 = (ms) => Math.round(ms * 1000) / 1000;
96
+ function statsOf(t) {
97
+ return {
98
+ count: t.count,
99
+ clientErrors: t.clientErrors,
100
+ serverErrors: t.serverErrors,
101
+ avgMs: round3(t.count === 0 ? 0 : t.sumMs / t.count),
102
+ maxMs: round3(t.maxMs),
103
+ p50Ms: round3(quantile(t.buckets, 0.5, t.minMs, t.maxMs)),
104
+ p95Ms: round3(quantile(t.buckets, 0.95, t.minMs, t.maxMs)),
105
+ p99Ms: round3(quantile(t.buckets, 0.99, t.minMs, t.maxMs)),
106
+ };
107
+ }
108
+ const iso = (sec) => new Date(sec * 1000).toISOString();
109
+ /**
110
+ * Per-minute call aggregates (count, errors, latency histogram) in SQLite under DATA_DIR, rolled up hourly.
111
+ * Observations buffer in memory and flush every FLUSH_INTERVAL_MS; a crash loses at most one interval.
112
+ */
113
+ export class MetricsStore {
114
+ db;
115
+ statements;
116
+ retentionDays;
117
+ log;
118
+ now;
119
+ pending = new Map();
120
+ lastPruneSec;
121
+ closed = false;
122
+ timer;
123
+ constructor(db, statements, retentionDays, log, now) {
124
+ this.db = db;
125
+ this.statements = statements;
126
+ this.retentionDays = retentionDays;
127
+ this.log = log;
128
+ this.now = now;
129
+ this.lastPruneSec = Math.floor(now() / 1000);
130
+ this.prune(this.lastPruneSec);
131
+ this.timer = setInterval(() => this.flush(), FLUSH_INTERVAL_MS);
132
+ this.timer.unref();
133
+ }
134
+ static async open(options) {
135
+ // Loaded lazily: node:sqlite prints an ExperimentalWarning on load, so only processes that open storage pay it,
136
+ // not every `ok-fine --help` or stdio-proxy start.
137
+ const { DatabaseSync } = await import("node:sqlite");
138
+ const connect = () => {
139
+ let db;
140
+ try {
141
+ db = new DatabaseSync(options.path);
142
+ // A filesystem without WAL support keeps rollback journaling; the result is ignored.
143
+ db.exec("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA busy_timeout = 1000");
144
+ const version = db.prepare("PRAGMA user_version").get();
145
+ if (!version || num(version, "user_version") !== SCHEMA_VERSION)
146
+ createSchema(db);
147
+ return { db, statements: prepare(db) };
148
+ }
149
+ catch (err) {
150
+ if (db?.isOpen)
151
+ db.close();
152
+ throw err;
153
+ }
154
+ };
155
+ let opened;
156
+ try {
157
+ opened = connect();
158
+ }
159
+ catch (err) {
160
+ options.log.warn({ err, path: options.path }, "metrics database unusable; recreating it");
161
+ for (const suffix of ["", "-wal", "-shm"])
162
+ rmSync(`${options.path}${suffix}`, { force: true });
163
+ opened = connect();
164
+ }
165
+ return new MetricsStore(opened.db, opened.statements, options.retentionDays, options.log, options.now ?? Date.now);
166
+ }
167
+ record(layer, op, startedAt, outcome) {
168
+ this.observe(layer, op, performance.now() - startedAt, outcome);
169
+ }
170
+ observe(layer, op, durationMs, outcome) {
171
+ if (this.closed)
172
+ return;
173
+ const minuteStart = Math.floor(this.now() / 60_000) * 60;
174
+ const key = `${minuteStart}\u0000${layer}\u0000${op}`;
175
+ let p = this.pending.get(key);
176
+ if (!p) {
177
+ p = {
178
+ minuteStart,
179
+ layer,
180
+ op,
181
+ count: 0,
182
+ clientErrors: 0,
183
+ serverErrors: 0,
184
+ sumMs: 0,
185
+ minMs: durationMs,
186
+ maxMs: durationMs,
187
+ buckets: new Array(BUCKET_COUNT).fill(0),
188
+ };
189
+ this.pending.set(key, p);
190
+ }
191
+ p.count++;
192
+ if (outcome === "client_error")
193
+ p.clientErrors++;
194
+ if (outcome === "server_error")
195
+ p.serverErrors++;
196
+ p.sumMs += durationMs;
197
+ p.minMs = Math.min(p.minMs, durationMs);
198
+ p.maxMs = Math.max(p.maxMs, durationMs);
199
+ const i = bucketIndex(durationMs);
200
+ p.buckets[i] = (p.buckets[i] ?? 0) + 1;
201
+ }
202
+ /** Writes buffered observations (and prunes when due) in one transaction. Never throws. */
203
+ flush() {
204
+ if (this.closed)
205
+ return;
206
+ const batch = this.pending;
207
+ this.pending = new Map();
208
+ const nowSec = Math.floor(this.now() / 1000);
209
+ const pruneDue = nowSec - this.lastPruneSec >= PRUNE_INTERVAL_SECONDS;
210
+ if (batch.size === 0 && !pruneDue)
211
+ return;
212
+ try {
213
+ this.db.exec("BEGIN");
214
+ for (const p of batch.values()) {
215
+ const values = [
216
+ p.layer,
217
+ p.op,
218
+ p.count,
219
+ p.clientErrors,
220
+ p.serverErrors,
221
+ p.sumMs,
222
+ p.minMs,
223
+ p.maxMs,
224
+ ...p.buckets,
225
+ ];
226
+ this.statements.upsert.metrics_minute.run(p.minuteStart, ...values);
227
+ this.statements.upsert.metrics_hour.run(p.minuteStart - (p.minuteStart % 3600), ...values);
228
+ }
229
+ if (pruneDue)
230
+ this.prune(nowSec);
231
+ this.db.exec("COMMIT");
232
+ }
233
+ catch (err) {
234
+ if (this.db.isTransaction)
235
+ this.db.exec("ROLLBACK");
236
+ this.log.warn({ err }, "metrics flush failed; dropped one batch");
237
+ }
238
+ }
239
+ prune(nowSec) {
240
+ this.statements.prune.metrics_minute.run(nowSec - MINUTE_RETENTION_SECONDS);
241
+ this.statements.prune.metrics_hour.run(nowSec - this.retentionDays * 86_400);
242
+ this.lastPruneSec = nowSec;
243
+ }
244
+ report(window) {
245
+ this.flush();
246
+ const spec = WINDOWS[window];
247
+ const nowSec = Math.floor(this.now() / 1000);
248
+ const start = nowSec - spec.seconds;
249
+ const fromSec = start - (start % spec.stepSeconds);
250
+ const layerTotals = new Map();
251
+ const operations = [];
252
+ for (const row of this.statements.operations[spec.table].all(fromSec)) {
253
+ const layer = METRICS_LAYERS.find((l) => l === str(row, "layer"));
254
+ if (!layer)
255
+ continue;
256
+ const totals = totalsOf(row);
257
+ operations.push({ layer, op: str(row, "op"), ...statsOf(totals) });
258
+ const sum = layerTotals.get(layer);
259
+ if (sum)
260
+ merge(sum, totals);
261
+ else
262
+ layerTotals.set(layer, { ...totals, buckets: [...totals.buckets] });
263
+ }
264
+ operations.sort((a, b) => METRICS_LAYERS.indexOf(a.layer) - METRICS_LAYERS.indexOf(b.layer) ||
265
+ b.count - a.count ||
266
+ (a.op < b.op ? -1 : a.op > b.op ? 1 : 0));
267
+ const layers = METRICS_LAYERS.flatMap((layer) => {
268
+ const totals = layerTotals.get(layer);
269
+ return totals ? [{ layer, ...statsOf(totals) }] : [];
270
+ });
271
+ const series = [];
272
+ for (const row of this.statements.series[spec.table].all(spec.stepSeconds, fromSec)) {
273
+ const layer = METRICS_LAYERS.find((l) => l === str(row, "layer"));
274
+ if (!layer)
275
+ continue;
276
+ const stats = statsOf(totalsOf(row));
277
+ series.push({
278
+ at: iso(num(row, "t")),
279
+ layer,
280
+ count: stats.count,
281
+ serverErrors: stats.serverErrors,
282
+ avgMs: stats.avgMs,
283
+ p95Ms: stats.p95Ms,
284
+ });
285
+ }
286
+ return { window, from: iso(fromSec), to: iso(nowSec), stepSeconds: spec.stepSeconds, layers, operations, series };
287
+ }
288
+ /** Idempotent: stops the timer, flushes, and closes the database. */
289
+ close() {
290
+ if (this.closed)
291
+ return;
292
+ clearInterval(this.timer);
293
+ this.flush();
294
+ this.closed = true;
295
+ this.db.close();
296
+ }
297
+ }
298
+ //# sourceMappingURL=store.js.map
@@ -0,0 +1,4 @@
1
+ // Wire types of GET /api/v1/metrics. No imports: the web UI imports this module.
2
+ export const METRICS_LAYERS = ["service", "storage"];
3
+ export const METRICS_WINDOWS = ["1h", "24h", "7d", "30d"];
4
+ //# sourceMappingURL=types.js.map
package/dist/server.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { join } from "node:path";
1
2
  import { setTimeout as sleep } from "node:timers/promises";
2
3
  import { buildOAuthProtectedResourceMetadata, createMcpHandler, getOAuthProtectedResourceMetadataUrl, } from "@modelcontextprotocol/server";
3
4
  import Fastify, {} from "fastify";
@@ -7,10 +8,13 @@ import { createAuthenticator } from "./auth/http-auth.js";
7
8
  import { JwtTokenVerifier, principalFromAuthInfo } from "./auth/verifier.js";
8
9
  import { OkfError } from "./errors.js";
9
10
  import { registerMcpRoute } from "./http/mcp-route.js";
11
+ import { registerMetricsRoutes } from "./http/metrics.js";
10
12
  import { registerRestRoutes } from "./http/rest.js";
11
13
  import { DEFAULT_UI_DIR, endpointOrigins, registerUiRoutes } from "./http/ui.js";
12
14
  import { createLocalHost } from "./local-host.js";
13
15
  import { createMcpServer } from "./mcp/server.js";
16
+ import { InstrumentedStorage, instrumentService } from "./metrics/instrument.js";
17
+ import { METRICS_DB_FILE, MetricsStore } from "./metrics/store.js";
14
18
  import { KnowledgeService } from "./service/knowledge-service.js";
15
19
  import { Catalog } from "./store/catalog.js";
16
20
  import { GitBackend } from "./store/git-backend.js";
@@ -205,10 +209,8 @@ export async function startServer(config, options = {}) {
205
209
  },
206
210
  };
207
211
  }
208
- const storage = await GitBackend.open(config, app.log);
209
- const catalog = new Catalog();
210
- const service = new KnowledgeService({ config, storage, catalog, log: app.log });
211
- await service.initialize();
212
+ const knowledge = await openKnowledge(config, app.log);
213
+ const service = knowledge.service;
212
214
  const mcpHandler = createMcpHandler(({ authInfo }) => {
213
215
  if (!authInfo)
214
216
  throw new Error("MCP request reached the handler without authInfo");
@@ -270,6 +272,7 @@ export async function startServer(config, options = {}) {
270
272
  });
271
273
  registerMcpRoute(secured, (request, options) => mcpHandler.fetch(request, options), config.publicBaseUrl);
272
274
  registerRestRoutes(secured, service);
275
+ registerMetricsRoutes(secured, knowledge.metrics);
273
276
  });
274
277
  const stopSync = startPeriodicSync(config, service, app.log);
275
278
  let localHost;
@@ -283,7 +286,7 @@ export async function startServer(config, options = {}) {
283
286
  catch (err) {
284
287
  stopSync();
285
288
  await localHost?.close();
286
- await storage.close();
289
+ await knowledge.close();
287
290
  await mcpHandler.close();
288
291
  throw err;
289
292
  }
@@ -294,11 +297,43 @@ export async function startServer(config, options = {}) {
294
297
  stopSync();
295
298
  await localHost?.close();
296
299
  await app.close();
297
- await storage.close();
300
+ await knowledge.close();
298
301
  await mcpHandler.close();
299
302
  },
300
303
  };
301
304
  }
305
+ /** Opens git storage, the metrics database under DATA_DIR, and the instrumented service on top of them. */
306
+ async function openKnowledge(config, log) {
307
+ // Creates DATA_DIR, which the metrics database lives in.
308
+ const git = await GitBackend.open(config, log);
309
+ let metrics;
310
+ try {
311
+ metrics = await MetricsStore.open({
312
+ path: join(config.dataDir, METRICS_DB_FILE),
313
+ retentionDays: config.metricsRetentionDays,
314
+ log,
315
+ });
316
+ }
317
+ catch (err) {
318
+ await git.close();
319
+ throw err;
320
+ }
321
+ const storage = new InstrumentedStorage(git, metrics);
322
+ const close = async () => {
323
+ await storage.close();
324
+ metrics.close();
325
+ };
326
+ // Not instrumented yet: the startup catalog rebuild records storage calls but no service op.
327
+ const raw = new KnowledgeService({ config, storage, catalog: new Catalog(), log });
328
+ try {
329
+ await raw.initialize();
330
+ }
331
+ catch (err) {
332
+ await close();
333
+ throw err;
334
+ }
335
+ return { service: instrumentService(raw, metrics), metrics, close };
336
+ }
302
337
  /** Runs `service.syncNow()` every GIT_SYNC_INTERVAL_SECONDS when a remote is configured; returns a stop function. */
303
338
  function startPeriodicSync(config, service, log) {
304
339
  if (!config.gitRemoteUrl || config.gitSyncIntervalSeconds <= 0)
@@ -326,10 +361,8 @@ function startPeriodicSync(config, service, log) {
326
361
  /** Starts the local endpoint host that listens on a domain socket / named pipe. */
327
362
  export async function startLocalHost(config, options) {
328
363
  const { log } = options;
329
- const storage = await GitBackend.open(config, log);
330
- const catalog = new Catalog();
331
- const service = new KnowledgeService({ config, storage, catalog, log });
332
- await service.initialize();
364
+ const knowledge = await openKnowledge(config, log);
365
+ const service = knowledge.service;
333
366
  const stopSync = startPeriodicSync(config, service, log);
334
367
  const host = await createLocalHost({ service, config, log });
335
368
  let closing;
@@ -340,7 +373,7 @@ export async function startLocalHost(config, options) {
340
373
  stopSync();
341
374
  await host.close();
342
375
  // Waits for the storage mutex, so an in-flight write still commits.
343
- await storage.close();
376
+ await knowledge.close();
344
377
  })();
345
378
  return closing;
346
379
  },