@momentumcms/plugins-otel 0.5.3 → 0.5.5
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/index.cjs +1074 -26
- package/index.js +1082 -26
- package/lib/otel-admin-routes.cjs +780 -0
- package/lib/otel-admin-routes.js +788 -0
- package/package.json +61 -36
- package/src/index.d.ts +1 -1
- package/src/lib/api/otel-api-guards.d.ts +27 -0
- package/src/lib/api/otel-query-handler.d.ts +20 -0
- package/src/lib/exporters/prometheus-handler.d.ts +20 -0
- package/src/lib/metrics/collection-metrics.d.ts +21 -0
- package/src/lib/metrics/metrics-snapshot-service.d.ts +31 -0
- package/src/lib/metrics/metrics-store.d.ts +31 -0
- package/src/lib/metrics/otel-helpers.d.ts +42 -0
- package/src/lib/metrics/otel-snapshot-collection.d.ts +7 -0
- package/src/lib/metrics/request-metrics.d.ts +19 -0
- package/src/lib/otel-admin-routes.d.ts +3 -0
- package/src/lib/otel-plugin.d.ts +11 -7
- package/src/lib/otel-plugin.types.d.ts +79 -1
- package/CHANGELOG.md +0 -106
- package/LICENSE +0 -21
package/index.cjs
CHANGED
|
@@ -58,10 +58,10 @@ var ANSI = {
|
|
|
58
58
|
bgRed: "\x1B[41m",
|
|
59
59
|
bgYellow: "\x1B[43m"
|
|
60
60
|
};
|
|
61
|
-
function colorize(
|
|
61
|
+
function colorize(text2, ...codes) {
|
|
62
62
|
if (codes.length === 0)
|
|
63
|
-
return
|
|
64
|
-
return `${codes.join("")}${
|
|
63
|
+
return text2;
|
|
64
|
+
return `${codes.join("")}${text2}${ANSI.reset}`;
|
|
65
65
|
}
|
|
66
66
|
function supportsColor() {
|
|
67
67
|
if (process.env["FORCE_COLOR"] === "1")
|
|
@@ -84,14 +84,14 @@ var LEVEL_COLORS = {
|
|
|
84
84
|
function padLevel(level) {
|
|
85
85
|
return level.toUpperCase().padEnd(5);
|
|
86
86
|
}
|
|
87
|
-
function formatTimestamp(
|
|
88
|
-
const y =
|
|
89
|
-
const mo = String(
|
|
90
|
-
const d = String(
|
|
91
|
-
const h = String(
|
|
92
|
-
const mi = String(
|
|
93
|
-
const s = String(
|
|
94
|
-
const ms = String(
|
|
87
|
+
function formatTimestamp(date2) {
|
|
88
|
+
const y = date2.getFullYear();
|
|
89
|
+
const mo = String(date2.getMonth() + 1).padStart(2, "0");
|
|
90
|
+
const d = String(date2.getDate()).padStart(2, "0");
|
|
91
|
+
const h = String(date2.getHours()).padStart(2, "0");
|
|
92
|
+
const mi = String(date2.getMinutes()).padStart(2, "0");
|
|
93
|
+
const s = String(date2.getSeconds()).padStart(2, "0");
|
|
94
|
+
const ms = String(date2.getMilliseconds()).padStart(3, "0");
|
|
95
95
|
return `${y}-${mo}-${d} ${h}:${mi}:${s}.${ms}`;
|
|
96
96
|
}
|
|
97
97
|
function formatData(data) {
|
|
@@ -241,7 +241,929 @@ function isResolvedConfig(config) {
|
|
|
241
241
|
typeof config.errorOutput === "function";
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
+
// libs/logger/src/lib/logger-singleton.ts
|
|
245
|
+
var loggerInstance = null;
|
|
246
|
+
var ROOT_CONTEXT = "Momentum";
|
|
247
|
+
function getMomentumLogger() {
|
|
248
|
+
if (!loggerInstance) {
|
|
249
|
+
loggerInstance = new MomentumLogger(ROOT_CONTEXT);
|
|
250
|
+
}
|
|
251
|
+
return loggerInstance;
|
|
252
|
+
}
|
|
253
|
+
function createLogger(context) {
|
|
254
|
+
return getMomentumLogger().child(context);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// libs/plugins/otel/src/lib/metrics/metrics-store.ts
|
|
258
|
+
var MetricsStore = class {
|
|
259
|
+
constructor(maxSpans = 100) {
|
|
260
|
+
this.startTime = Date.now();
|
|
261
|
+
this.recentSpans = [];
|
|
262
|
+
this.collectionMetrics = /* @__PURE__ */ new Map();
|
|
263
|
+
this.activeRequests = 0;
|
|
264
|
+
// HTTP request metrics
|
|
265
|
+
this.totalRequests = 0;
|
|
266
|
+
this.totalDurationMs = 0;
|
|
267
|
+
this.errorCount = 0;
|
|
268
|
+
this.byMethod = /* @__PURE__ */ new Map();
|
|
269
|
+
this.byStatusCode = /* @__PURE__ */ new Map();
|
|
270
|
+
this.maxSpans = maxSpans;
|
|
271
|
+
}
|
|
272
|
+
recordSpan(span) {
|
|
273
|
+
this.recentSpans.push(span);
|
|
274
|
+
if (this.recentSpans.length > this.maxSpans) {
|
|
275
|
+
this.recentSpans.shift();
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
recordCollectionOperation(collection, operation, durationMs) {
|
|
279
|
+
let entry = this.collectionMetrics.get(collection);
|
|
280
|
+
if (!entry) {
|
|
281
|
+
entry = { creates: 0, updates: 0, deletes: 0, totalDurationMs: 0, operationCount: 0 };
|
|
282
|
+
this.collectionMetrics.set(collection, entry);
|
|
283
|
+
}
|
|
284
|
+
if (operation === "create")
|
|
285
|
+
entry.creates++;
|
|
286
|
+
else if (operation === "update")
|
|
287
|
+
entry.updates++;
|
|
288
|
+
else if (operation === "delete")
|
|
289
|
+
entry.deletes++;
|
|
290
|
+
entry.totalDurationMs += durationMs;
|
|
291
|
+
entry.operationCount++;
|
|
292
|
+
}
|
|
293
|
+
recordHttpRequest(method, statusCode, durationMs) {
|
|
294
|
+
this.totalRequests++;
|
|
295
|
+
this.totalDurationMs += durationMs;
|
|
296
|
+
if (statusCode >= 400)
|
|
297
|
+
this.errorCount++;
|
|
298
|
+
const methodUpper = method.toUpperCase();
|
|
299
|
+
this.byMethod.set(methodUpper, (this.byMethod.get(methodUpper) ?? 0) + 1);
|
|
300
|
+
const statusKey = String(statusCode);
|
|
301
|
+
this.byStatusCode.set(statusKey, (this.byStatusCode.get(statusKey) ?? 0) + 1);
|
|
302
|
+
}
|
|
303
|
+
incrementActiveRequests() {
|
|
304
|
+
this.activeRequests++;
|
|
305
|
+
}
|
|
306
|
+
decrementActiveRequests() {
|
|
307
|
+
this.activeRequests = Math.max(0, this.activeRequests - 1);
|
|
308
|
+
}
|
|
309
|
+
getSnapshotData() {
|
|
310
|
+
const collectionMetrics = this.buildCollectionMetrics();
|
|
311
|
+
const topSpans = [...this.recentSpans].sort((a, b) => b.durationMs - a.durationMs).slice(0, 5);
|
|
312
|
+
return {
|
|
313
|
+
totalRequests: this.totalRequests,
|
|
314
|
+
errorCount: this.errorCount,
|
|
315
|
+
avgDurationMs: this.totalRequests > 0 ? Math.round(this.totalDurationMs / this.totalRequests) : 0,
|
|
316
|
+
memoryUsageMb: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
|
|
317
|
+
byMethod: Object.fromEntries(this.byMethod),
|
|
318
|
+
byStatusCode: Object.fromEntries(this.byStatusCode),
|
|
319
|
+
collectionMetrics,
|
|
320
|
+
topSpans
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
restore(snapshot) {
|
|
324
|
+
this.totalRequests = snapshot.totalRequests;
|
|
325
|
+
this.errorCount = snapshot.errorCount;
|
|
326
|
+
this.totalDurationMs = snapshot.avgDurationMs * snapshot.totalRequests;
|
|
327
|
+
this.byMethod.clear();
|
|
328
|
+
for (const [method, count] of Object.entries(snapshot.byMethod)) {
|
|
329
|
+
this.byMethod.set(method, count);
|
|
330
|
+
}
|
|
331
|
+
this.byStatusCode.clear();
|
|
332
|
+
for (const [status, count] of Object.entries(snapshot.byStatusCode)) {
|
|
333
|
+
this.byStatusCode.set(status, count);
|
|
334
|
+
}
|
|
335
|
+
this.collectionMetrics.clear();
|
|
336
|
+
for (const cm of snapshot.collectionMetrics) {
|
|
337
|
+
const totalOps = cm.creates + cm.updates + cm.deletes;
|
|
338
|
+
this.collectionMetrics.set(cm.collection, {
|
|
339
|
+
creates: cm.creates,
|
|
340
|
+
updates: cm.updates,
|
|
341
|
+
deletes: cm.deletes,
|
|
342
|
+
totalDurationMs: cm.avgDurationMs * totalOps,
|
|
343
|
+
operationCount: totalOps
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
buildCollectionMetrics() {
|
|
348
|
+
return Array.from(this.collectionMetrics.entries()).map(
|
|
349
|
+
([collection, entry]) => ({
|
|
350
|
+
collection,
|
|
351
|
+
creates: entry.creates,
|
|
352
|
+
updates: entry.updates,
|
|
353
|
+
deletes: entry.deletes,
|
|
354
|
+
avgDurationMs: entry.operationCount > 0 ? Math.round(entry.totalDurationMs / entry.operationCount) : 0
|
|
355
|
+
})
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
getSummary() {
|
|
359
|
+
const collectionMetrics = this.buildCollectionMetrics();
|
|
360
|
+
return {
|
|
361
|
+
uptime: Math.round((Date.now() - this.startTime) / 1e3),
|
|
362
|
+
activeRequests: this.activeRequests,
|
|
363
|
+
memoryUsageMb: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
|
|
364
|
+
requestMetrics: {
|
|
365
|
+
totalRequests: this.totalRequests,
|
|
366
|
+
avgDurationMs: this.totalRequests > 0 ? Math.round(this.totalDurationMs / this.totalRequests) : 0,
|
|
367
|
+
errorCount: this.errorCount,
|
|
368
|
+
byMethod: Object.fromEntries(this.byMethod),
|
|
369
|
+
byStatusCode: Object.fromEntries(this.byStatusCode)
|
|
370
|
+
},
|
|
371
|
+
collectionMetrics,
|
|
372
|
+
recentSpans: [...this.recentSpans].reverse()
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
// libs/plugins/otel/src/lib/metrics/request-metrics.ts
|
|
378
|
+
var import_express = require("express");
|
|
379
|
+
|
|
380
|
+
// libs/plugins/otel/src/lib/metrics/otel-helpers.ts
|
|
381
|
+
function createRequestInstruments(meter) {
|
|
382
|
+
return {
|
|
383
|
+
requestDuration: meter.createHistogram("http.server.request.duration", {
|
|
384
|
+
description: "HTTP request duration in seconds",
|
|
385
|
+
unit: "s"
|
|
386
|
+
}),
|
|
387
|
+
requestTotal: meter.createCounter("http.server.request.total", {
|
|
388
|
+
description: "Total HTTP requests"
|
|
389
|
+
}),
|
|
390
|
+
activeRequests: meter.createUpDownCounter("http.server.active_requests", {
|
|
391
|
+
description: "Number of active HTTP requests"
|
|
392
|
+
})
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
function createCollectionInstruments(meter) {
|
|
396
|
+
return {
|
|
397
|
+
operationTotal: meter.createCounter("momentum.collection.operation.total", {
|
|
398
|
+
description: "Total collection operations"
|
|
399
|
+
}),
|
|
400
|
+
operationDuration: meter.createHistogram("momentum.collection.operation.duration", {
|
|
401
|
+
description: "Collection operation duration in seconds",
|
|
402
|
+
unit: "s"
|
|
403
|
+
})
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
function tryLoadOtelSdk(serviceName) {
|
|
407
|
+
try {
|
|
408
|
+
const sdkMetrics = require("@opentelemetry/sdk-metrics");
|
|
409
|
+
const promExporter = require("@opentelemetry/exporter-prometheus");
|
|
410
|
+
const exporter = new promExporter.PrometheusExporter({
|
|
411
|
+
preventServerStart: true
|
|
412
|
+
});
|
|
413
|
+
const provider = new sdkMetrics.MeterProvider({
|
|
414
|
+
readers: [exporter]
|
|
415
|
+
});
|
|
416
|
+
return {
|
|
417
|
+
meter: provider.getMeter(serviceName),
|
|
418
|
+
provider,
|
|
419
|
+
exporter
|
|
420
|
+
};
|
|
421
|
+
} catch {
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
function getSpanContext(doc) {
|
|
426
|
+
const span = doc["__otelSpan"];
|
|
427
|
+
if (span == null || typeof span !== "object")
|
|
428
|
+
return null;
|
|
429
|
+
if (!("spanContext" in span))
|
|
430
|
+
return null;
|
|
431
|
+
const spanContextFn = span["spanContext"];
|
|
432
|
+
if (typeof spanContextFn !== "function")
|
|
433
|
+
return null;
|
|
434
|
+
const ctx = spanContextFn.call(span);
|
|
435
|
+
if (ctx == null || typeof ctx !== "object")
|
|
436
|
+
return null;
|
|
437
|
+
if (!("traceId" in ctx) || !("spanId" in ctx))
|
|
438
|
+
return null;
|
|
439
|
+
const traceId = typeof ctx["traceId"] === "string" ? ctx["traceId"] : "";
|
|
440
|
+
const spanId = typeof ctx["spanId"] === "string" ? ctx["spanId"] : "";
|
|
441
|
+
return { traceId, spanId };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// libs/plugins/otel/src/lib/metrics/request-metrics.ts
|
|
445
|
+
function createRequestMetricsMiddleware(options) {
|
|
446
|
+
const { store } = options;
|
|
447
|
+
let instruments = null;
|
|
448
|
+
if (options.meter) {
|
|
449
|
+
instruments = createRequestInstruments(options.meter);
|
|
450
|
+
}
|
|
451
|
+
const router = (0, import_express.Router)();
|
|
452
|
+
router.use((req, res, next) => {
|
|
453
|
+
const start = process.hrtime.bigint();
|
|
454
|
+
store.incrementActiveRequests();
|
|
455
|
+
instruments?.activeRequests.add(1);
|
|
456
|
+
res.on("finish", () => {
|
|
457
|
+
const durationNs = Number(process.hrtime.bigint() - start);
|
|
458
|
+
const durationMs = durationNs / 1e6;
|
|
459
|
+
const durationSec = durationNs / 1e9;
|
|
460
|
+
const method = req.method;
|
|
461
|
+
const statusCode = res.statusCode;
|
|
462
|
+
const route = req.route?.path ?? req.path;
|
|
463
|
+
const attrs = {
|
|
464
|
+
method,
|
|
465
|
+
route,
|
|
466
|
+
status_code: String(statusCode)
|
|
467
|
+
};
|
|
468
|
+
instruments?.requestDuration.record(durationSec, attrs);
|
|
469
|
+
instruments?.requestTotal.add(1, attrs);
|
|
470
|
+
instruments?.activeRequests.add(-1);
|
|
471
|
+
store.decrementActiveRequests();
|
|
472
|
+
store.recordHttpRequest(method, statusCode, durationMs);
|
|
473
|
+
});
|
|
474
|
+
next();
|
|
475
|
+
});
|
|
476
|
+
return router;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// libs/plugins/otel/src/lib/metrics/collection-metrics.ts
|
|
480
|
+
var EXCLUDED_COLLECTIONS = /* @__PURE__ */ new Set(["otel-snapshots"]);
|
|
481
|
+
var TRACKED_OPERATIONS = /* @__PURE__ */ new Set(["create", "update", "delete"]);
|
|
482
|
+
function isTrackedOperation(op) {
|
|
483
|
+
return TRACKED_OPERATIONS.has(op);
|
|
484
|
+
}
|
|
485
|
+
function calcDurationMs(startTime) {
|
|
486
|
+
if (typeof startTime === "bigint") {
|
|
487
|
+
return Number(process.hrtime.bigint() - startTime) / 1e6;
|
|
488
|
+
}
|
|
489
|
+
return 0;
|
|
490
|
+
}
|
|
491
|
+
function buildSpanRecord(doc, slug2, operation, durationMs) {
|
|
492
|
+
const spanCtx = getSpanContext(doc);
|
|
493
|
+
return {
|
|
494
|
+
traceId: spanCtx?.traceId ?? "",
|
|
495
|
+
spanId: spanCtx?.spanId ?? "",
|
|
496
|
+
name: `${slug2}.${operation}`,
|
|
497
|
+
collection: slug2,
|
|
498
|
+
operation,
|
|
499
|
+
durationMs: Math.round(durationMs),
|
|
500
|
+
status: "ok",
|
|
501
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
function injectCollectionMetricsHooks(collections, options) {
|
|
505
|
+
const { store } = options;
|
|
506
|
+
let instruments = null;
|
|
507
|
+
if (options.meter) {
|
|
508
|
+
instruments = createCollectionInstruments(options.meter);
|
|
509
|
+
}
|
|
510
|
+
for (const collection of collections) {
|
|
511
|
+
if (EXCLUDED_COLLECTIONS.has(collection.slug))
|
|
512
|
+
continue;
|
|
513
|
+
collection.hooks = collection.hooks ?? {};
|
|
514
|
+
const beforeChangeHook = (args) => {
|
|
515
|
+
const operation = args.operation ?? "create";
|
|
516
|
+
if (options.operations && !options.operations.includes(operation)) {
|
|
517
|
+
return args.data;
|
|
518
|
+
}
|
|
519
|
+
if (args.data) {
|
|
520
|
+
args.data["__metricsStart"] = process.hrtime.bigint();
|
|
521
|
+
}
|
|
522
|
+
return args.data;
|
|
523
|
+
};
|
|
524
|
+
const afterChangeHook = (args) => {
|
|
525
|
+
const doc = args.doc ?? args.data ?? {};
|
|
526
|
+
const operation = args.operation ?? "create";
|
|
527
|
+
if (options.operations && !options.operations.includes(operation)) {
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
const durationMs = calcDurationMs(doc["__metricsStart"]);
|
|
531
|
+
const attrs = { collection: collection.slug, operation };
|
|
532
|
+
instruments?.operationTotal.add(1, attrs);
|
|
533
|
+
instruments?.operationDuration.record(durationMs / 1e3, attrs);
|
|
534
|
+
if (isTrackedOperation(operation)) {
|
|
535
|
+
store.recordCollectionOperation(collection.slug, operation, durationMs);
|
|
536
|
+
}
|
|
537
|
+
store.recordSpan(buildSpanRecord(doc, collection.slug, operation, durationMs));
|
|
538
|
+
if (args.doc) {
|
|
539
|
+
delete args.doc["__metricsStart"];
|
|
540
|
+
}
|
|
541
|
+
if (args.data) {
|
|
542
|
+
delete args.data["__metricsStart"];
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
const beforeDeleteHook = (args) => {
|
|
546
|
+
if (options.operations && !options.operations.includes("delete")) {
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
if (args.doc) {
|
|
550
|
+
args.doc["__metricsStart"] = process.hrtime.bigint();
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
const afterDeleteHook = (args) => {
|
|
554
|
+
if (options.operations && !options.operations.includes("delete")) {
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
const doc = args.doc ?? {};
|
|
558
|
+
const durationMs = calcDurationMs(doc["__metricsStart"]);
|
|
559
|
+
const attrs = { collection: collection.slug, operation: "delete" };
|
|
560
|
+
instruments?.operationTotal.add(1, attrs);
|
|
561
|
+
instruments?.operationDuration.record(durationMs / 1e3, attrs);
|
|
562
|
+
store.recordCollectionOperation(collection.slug, "delete", durationMs);
|
|
563
|
+
store.recordSpan(buildSpanRecord(doc, collection.slug, "delete", durationMs));
|
|
564
|
+
};
|
|
565
|
+
const existingBeforeChange = collection.hooks.beforeChange ?? [];
|
|
566
|
+
collection.hooks.beforeChange = [beforeChangeHook, ...existingBeforeChange];
|
|
567
|
+
const existingAfterChange = collection.hooks.afterChange ?? [];
|
|
568
|
+
collection.hooks.afterChange = [...existingAfterChange, afterChangeHook];
|
|
569
|
+
const existingBeforeDelete = collection.hooks.beforeDelete ?? [];
|
|
570
|
+
collection.hooks.beforeDelete = [beforeDeleteHook, ...existingBeforeDelete];
|
|
571
|
+
const existingAfterDelete = collection.hooks.afterDelete ?? [];
|
|
572
|
+
collection.hooks.afterDelete = [...existingAfterDelete, afterDeleteHook];
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// libs/plugins/otel/src/lib/api/otel-query-handler.ts
|
|
577
|
+
var import_express2 = require("express");
|
|
578
|
+
|
|
579
|
+
// libs/plugins/otel/src/lib/api/otel-api-guards.ts
|
|
580
|
+
function isFindable(val) {
|
|
581
|
+
return val != null && typeof val === "object" && "find" in val;
|
|
582
|
+
}
|
|
583
|
+
function isCreatable(val) {
|
|
584
|
+
return val != null && typeof val === "object" && "create" in val;
|
|
585
|
+
}
|
|
586
|
+
function isDeletable(val) {
|
|
587
|
+
return val != null && typeof val === "object" && "delete" in val;
|
|
588
|
+
}
|
|
589
|
+
function isRecord(val) {
|
|
590
|
+
return val != null && typeof val === "object" && !Array.isArray(val);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// libs/plugins/otel/src/lib/api/otel-query-handler.ts
|
|
594
|
+
var logger = createLogger("OTel-API");
|
|
595
|
+
function getUser(req) {
|
|
596
|
+
if (!("user" in req))
|
|
597
|
+
return null;
|
|
598
|
+
const user = req["user"];
|
|
599
|
+
if (user == null || typeof user !== "object")
|
|
600
|
+
return null;
|
|
601
|
+
if (!("id" in user))
|
|
602
|
+
return null;
|
|
603
|
+
return user;
|
|
604
|
+
}
|
|
605
|
+
function isAuthenticated(req) {
|
|
606
|
+
return getUser(req) != null;
|
|
607
|
+
}
|
|
608
|
+
function isAdmin(req) {
|
|
609
|
+
const user = getUser(req);
|
|
610
|
+
if (!user)
|
|
611
|
+
return false;
|
|
612
|
+
return "role" in user && user["role"] === "admin";
|
|
613
|
+
}
|
|
614
|
+
function requireAdmin(req, res) {
|
|
615
|
+
if (!isAuthenticated(req)) {
|
|
616
|
+
res.status(401).json({ error: "Authentication required" });
|
|
617
|
+
return false;
|
|
618
|
+
}
|
|
619
|
+
if (!isAdmin(req)) {
|
|
620
|
+
res.status(403).json({ error: "Admin role required" });
|
|
621
|
+
return false;
|
|
622
|
+
}
|
|
623
|
+
return true;
|
|
624
|
+
}
|
|
625
|
+
function buildTimeRangeWhere(req) {
|
|
626
|
+
const where = {};
|
|
627
|
+
const from = req.query["from"];
|
|
628
|
+
const to = req.query["to"];
|
|
629
|
+
if (typeof from === "string" || typeof to === "string") {
|
|
630
|
+
const createdAt = {};
|
|
631
|
+
if (typeof from === "string")
|
|
632
|
+
createdAt["gte"] = from;
|
|
633
|
+
if (typeof to === "string")
|
|
634
|
+
createdAt["lte"] = to;
|
|
635
|
+
where["createdAt"] = createdAt;
|
|
636
|
+
}
|
|
637
|
+
return where;
|
|
638
|
+
}
|
|
639
|
+
function createOtelQueryRouter(store, getApi, snapshotService) {
|
|
640
|
+
const router = (0, import_express2.Router)();
|
|
641
|
+
router.get("/summary", (req, res) => {
|
|
642
|
+
if (!requireAdmin(req, res))
|
|
643
|
+
return;
|
|
644
|
+
res.json(store.getSummary());
|
|
645
|
+
});
|
|
646
|
+
router.get("/history", async (req, res) => {
|
|
647
|
+
if (!requireAdmin(req, res))
|
|
648
|
+
return;
|
|
649
|
+
try {
|
|
650
|
+
const api = getApi?.();
|
|
651
|
+
if (!api) {
|
|
652
|
+
res.json({ snapshots: [], total: 0 });
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
const ops = api.setContext({ overrideAccess: true }).collection("otel-snapshots");
|
|
656
|
+
if (!isFindable(ops)) {
|
|
657
|
+
res.json({ snapshots: [], total: 0 });
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
const limit = Math.min(Number(req.query["limit"]) || 100, 500);
|
|
661
|
+
const page = Math.max(Number(req.query["page"]) || 1, 1);
|
|
662
|
+
const where = buildTimeRangeWhere(req);
|
|
663
|
+
const result = await ops.find({ where, limit, page, sort: "-createdAt" });
|
|
664
|
+
const docs = Array.isArray(result.docs) ? result.docs : [];
|
|
665
|
+
res.json({
|
|
666
|
+
snapshots: docs,
|
|
667
|
+
total: typeof result.totalDocs === "number" ? result.totalDocs : docs.length
|
|
668
|
+
});
|
|
669
|
+
} catch (err) {
|
|
670
|
+
logger.error("Failed to fetch history", { error: String(err) });
|
|
671
|
+
res.status(500).json({ error: "Failed to fetch metrics history" });
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
router.delete("/history", async (req, res) => {
|
|
675
|
+
if (!requireAdmin(req, res))
|
|
676
|
+
return;
|
|
677
|
+
try {
|
|
678
|
+
const deleted = await snapshotService?.purgeAll() ?? 0;
|
|
679
|
+
res.json({ deleted });
|
|
680
|
+
} catch (err) {
|
|
681
|
+
logger.error("Failed to purge history", { error: String(err) });
|
|
682
|
+
res.status(500).json({ error: "Failed to purge metrics history" });
|
|
683
|
+
}
|
|
684
|
+
});
|
|
685
|
+
router.get("/export", async (req, res) => {
|
|
686
|
+
if (!requireAdmin(req, res))
|
|
687
|
+
return;
|
|
688
|
+
try {
|
|
689
|
+
const api = getApi?.();
|
|
690
|
+
if (!api) {
|
|
691
|
+
res.status(503).json({ error: "API not available" });
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
const ops = api.setContext({ overrideAccess: true }).collection("otel-snapshots");
|
|
695
|
+
if (!isFindable(ops)) {
|
|
696
|
+
res.status(503).json({ error: "Snapshots collection not available" });
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
const where = buildTimeRangeWhere(req);
|
|
700
|
+
const date2 = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
701
|
+
res.setHeader("Content-Type", "text/csv");
|
|
702
|
+
res.setHeader("Content-Disposition", `attachment; filename="otel-metrics-${date2}.csv"`);
|
|
703
|
+
res.write(
|
|
704
|
+
"timestamp,totalRequests,errorCount,avgDurationMs,memoryUsageMb,byMethod,byStatusCode,collectionMetrics\n"
|
|
705
|
+
);
|
|
706
|
+
let currentPage = 1;
|
|
707
|
+
let hasMore = true;
|
|
708
|
+
const batchSize = 50;
|
|
709
|
+
while (hasMore) {
|
|
710
|
+
const result = await ops.find({
|
|
711
|
+
where,
|
|
712
|
+
limit: batchSize,
|
|
713
|
+
page: currentPage,
|
|
714
|
+
sort: "-createdAt"
|
|
715
|
+
});
|
|
716
|
+
const docs = Array.isArray(result.docs) ? result.docs : [];
|
|
717
|
+
if (docs.length === 0) {
|
|
718
|
+
hasMore = false;
|
|
719
|
+
break;
|
|
720
|
+
}
|
|
721
|
+
for (const doc of docs) {
|
|
722
|
+
if (!isRecord(doc))
|
|
723
|
+
continue;
|
|
724
|
+
const timestamp = typeof doc["createdAt"] === "string" ? doc["createdAt"] : "";
|
|
725
|
+
const totalReqs = typeof doc["totalRequests"] === "number" ? doc["totalRequests"] : 0;
|
|
726
|
+
const errors = typeof doc["errorCount"] === "number" ? doc["errorCount"] : 0;
|
|
727
|
+
const avgMs = typeof doc["avgDurationMs"] === "number" ? doc["avgDurationMs"] : 0;
|
|
728
|
+
const memMb = typeof doc["memoryUsageMb"] === "number" ? doc["memoryUsageMb"] : 0;
|
|
729
|
+
const byMethod = csvEscape(JSON.stringify(doc["byMethod"] ?? {}));
|
|
730
|
+
const byStatus = csvEscape(JSON.stringify(doc["byStatusCode"] ?? {}));
|
|
731
|
+
const colMetrics = csvEscape(JSON.stringify(doc["collectionMetrics"] ?? []));
|
|
732
|
+
res.write(
|
|
733
|
+
`${timestamp},${totalReqs},${errors},${avgMs},${memMb},${byMethod},${byStatus},${colMetrics}
|
|
734
|
+
`
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
const totalPages = typeof result.totalPages === "number" ? result.totalPages : 1;
|
|
738
|
+
hasMore = currentPage < totalPages;
|
|
739
|
+
currentPage++;
|
|
740
|
+
}
|
|
741
|
+
res.end();
|
|
742
|
+
} catch (err) {
|
|
743
|
+
logger.error("Failed to export metrics", { error: String(err) });
|
|
744
|
+
if (!res.headersSent) {
|
|
745
|
+
res.status(500).json({ error: "Failed to export metrics" });
|
|
746
|
+
} else {
|
|
747
|
+
res.end();
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
return router;
|
|
752
|
+
}
|
|
753
|
+
function csvEscape(value) {
|
|
754
|
+
if (value.includes(",") || value.includes('"') || value.includes("\n")) {
|
|
755
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
756
|
+
}
|
|
757
|
+
return value;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// libs/plugins/otel/src/lib/exporters/prometheus-handler.ts
|
|
761
|
+
var import_express3 = require("express");
|
|
762
|
+
function hasMetricsHandler(exporter) {
|
|
763
|
+
if (exporter == null || typeof exporter !== "object")
|
|
764
|
+
return false;
|
|
765
|
+
if (!("getMetricsRequestHandler" in exporter))
|
|
766
|
+
return false;
|
|
767
|
+
return typeof exporter["getMetricsRequestHandler"] === "function";
|
|
768
|
+
}
|
|
769
|
+
function createPrometheusHandler(options) {
|
|
770
|
+
const router = (0, import_express3.Router)();
|
|
771
|
+
const exporter = options.exporter;
|
|
772
|
+
const canServe = hasMetricsHandler(exporter);
|
|
773
|
+
router.get("/", (req, res) => {
|
|
774
|
+
if (canServe) {
|
|
775
|
+
exporter.getMetricsRequestHandler(req, res);
|
|
776
|
+
} else {
|
|
777
|
+
res.status(503).send("Prometheus exporter not available");
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
return router;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// libs/core/src/lib/collections/define-collection.ts
|
|
784
|
+
function defineCollection(config) {
|
|
785
|
+
const collection = {
|
|
786
|
+
timestamps: true,
|
|
787
|
+
// Enable timestamps by default
|
|
788
|
+
...config
|
|
789
|
+
};
|
|
790
|
+
if (!collection.slug) {
|
|
791
|
+
throw new Error("Collection must have a slug");
|
|
792
|
+
}
|
|
793
|
+
if (!collection.fields || collection.fields.length === 0) {
|
|
794
|
+
throw new Error(`Collection "${collection.slug}" must have at least one field`);
|
|
795
|
+
}
|
|
796
|
+
if (!/^[a-z][a-z0-9-]*$/.test(collection.slug)) {
|
|
797
|
+
throw new Error(
|
|
798
|
+
`Collection slug "${collection.slug}" must be kebab-case (lowercase letters, numbers, and hyphens, starting with a letter)`
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
return collection;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// libs/core/src/lib/fields/field-builders.ts
|
|
805
|
+
function text(name, options = {}) {
|
|
806
|
+
return {
|
|
807
|
+
name,
|
|
808
|
+
type: "text",
|
|
809
|
+
...options
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
function number(name, options = {}) {
|
|
813
|
+
return {
|
|
814
|
+
name,
|
|
815
|
+
type: "number",
|
|
816
|
+
...options
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
function json(name, options = {}) {
|
|
820
|
+
return {
|
|
821
|
+
name,
|
|
822
|
+
type: "json",
|
|
823
|
+
...options
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// libs/core/src/lib/collections/media.collection.ts
|
|
828
|
+
var validateFocalPoint = (value) => {
|
|
829
|
+
if (value === null || value === void 0)
|
|
830
|
+
return true;
|
|
831
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
832
|
+
return "Focal point must be an object with x and y coordinates";
|
|
833
|
+
}
|
|
834
|
+
const fp = Object.fromEntries(Object.entries(value));
|
|
835
|
+
if (!("x" in fp) || !("y" in fp)) {
|
|
836
|
+
return "Focal point must have both x and y properties";
|
|
837
|
+
}
|
|
838
|
+
const { x, y } = fp;
|
|
839
|
+
if (typeof x !== "number" || !Number.isFinite(x)) {
|
|
840
|
+
return "Focal point x must be a finite number";
|
|
841
|
+
}
|
|
842
|
+
if (typeof y !== "number" || !Number.isFinite(y)) {
|
|
843
|
+
return "Focal point y must be a finite number";
|
|
844
|
+
}
|
|
845
|
+
if (x < 0 || x > 1) {
|
|
846
|
+
return `Focal point x must be between 0 and 1 (received ${x})`;
|
|
847
|
+
}
|
|
848
|
+
if (y < 0 || y > 1) {
|
|
849
|
+
return `Focal point y must be between 0 and 1 (received ${y})`;
|
|
850
|
+
}
|
|
851
|
+
return true;
|
|
852
|
+
};
|
|
853
|
+
var MediaCollection = defineCollection({
|
|
854
|
+
slug: "media",
|
|
855
|
+
labels: {
|
|
856
|
+
singular: "Media",
|
|
857
|
+
plural: "Media"
|
|
858
|
+
},
|
|
859
|
+
upload: {
|
|
860
|
+
mimeTypes: ["image/*", "application/pdf", "video/*", "audio/*"]
|
|
861
|
+
},
|
|
862
|
+
admin: {
|
|
863
|
+
useAsTitle: "filename",
|
|
864
|
+
defaultColumns: ["filename", "mimeType", "filesize", "createdAt"]
|
|
865
|
+
},
|
|
866
|
+
fields: [
|
|
867
|
+
text("filename", {
|
|
868
|
+
required: true,
|
|
869
|
+
label: "Filename",
|
|
870
|
+
description: "Original filename of the uploaded file"
|
|
871
|
+
}),
|
|
872
|
+
text("mimeType", {
|
|
873
|
+
required: true,
|
|
874
|
+
label: "MIME Type",
|
|
875
|
+
description: "File MIME type (e.g., image/jpeg, application/pdf)"
|
|
876
|
+
}),
|
|
877
|
+
number("filesize", {
|
|
878
|
+
label: "File Size",
|
|
879
|
+
description: "File size in bytes"
|
|
880
|
+
}),
|
|
881
|
+
text("path", {
|
|
882
|
+
label: "Storage Path",
|
|
883
|
+
description: "Path/key where the file is stored",
|
|
884
|
+
admin: {
|
|
885
|
+
hidden: true
|
|
886
|
+
}
|
|
887
|
+
}),
|
|
888
|
+
text("url", {
|
|
889
|
+
label: "URL",
|
|
890
|
+
description: "Public URL to access the file"
|
|
891
|
+
}),
|
|
892
|
+
text("alt", {
|
|
893
|
+
label: "Alt Text",
|
|
894
|
+
description: "Alternative text for accessibility"
|
|
895
|
+
}),
|
|
896
|
+
number("width", {
|
|
897
|
+
label: "Width",
|
|
898
|
+
description: "Image width in pixels (for images only)"
|
|
899
|
+
}),
|
|
900
|
+
number("height", {
|
|
901
|
+
label: "Height",
|
|
902
|
+
description: "Image height in pixels (for images only)"
|
|
903
|
+
}),
|
|
904
|
+
json("focalPoint", {
|
|
905
|
+
label: "Focal Point",
|
|
906
|
+
description: "Focal point coordinates for image cropping",
|
|
907
|
+
validate: validateFocalPoint,
|
|
908
|
+
admin: {
|
|
909
|
+
hidden: true
|
|
910
|
+
}
|
|
911
|
+
}),
|
|
912
|
+
json("sizes", {
|
|
913
|
+
label: "Image Sizes",
|
|
914
|
+
description: "Generated image size variants",
|
|
915
|
+
admin: {
|
|
916
|
+
hidden: true
|
|
917
|
+
}
|
|
918
|
+
})
|
|
919
|
+
],
|
|
920
|
+
access: {
|
|
921
|
+
// Media is readable by anyone by default
|
|
922
|
+
read: () => true,
|
|
923
|
+
// Only authenticated users can create/update/delete
|
|
924
|
+
create: ({ req }) => !!req?.user,
|
|
925
|
+
update: ({ req }) => !!req?.user,
|
|
926
|
+
delete: ({ req }) => !!req?.user
|
|
927
|
+
}
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
// libs/core/src/lib/access/access-helpers.ts
|
|
931
|
+
function hasRole(role) {
|
|
932
|
+
return ({ req }) => req.user?.role === role;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// libs/plugins/otel/src/lib/metrics/otel-snapshot-collection.ts
|
|
936
|
+
var OtelSnapshotsCollection = defineCollection({
|
|
937
|
+
slug: "otel-snapshots",
|
|
938
|
+
labels: {
|
|
939
|
+
singular: "OTel Snapshot",
|
|
940
|
+
plural: "OTel Snapshots"
|
|
941
|
+
},
|
|
942
|
+
admin: {
|
|
943
|
+
hidden: true
|
|
944
|
+
},
|
|
945
|
+
timestamps: true,
|
|
946
|
+
fields: [
|
|
947
|
+
number("totalRequests", { required: true }),
|
|
948
|
+
number("errorCount", { required: true }),
|
|
949
|
+
number("avgDurationMs", { required: true }),
|
|
950
|
+
number("memoryUsageMb", { required: true }),
|
|
951
|
+
json("byMethod", {}),
|
|
952
|
+
json("byStatusCode", {}),
|
|
953
|
+
json("collectionMetrics", {}),
|
|
954
|
+
json("topSpans", {})
|
|
955
|
+
],
|
|
956
|
+
access: {
|
|
957
|
+
read: hasRole("admin"),
|
|
958
|
+
create: hasRole("admin"),
|
|
959
|
+
update: hasRole("admin"),
|
|
960
|
+
delete: hasRole("admin"),
|
|
961
|
+
admin: () => false
|
|
962
|
+
}
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
// libs/plugins/otel/src/lib/metrics/metrics-snapshot-service.ts
|
|
966
|
+
var MetricsSnapshotService = class {
|
|
967
|
+
constructor(options) {
|
|
968
|
+
this.timer = null;
|
|
969
|
+
this.flushing = false;
|
|
970
|
+
this.store = options.store;
|
|
971
|
+
this.getApi = options.getApi;
|
|
972
|
+
this.snapshotInterval = options.snapshotInterval ?? 6e4;
|
|
973
|
+
this.retentionDays = options.retentionDays ?? 7;
|
|
974
|
+
this.logger = createLogger("OTel-Snapshots");
|
|
975
|
+
}
|
|
976
|
+
start() {
|
|
977
|
+
if (this.timer)
|
|
978
|
+
return;
|
|
979
|
+
this.timer = setInterval(() => {
|
|
980
|
+
void this.flush();
|
|
981
|
+
}, this.snapshotInterval);
|
|
982
|
+
}
|
|
983
|
+
async flush() {
|
|
984
|
+
if (this.flushing)
|
|
985
|
+
return;
|
|
986
|
+
this.flushing = true;
|
|
987
|
+
try {
|
|
988
|
+
const api = this.getApi();
|
|
989
|
+
if (!api)
|
|
990
|
+
return;
|
|
991
|
+
const ops = api.setContext({ overrideAccess: true }).collection("otel-snapshots");
|
|
992
|
+
if (!isCreatable(ops))
|
|
993
|
+
return;
|
|
994
|
+
const snapshot = this.store.getSnapshotData();
|
|
995
|
+
const data = {
|
|
996
|
+
totalRequests: snapshot.totalRequests,
|
|
997
|
+
errorCount: snapshot.errorCount,
|
|
998
|
+
avgDurationMs: snapshot.avgDurationMs,
|
|
999
|
+
memoryUsageMb: snapshot.memoryUsageMb,
|
|
1000
|
+
byMethod: snapshot.byMethod,
|
|
1001
|
+
byStatusCode: snapshot.byStatusCode,
|
|
1002
|
+
collectionMetrics: snapshot.collectionMetrics,
|
|
1003
|
+
topSpans: snapshot.topSpans
|
|
1004
|
+
};
|
|
1005
|
+
await ops.create(data);
|
|
1006
|
+
await this.prune();
|
|
1007
|
+
} catch (error) {
|
|
1008
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1009
|
+
this.logger.error(`Failed to flush snapshot: ${message}`);
|
|
1010
|
+
} finally {
|
|
1011
|
+
this.flushing = false;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
async restore() {
|
|
1015
|
+
try {
|
|
1016
|
+
const api = this.getApi();
|
|
1017
|
+
if (!api)
|
|
1018
|
+
return;
|
|
1019
|
+
const ops = api.setContext({ overrideAccess: true }).collection("otel-snapshots");
|
|
1020
|
+
if (!isFindable(ops))
|
|
1021
|
+
return;
|
|
1022
|
+
const result = await ops.find({ limit: 1, sort: "-createdAt" });
|
|
1023
|
+
const docs = Array.isArray(result.docs) ? result.docs : [];
|
|
1024
|
+
if (docs.length === 0)
|
|
1025
|
+
return;
|
|
1026
|
+
const doc = docs[0];
|
|
1027
|
+
if (!isRecord(doc))
|
|
1028
|
+
return;
|
|
1029
|
+
const byMethod = {};
|
|
1030
|
+
const rawMethod = doc["byMethod"];
|
|
1031
|
+
if (isRecord(rawMethod)) {
|
|
1032
|
+
for (const [k, v] of Object.entries(rawMethod)) {
|
|
1033
|
+
if (typeof v === "number")
|
|
1034
|
+
byMethod[k] = v;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
const byStatusCode = {};
|
|
1038
|
+
const rawStatus = doc["byStatusCode"];
|
|
1039
|
+
if (isRecord(rawStatus)) {
|
|
1040
|
+
for (const [k, v] of Object.entries(rawStatus)) {
|
|
1041
|
+
if (typeof v === "number")
|
|
1042
|
+
byStatusCode[k] = v;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
const snapshot = {
|
|
1046
|
+
totalRequests: typeof doc["totalRequests"] === "number" ? doc["totalRequests"] : 0,
|
|
1047
|
+
errorCount: typeof doc["errorCount"] === "number" ? doc["errorCount"] : 0,
|
|
1048
|
+
avgDurationMs: typeof doc["avgDurationMs"] === "number" ? doc["avgDurationMs"] : 0,
|
|
1049
|
+
memoryUsageMb: typeof doc["memoryUsageMb"] === "number" ? doc["memoryUsageMb"] : 0,
|
|
1050
|
+
byMethod,
|
|
1051
|
+
byStatusCode,
|
|
1052
|
+
collectionMetrics: Array.isArray(doc["collectionMetrics"]) ? extractCollectionMetrics(doc["collectionMetrics"]) : [],
|
|
1053
|
+
topSpans: Array.isArray(doc["topSpans"]) ? extractSpanRecords(doc["topSpans"]) : []
|
|
1054
|
+
};
|
|
1055
|
+
this.store.restore(snapshot);
|
|
1056
|
+
this.logger.info("Metrics restored from last snapshot");
|
|
1057
|
+
} catch (error) {
|
|
1058
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1059
|
+
this.logger.warn(`Failed to restore snapshot: ${message}`);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
async shutdown() {
|
|
1063
|
+
if (this.timer) {
|
|
1064
|
+
clearInterval(this.timer);
|
|
1065
|
+
this.timer = null;
|
|
1066
|
+
}
|
|
1067
|
+
await this.flush();
|
|
1068
|
+
}
|
|
1069
|
+
async purgeAll() {
|
|
1070
|
+
const api = this.getApi();
|
|
1071
|
+
if (!api)
|
|
1072
|
+
return 0;
|
|
1073
|
+
const ops = api.setContext({ overrideAccess: true }).collection("otel-snapshots");
|
|
1074
|
+
if (!isFindable(ops) || !isDeletable(ops))
|
|
1075
|
+
return 0;
|
|
1076
|
+
let deleted = 0;
|
|
1077
|
+
const MAX_BATCHES = 100;
|
|
1078
|
+
for (let batch = 0; batch < MAX_BATCHES; batch++) {
|
|
1079
|
+
const result = await ops.find({ limit: 50 });
|
|
1080
|
+
const docs = Array.isArray(result.docs) ? result.docs : [];
|
|
1081
|
+
if (docs.length === 0)
|
|
1082
|
+
break;
|
|
1083
|
+
let batchDeleted = 0;
|
|
1084
|
+
for (const doc of docs) {
|
|
1085
|
+
if (isRecord(doc) && typeof doc["id"] === "string") {
|
|
1086
|
+
await ops.delete(doc["id"]);
|
|
1087
|
+
deleted++;
|
|
1088
|
+
batchDeleted++;
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
if (batchDeleted === 0)
|
|
1092
|
+
break;
|
|
1093
|
+
}
|
|
1094
|
+
this.logger.info(`Purged ${deleted} snapshots`);
|
|
1095
|
+
return deleted;
|
|
1096
|
+
}
|
|
1097
|
+
async prune() {
|
|
1098
|
+
const api = this.getApi();
|
|
1099
|
+
if (!api)
|
|
1100
|
+
return;
|
|
1101
|
+
const ops = api.setContext({ overrideAccess: true }).collection("otel-snapshots");
|
|
1102
|
+
if (!isFindable(ops) || !isDeletable(ops))
|
|
1103
|
+
return;
|
|
1104
|
+
const cutoff = new Date(Date.now() - this.retentionDays * 24 * 60 * 60 * 1e3).toISOString();
|
|
1105
|
+
try {
|
|
1106
|
+
const result = await ops.find({
|
|
1107
|
+
where: { createdAt: { lt: cutoff } },
|
|
1108
|
+
limit: 100
|
|
1109
|
+
});
|
|
1110
|
+
const docs = Array.isArray(result.docs) ? result.docs : [];
|
|
1111
|
+
for (const doc of docs) {
|
|
1112
|
+
if (isRecord(doc) && typeof doc["id"] === "string") {
|
|
1113
|
+
await ops.delete(doc["id"]);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
if (docs.length > 0) {
|
|
1117
|
+
this.logger.info(`Pruned ${docs.length} expired snapshots`);
|
|
1118
|
+
}
|
|
1119
|
+
} catch (error) {
|
|
1120
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1121
|
+
this.logger.warn(`Failed to prune snapshots: ${message}`);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
};
|
|
1125
|
+
function extractCollectionMetrics(raw) {
|
|
1126
|
+
const result = [];
|
|
1127
|
+
for (const item of raw) {
|
|
1128
|
+
if (!isRecord(item))
|
|
1129
|
+
continue;
|
|
1130
|
+
result.push({
|
|
1131
|
+
collection: typeof item["collection"] === "string" ? item["collection"] : "",
|
|
1132
|
+
creates: typeof item["creates"] === "number" ? item["creates"] : 0,
|
|
1133
|
+
updates: typeof item["updates"] === "number" ? item["updates"] : 0,
|
|
1134
|
+
deletes: typeof item["deletes"] === "number" ? item["deletes"] : 0,
|
|
1135
|
+
avgDurationMs: typeof item["avgDurationMs"] === "number" ? item["avgDurationMs"] : 0
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
return result;
|
|
1139
|
+
}
|
|
1140
|
+
function extractSpanRecords(raw) {
|
|
1141
|
+
const result = [];
|
|
1142
|
+
for (const item of raw) {
|
|
1143
|
+
if (!isRecord(item))
|
|
1144
|
+
continue;
|
|
1145
|
+
result.push({
|
|
1146
|
+
traceId: typeof item["traceId"] === "string" ? item["traceId"] : "",
|
|
1147
|
+
spanId: typeof item["spanId"] === "string" ? item["spanId"] : "",
|
|
1148
|
+
name: typeof item["name"] === "string" ? item["name"] : "",
|
|
1149
|
+
collection: typeof item["collection"] === "string" ? item["collection"] : "",
|
|
1150
|
+
operation: typeof item["operation"] === "string" ? item["operation"] : "",
|
|
1151
|
+
durationMs: typeof item["durationMs"] === "number" ? item["durationMs"] : 0,
|
|
1152
|
+
status: item["status"] === "error" ? "error" : "ok",
|
|
1153
|
+
timestamp: typeof item["timestamp"] === "string" ? item["timestamp"] : ""
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
return result;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
244
1159
|
// libs/plugins/otel/src/lib/otel-plugin.ts
|
|
1160
|
+
function isSpanLike(value) {
|
|
1161
|
+
if (value == null || typeof value !== "object")
|
|
1162
|
+
return false;
|
|
1163
|
+
if (!("end" in value) || !("setStatus" in value))
|
|
1164
|
+
return false;
|
|
1165
|
+
return typeof value["end"] === "function" && typeof value["setStatus"] === "function";
|
|
1166
|
+
}
|
|
245
1167
|
var OtelLogEnricher = class {
|
|
246
1168
|
enrich() {
|
|
247
1169
|
const span = import_api.trace.getActiveSpan();
|
|
@@ -255,31 +1177,156 @@ var OtelLogEnricher = class {
|
|
|
255
1177
|
};
|
|
256
1178
|
}
|
|
257
1179
|
};
|
|
1180
|
+
function resolveAdminRoutes(dashboardConfig) {
|
|
1181
|
+
if (dashboardConfig === false)
|
|
1182
|
+
return [];
|
|
1183
|
+
const dashboardModule = "./dashboard/otel-dashboard.page";
|
|
1184
|
+
return [
|
|
1185
|
+
{
|
|
1186
|
+
path: "observability",
|
|
1187
|
+
label: "Observability",
|
|
1188
|
+
icon: "heroSignal",
|
|
1189
|
+
loadComponent: () => import(dashboardModule).then(
|
|
1190
|
+
(m) => m["OtelDashboardPage"]
|
|
1191
|
+
),
|
|
1192
|
+
group: "Plugins"
|
|
1193
|
+
}
|
|
1194
|
+
];
|
|
1195
|
+
}
|
|
1196
|
+
function getMeterFromProvider(provider, serviceName) {
|
|
1197
|
+
if (provider == null || typeof provider !== "object")
|
|
1198
|
+
return null;
|
|
1199
|
+
if (!("getMeter" in provider))
|
|
1200
|
+
return null;
|
|
1201
|
+
const getMeterFn = provider["getMeter"];
|
|
1202
|
+
if (typeof getMeterFn !== "function")
|
|
1203
|
+
return null;
|
|
1204
|
+
return getMeterFn.call(provider, serviceName);
|
|
1205
|
+
}
|
|
258
1206
|
function otelPlugin(config = {}) {
|
|
259
|
-
const {
|
|
1207
|
+
const {
|
|
1208
|
+
serviceName = "momentum-cms",
|
|
1209
|
+
enrichLogs = true,
|
|
1210
|
+
attributes = {},
|
|
1211
|
+
operations,
|
|
1212
|
+
metrics: metricsConfig
|
|
1213
|
+
} = config;
|
|
1214
|
+
const metricsEnabled = metricsConfig?.enabled ?? false;
|
|
1215
|
+
const prometheusEnabled = metricsConfig?.prometheus ?? metricsEnabled;
|
|
1216
|
+
const dashboardEnabled = metricsConfig?.adminDashboard ?? metricsEnabled;
|
|
260
1217
|
let tracer;
|
|
261
1218
|
let enricher = null;
|
|
1219
|
+
let metricsStore = null;
|
|
1220
|
+
let meterProviderRef = null;
|
|
1221
|
+
let snapshotService = null;
|
|
1222
|
+
let momentumApi = null;
|
|
1223
|
+
const adminRoutes = metricsEnabled ? resolveAdminRoutes(dashboardEnabled) : [];
|
|
262
1224
|
return {
|
|
263
1225
|
name: "otel",
|
|
264
|
-
|
|
1226
|
+
adminRoutes,
|
|
1227
|
+
browserImports: metricsEnabled ? {
|
|
1228
|
+
adminRoutes: {
|
|
1229
|
+
path: "@momentumcms/plugins-otel/admin-routes",
|
|
1230
|
+
exportName: "otelAdminRoutes"
|
|
1231
|
+
}
|
|
1232
|
+
} : void 0,
|
|
1233
|
+
onInit({ collections, logger: logger2, registerMiddleware }) {
|
|
265
1234
|
tracer = import_api.trace.getTracer(serviceName);
|
|
266
|
-
|
|
1235
|
+
logger2.info(`OpenTelemetry tracing enabled (service: ${serviceName})`);
|
|
267
1236
|
if (enrichLogs) {
|
|
268
1237
|
enricher = new OtelLogEnricher();
|
|
269
1238
|
MomentumLogger.registerEnricher(enricher);
|
|
270
|
-
|
|
1239
|
+
logger2.info("Log enricher registered for trace/span IDs");
|
|
271
1240
|
}
|
|
272
1241
|
for (const collection of collections) {
|
|
273
1242
|
injectTracingHooks(collection, tracer, attributes, operations);
|
|
274
1243
|
}
|
|
275
|
-
|
|
1244
|
+
logger2.info(`Tracing hooks injected into ${collections.length} collections`);
|
|
1245
|
+
if (metricsEnabled) {
|
|
1246
|
+
metricsStore = new MetricsStore();
|
|
1247
|
+
collections.push(OtelSnapshotsCollection);
|
|
1248
|
+
snapshotService = new MetricsSnapshotService({
|
|
1249
|
+
store: metricsStore,
|
|
1250
|
+
getApi: () => momentumApi,
|
|
1251
|
+
snapshotInterval: metricsConfig?.snapshotInterval,
|
|
1252
|
+
retentionDays: metricsConfig?.retentionDays
|
|
1253
|
+
});
|
|
1254
|
+
let meter = null;
|
|
1255
|
+
if (metricsConfig?.meterProvider) {
|
|
1256
|
+
meter = getMeterFromProvider(metricsConfig.meterProvider, serviceName);
|
|
1257
|
+
logger2.info("Using user-supplied MeterProvider");
|
|
1258
|
+
} else if (prometheusEnabled) {
|
|
1259
|
+
const sdk = tryLoadOtelSdk(serviceName);
|
|
1260
|
+
if (sdk) {
|
|
1261
|
+
meter = sdk.meter;
|
|
1262
|
+
meterProviderRef = sdk.provider;
|
|
1263
|
+
const prometheusPath = typeof metricsConfig?.prometheus === "object" ? metricsConfig.prometheus.path ?? "/metrics" : "/metrics";
|
|
1264
|
+
registerMiddleware({
|
|
1265
|
+
path: prometheusPath,
|
|
1266
|
+
handler: createPrometheusHandler({ exporter: sdk.exporter }),
|
|
1267
|
+
position: "root"
|
|
1268
|
+
});
|
|
1269
|
+
logger2.info(`Prometheus endpoint registered at ${prometheusPath}`);
|
|
1270
|
+
} else {
|
|
1271
|
+
logger2.warn(
|
|
1272
|
+
"@opentelemetry/sdk-metrics or @opentelemetry/exporter-prometheus not found. Install them to enable Prometheus metrics. Metrics store will still work for the dashboard."
|
|
1273
|
+
);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
const requestMetrics = createRequestMetricsMiddleware({
|
|
1277
|
+
store: metricsStore,
|
|
1278
|
+
meter
|
|
1279
|
+
});
|
|
1280
|
+
registerMiddleware({
|
|
1281
|
+
path: "/",
|
|
1282
|
+
handler: requestMetrics,
|
|
1283
|
+
position: "before-api"
|
|
1284
|
+
});
|
|
1285
|
+
injectCollectionMetricsHooks(collections, {
|
|
1286
|
+
store: metricsStore,
|
|
1287
|
+
meter,
|
|
1288
|
+
operations: operations ?? void 0
|
|
1289
|
+
});
|
|
1290
|
+
if (dashboardEnabled) {
|
|
1291
|
+
const queryRouter = createOtelQueryRouter(
|
|
1292
|
+
metricsStore,
|
|
1293
|
+
() => momentumApi,
|
|
1294
|
+
snapshotService
|
|
1295
|
+
);
|
|
1296
|
+
registerMiddleware({
|
|
1297
|
+
path: "/otel",
|
|
1298
|
+
handler: queryRouter,
|
|
1299
|
+
position: "before-api"
|
|
1300
|
+
});
|
|
1301
|
+
logger2.info("Observability dashboard API registered");
|
|
1302
|
+
}
|
|
1303
|
+
logger2.info("Metrics collection enabled");
|
|
1304
|
+
}
|
|
276
1305
|
},
|
|
277
|
-
|
|
1306
|
+
async onReady({ logger: logger2, api }) {
|
|
1307
|
+
momentumApi = api;
|
|
1308
|
+
if (snapshotService) {
|
|
1309
|
+
await snapshotService.restore();
|
|
1310
|
+
snapshotService.start();
|
|
1311
|
+
logger2.info("Metrics snapshot service started");
|
|
1312
|
+
}
|
|
1313
|
+
},
|
|
1314
|
+
async onShutdown({ logger: logger2 }) {
|
|
1315
|
+
if (snapshotService) {
|
|
1316
|
+
await snapshotService.shutdown();
|
|
1317
|
+
snapshotService = null;
|
|
1318
|
+
}
|
|
278
1319
|
if (enricher) {
|
|
279
1320
|
MomentumLogger.removeEnricher(enricher);
|
|
280
1321
|
enricher = null;
|
|
281
1322
|
}
|
|
282
|
-
|
|
1323
|
+
if (meterProviderRef) {
|
|
1324
|
+
await meterProviderRef.shutdown();
|
|
1325
|
+
meterProviderRef = null;
|
|
1326
|
+
}
|
|
1327
|
+
metricsStore = null;
|
|
1328
|
+
momentumApi = null;
|
|
1329
|
+
logger2.info("OpenTelemetry plugin shut down");
|
|
283
1330
|
}
|
|
284
1331
|
};
|
|
285
1332
|
}
|
|
@@ -305,14 +1352,16 @@ function injectTracingHooks(collection, tracer, attributes, operationFilter) {
|
|
|
305
1352
|
const afterChangeHook = (args) => {
|
|
306
1353
|
const doc = args.doc ?? args.data ?? {};
|
|
307
1354
|
const span = doc["__otelSpan"];
|
|
308
|
-
if (span
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
typedSpan.end();
|
|
1355
|
+
if (isSpanLike(span)) {
|
|
1356
|
+
span.setStatus({ code: import_api.SpanStatusCode.OK });
|
|
1357
|
+
span.end();
|
|
312
1358
|
}
|
|
313
1359
|
if (args.doc) {
|
|
314
1360
|
delete args.doc["__otelSpan"];
|
|
315
1361
|
}
|
|
1362
|
+
if (args.data) {
|
|
1363
|
+
delete args.data["__otelSpan"];
|
|
1364
|
+
}
|
|
316
1365
|
};
|
|
317
1366
|
const beforeDeleteHook = (args) => {
|
|
318
1367
|
if (operationFilter && !operationFilter.includes("delete")) {
|
|
@@ -333,10 +1382,9 @@ function injectTracingHooks(collection, tracer, attributes, operationFilter) {
|
|
|
333
1382
|
const afterDeleteHook = (args) => {
|
|
334
1383
|
const doc = args.doc ?? {};
|
|
335
1384
|
const span = doc["__otelSpan"];
|
|
336
|
-
if (span
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
typedSpan.end();
|
|
1385
|
+
if (isSpanLike(span)) {
|
|
1386
|
+
span.setStatus({ code: import_api.SpanStatusCode.OK });
|
|
1387
|
+
span.end();
|
|
340
1388
|
}
|
|
341
1389
|
};
|
|
342
1390
|
const existingBeforeChange = collection.hooks.beforeChange ?? [];
|