@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 CHANGED
@@ -58,10 +58,10 @@ var ANSI = {
58
58
  bgRed: "\x1B[41m",
59
59
  bgYellow: "\x1B[43m"
60
60
  };
61
- function colorize(text, ...codes) {
61
+ function colorize(text2, ...codes) {
62
62
  if (codes.length === 0)
63
- return text;
64
- return `${codes.join("")}${text}${ANSI.reset}`;
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(date) {
88
- const y = date.getFullYear();
89
- const mo = String(date.getMonth() + 1).padStart(2, "0");
90
- const d = String(date.getDate()).padStart(2, "0");
91
- const h = String(date.getHours()).padStart(2, "0");
92
- const mi = String(date.getMinutes()).padStart(2, "0");
93
- const s = String(date.getSeconds()).padStart(2, "0");
94
- const ms = String(date.getMilliseconds()).padStart(3, "0");
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 { serviceName = "momentum-cms", enrichLogs = true, attributes = {}, operations } = config;
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
- onInit({ collections, logger }) {
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
- logger.info(`OpenTelemetry tracing enabled (service: ${serviceName})`);
1235
+ logger2.info(`OpenTelemetry tracing enabled (service: ${serviceName})`);
267
1236
  if (enrichLogs) {
268
1237
  enricher = new OtelLogEnricher();
269
1238
  MomentumLogger.registerEnricher(enricher);
270
- logger.info("Log enricher registered for trace/span IDs");
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
- logger.info(`Tracing hooks injected into ${collections.length} collections`);
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
- onShutdown({ logger }) {
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
- logger.info("OpenTelemetry plugin shut down");
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 && typeof span === "object" && "end" in span) {
309
- const typedSpan = span;
310
- typedSpan.setStatus({ code: import_api.SpanStatusCode.OK });
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 && typeof span === "object" && "end" in span) {
337
- const typedSpan = span;
338
- typedSpan.setStatus({ code: import_api.SpanStatusCode.OK });
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 ?? [];