adonisjs-server-stats 1.12.3 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/README.md +11 -6
  2. package/dist/configure.js +6 -1
  3. package/dist/core/collectors/collector.d.ts +83 -0
  4. package/dist/core/core/types.d.ts +186 -0
  5. package/dist/core/debug/types.d.ts +467 -0
  6. package/dist/core/index.js +184 -169
  7. package/dist/core/types.d.ts +855 -167
  8. package/dist/react/{CacheSection-CX2duJuc.js → CacheSection-DkLtukWz.js} +1 -1
  9. package/dist/react/{CacheTab-CgT4t0oZ.js → CacheTab-DWeXnEBI.js} +51 -46
  10. package/dist/react/ConfigContent-Bwvfl_G7.js +350 -0
  11. package/dist/react/{ConfigSection-Dt0FyeaW.js → ConfigSection-DaVbFPDg.js} +2 -2
  12. package/dist/react/{ConfigTab-KtiTtsrj.js → ConfigTab-D4640WJZ.js} +2 -2
  13. package/dist/react/{CustomPaneTab-15QoEplP.js → CustomPaneTab-DglmAVMC.js} +1 -1
  14. package/dist/react/{EmailsSection-YcJYR5eA.js → EmailsSection-vYx6ExTb.js} +1 -1
  15. package/dist/react/{EmailsTab-CUjubln_.js → EmailsTab-DKDrRmRH.js} +54 -49
  16. package/dist/react/{EventsSection-GGPoYSNv.js → EventsSection-CapH5xut.js} +1 -1
  17. package/dist/react/{EventsTab-Wdwr0_ny.js → EventsTab-BNVEySAm.js} +1 -1
  18. package/dist/react/{InternalsContent-BNOnSoi9.js → InternalsContent-C9lIA92C.js} +1 -1
  19. package/dist/react/{InternalsSection-BwrTfpjA.js → InternalsSection-lOv_UcSV.js} +1 -1
  20. package/dist/react/{InternalsTab--RD-L1dX.js → InternalsTab-BygoSorC.js} +1 -1
  21. package/dist/react/{JobsSection-BqqIh_DR.js → JobsSection-ZIQsJWid.js} +1 -1
  22. package/dist/react/{JobsTab-CBrU-ryL.js → JobsTab-DXQzXrDt.js} +1 -1
  23. package/dist/react/{LogEntryRow-BOrRkhRU.js → LogEntryRow-BWkHE51-.js} +1 -1
  24. package/dist/react/{LogsSection-Cm_lphM6.js → LogsSection-ConXdBkL.js} +2 -2
  25. package/dist/react/LogsTab-CJM47LPn.js +82 -0
  26. package/dist/react/{OverviewSection-XF7bakyM.js → OverviewSection-DOMu2qvl.js} +131 -131
  27. package/dist/react/{QueriesSection-DsQBKrNK.js → QueriesSection-DFsaOSJI.js} +1 -1
  28. package/dist/react/{QueriesTab-CxCC1GVq.js → QueriesTab-CY9CG_7L.js} +1 -1
  29. package/dist/react/RequestsSection-CC-eVHsl.js +326 -0
  30. package/dist/react/{RoutesSection-D0y5JQP4.js → RoutesSection-p1DMq41y.js} +1 -1
  31. package/dist/react/{RoutesTab-Y_alJVMV.js → RoutesTab-Cnqy8UcK.js} +1 -1
  32. package/dist/react/{SplitPaneWrapper-CZl1ouIT.js → SplitPaneWrapper-XgkA0QxE.js} +1 -1
  33. package/dist/react/{TimelineTab-HyqZpfbp.js → TimelineTab-1YOERxe5.js} +3 -3
  34. package/dist/react/index-DOSlCpZ9.js +1159 -0
  35. package/dist/react/index.js +1 -1
  36. package/dist/react/useApiClient-CtEG7rHs.js +11 -0
  37. package/dist/src/collectors/adonisjs_queue_collector.d.ts +21 -0
  38. package/dist/src/collectors/adonisjs_queue_collector.js +61 -0
  39. package/dist/src/collectors/auto_detect.js +8 -2
  40. package/dist/src/collectors/index.d.ts +2 -0
  41. package/dist/src/collectors/index.js +1 -0
  42. package/dist/src/collectors/queue_collector.js +22 -6
  43. package/dist/src/config/deprecation_migration.js +11 -0
  44. package/dist/src/dashboard/cache_handlers.js +22 -2
  45. package/dist/src/dashboard/flush_manager.d.ts +2 -0
  46. package/dist/src/dashboard/flush_manager.js +17 -0
  47. package/dist/src/dashboard/inspector_manager.d.ts +4 -4
  48. package/dist/src/dashboard/inspector_manager.js +20 -10
  49. package/dist/src/dashboard/integrations/adonisjs_queue_inspector.d.ts +42 -0
  50. package/dist/src/dashboard/integrations/adonisjs_queue_inspector.js +135 -0
  51. package/dist/src/dashboard/integrations/adonisjs_queue_store.d.ts +166 -0
  52. package/dist/src/dashboard/integrations/adonisjs_queue_store.js +629 -0
  53. package/dist/src/dashboard/integrations/config_inspector.d.ts +0 -1
  54. package/dist/src/dashboard/integrations/config_inspector.js +4 -2
  55. package/dist/src/dashboard/integrations/index.d.ts +4 -1
  56. package/dist/src/dashboard/integrations/index.js +2 -0
  57. package/dist/src/dashboard/integrations/queue_inspector.d.ts +5 -61
  58. package/dist/src/dashboard/integrations/queue_inspector.js +5 -1
  59. package/dist/src/dashboard/integrations/queue_inspector_contract.d.ts +73 -0
  60. package/dist/src/dashboard/integrations/queue_inspector_contract.js +15 -0
  61. package/dist/src/dashboard/paginate_helper.d.ts +5 -0
  62. package/dist/src/dashboard/paginate_helper.js +16 -4
  63. package/dist/src/dashboard/query_explain_handler.d.ts +6 -1
  64. package/dist/src/dashboard/query_explain_handler.js +12 -2
  65. package/dist/src/dashboard/write_queue.d.ts +13 -0
  66. package/dist/src/dashboard/write_queue.js +34 -2
  67. package/dist/src/debug/debug_store.js +8 -1
  68. package/dist/src/debug/event_collector.js +4 -0
  69. package/dist/src/debug/query_collector.js +2 -0
  70. package/dist/src/debug/ring_buffer.js +8 -1
  71. package/dist/src/debug/trace_collector.js +34 -10
  72. package/dist/src/debug/types.d.ts +2 -0
  73. package/dist/src/define_config.js +1 -0
  74. package/dist/src/edge/client/dashboard.js +2 -2
  75. package/dist/src/edge/client/debug-panel-deferred.js +1 -1
  76. package/dist/src/edge/client/stats-bar.js +1 -1
  77. package/dist/src/edge/client-vue/dashboard.js +5 -5
  78. package/dist/src/edge/client-vue/debug-panel-deferred.js +2 -2
  79. package/dist/src/engine/request_metrics.js +8 -10
  80. package/dist/src/log_stream/log_stream_service.d.ts +1 -0
  81. package/dist/src/log_stream/log_stream_service.js +7 -5
  82. package/dist/src/prometheus/prometheus_collector.d.ts +9 -0
  83. package/dist/src/prometheus/prometheus_collector.js +11 -0
  84. package/dist/src/provider/dashboard_init.js +15 -0
  85. package/dist/src/provider/dashboard_setup.js +9 -2
  86. package/dist/src/provider/email_bridge.d.ts +14 -3
  87. package/dist/src/provider/email_bridge.js +25 -3
  88. package/dist/src/provider/email_helpers.d.ts +9 -1
  89. package/dist/src/provider/email_helpers.js +32 -4
  90. package/dist/src/provider/pino_hook.d.ts +8 -0
  91. package/dist/src/provider/pino_hook.js +19 -1
  92. package/dist/src/provider/provider_helpers_extra.d.ts +2 -0
  93. package/dist/src/provider/provider_helpers_extra.js +17 -2
  94. package/dist/src/provider/server_stats_provider.js +19 -1
  95. package/dist/src/provider/shutdown_helpers.js +2 -0
  96. package/dist/src/provider/toolbar_setup.js +32 -17
  97. package/dist/src/routes/dashboard_routes.js +96 -62
  98. package/dist/src/routes/debug_routes.js +45 -35
  99. package/dist/src/routes/no_session_middleware.d.ts +6 -3
  100. package/dist/src/routes/no_session_middleware.js +7 -3
  101. package/dist/src/routes/register_routes.d.ts +8 -0
  102. package/dist/src/routes/register_routes.js +19 -0
  103. package/dist/src/types.d.ts +31 -0
  104. package/dist/src/utils/app_import.js +12 -4
  105. package/dist/vue/CacheSection-CxEBVVkF.js +156 -0
  106. package/dist/vue/ConfigSection-BiRAiaHj.js +415 -0
  107. package/dist/vue/EmailsSection-Dl44qyqY.js +208 -0
  108. package/dist/vue/{EmailsTab-CwIF1fik.js → EmailsTab-Diya54CP.js} +6 -5
  109. package/dist/vue/{EventsSection-BpgkWIwM.js → EventsSection-CWjeitjU.js} +1 -1
  110. package/dist/vue/{InternalsTab-521fxYYj.js → InternalsTab-Z3c82glB.js} +15 -15
  111. package/dist/vue/{JobsSection-DrghFEKL.js → JobsSection-DOBb4LjZ.js} +1 -1
  112. package/dist/vue/LogsSection-CXx-HOWJ.js +260 -0
  113. package/dist/vue/{OverviewSection-BAgZTPjY.js → OverviewSection-CyfNQ8uV.js} +318 -302
  114. package/dist/vue/{QueriesSection-CUpwhp7u.js → QueriesSection-CXBsFp-y.js} +1 -1
  115. package/dist/vue/{RequestsSection-D8P2xpF2.js → RequestsSection-YTIaZGZd.js} +151 -145
  116. package/dist/vue/{RoutesSection-0qB81hTT.js → RoutesSection-CDKMey49.js} +1 -1
  117. package/dist/vue/composables/useApiClient.d.ts +8 -1
  118. package/dist/vue/index-14x39RI_.js +1239 -0
  119. package/dist/vue/index.js +1 -1
  120. package/dist/vue/style.css +1 -1
  121. package/package.json +16 -3
  122. package/dist/react/ConfigContent-CnsEI4j3.js +0 -397
  123. package/dist/react/LogsTab-jKwv9G7Q.js +0 -79
  124. package/dist/react/RequestsSection-D8cMbZU0.js +0 -321
  125. package/dist/react/index-CsprmgzI.js +0 -1121
  126. package/dist/react/useApiClient-BVtNCmnL.js +0 -9
  127. package/dist/vue/CacheSection-DRqV3YX2.js +0 -149
  128. package/dist/vue/ConfigSection-C6pQCHAL.js +0 -576
  129. package/dist/vue/EmailsSection-BTNw3ZU2.js +0 -206
  130. package/dist/vue/LogsSection-BDxx9Bfi.js +0 -258
  131. package/dist/vue/index-30jLw-_w.js +0 -1232
  132. /package/dist/core/{api-client.d.ts → core/api-client.d.ts} +0 -0
  133. /package/dist/core/{config-utils.d.ts → core/config-utils.d.ts} +0 -0
  134. /package/dist/core/{constants.d.ts → core/constants.d.ts} +0 -0
  135. /package/dist/core/{dashboard-api.d.ts → core/dashboard-api.d.ts} +0 -0
  136. /package/dist/core/{dashboard-data-controller.d.ts → core/dashboard-data-controller.d.ts} +0 -0
  137. /package/dist/core/{dashboard-data-helpers.d.ts → core/dashboard-data-helpers.d.ts} +0 -0
  138. /package/dist/core/{debug-data-controller.d.ts → core/debug-data-controller.d.ts} +0 -0
  139. /package/dist/core/{define-config-helpers.d.ts → core/define-config-helpers.d.ts} +0 -0
  140. /package/dist/core/{explain-utils.d.ts → core/explain-utils.d.ts} +0 -0
  141. /package/dist/core/{feature-detect-helpers.d.ts → core/feature-detect-helpers.d.ts} +0 -0
  142. /package/dist/core/{feature-detect.d.ts → core/feature-detect.d.ts} +0 -0
  143. /package/dist/core/{field-resolvers.d.ts → core/field-resolvers.d.ts} +0 -0
  144. /package/dist/core/{formatters-helpers.d.ts → core/formatters-helpers.d.ts} +0 -0
  145. /package/dist/core/{formatters.d.ts → core/formatters.d.ts} +0 -0
  146. /package/dist/core/{history-buffer.d.ts → core/history-buffer.d.ts} +0 -0
  147. /package/dist/core/{icons.d.ts → core/icons.d.ts} +0 -0
  148. /package/dist/core/{index.d.ts → core/index.d.ts} +0 -0
  149. /package/dist/core/{internals-utils.d.ts → core/internals-utils.d.ts} +0 -0
  150. /package/dist/core/{job-utils.d.ts → core/job-utils.d.ts} +0 -0
  151. /package/dist/core/{log-utils-helpers.d.ts → core/log-utils-helpers.d.ts} +0 -0
  152. /package/dist/core/{log-utils.d.ts → core/log-utils.d.ts} +0 -0
  153. /package/dist/core/{metrics.d.ts → core/metrics.d.ts} +0 -0
  154. /package/dist/core/{pagination.d.ts → core/pagination.d.ts} +0 -0
  155. /package/dist/core/{queries-columns.d.ts → core/queries-columns.d.ts} +0 -0
  156. /package/dist/core/{queries-controller.d.ts → core/queries-controller.d.ts} +0 -0
  157. /package/dist/core/{query-utils.d.ts → core/query-utils.d.ts} +0 -0
  158. /package/dist/core/{resizable-columns.d.ts → core/resizable-columns.d.ts} +0 -0
  159. /package/dist/core/{routes.d.ts → core/routes.d.ts} +0 -0
  160. /package/dist/core/{server-stats-controller.d.ts → core/server-stats-controller.d.ts} +0 -0
  161. /package/dist/core/{sparkline.d.ts → core/sparkline.d.ts} +0 -0
  162. /package/dist/core/{split-pane.d.ts → core/split-pane.d.ts} +0 -0
  163. /package/dist/core/{theme.d.ts → core/theme.d.ts} +0 -0
  164. /package/dist/core/{trace-utils.d.ts → core/trace-utils.d.ts} +0 -0
  165. /package/dist/core/{transmit-adapter.d.ts → core/transmit-adapter.d.ts} +0 -0
  166. /package/dist/core/{transmit-helpers.d.ts → core/transmit-helpers.d.ts} +0 -0
  167. /package/dist/core/{types-dashboard.d.ts → core/types-dashboard.d.ts} +0 -0
  168. /package/dist/core/{types-diagnostics.d.ts → core/types-diagnostics.d.ts} +0 -0
@@ -28,8 +28,8 @@ export class RequestMetrics {
28
28
  }
29
29
  else {
30
30
  this.records[this.writeIndex] = record;
31
+ this.writeIndex = (this.writeIndex + 1) % this.maxRecords;
31
32
  }
32
- this.writeIndex = (this.writeIndex + 1) % this.maxRecords;
33
33
  }
34
34
  incrementActiveConnections() {
35
35
  this.activeConnections++;
@@ -64,15 +64,13 @@ export class RequestMetrics {
64
64
  }
65
65
  }
66
66
  #scanPartialBuffer(cutoff, acc) {
67
- let startIdx = 0;
68
- for (let i = this.count - 1; i >= 0; i--) {
69
- if (this.records[i].timestamp < cutoff) {
70
- startIdx = i + 1;
71
- break;
72
- }
73
- }
74
- for (let i = startIdx; i < this.count; i++) {
75
- accumulateRecord(acc, this.records[i]);
67
+ // Scan all records unconditionally: during the fill phase the buffer is
68
+ // small, and completion order is not guaranteed to match timestamp order,
69
+ // so we must select by timestamp rather than assume an ascending prefix.
70
+ for (let i = 0; i < this.count; i++) {
71
+ const r = this.records[i];
72
+ if (r.timestamp >= cutoff)
73
+ accumulateRecord(acc, r);
76
74
  }
77
75
  }
78
76
  }
@@ -7,6 +7,7 @@ export declare class LogStreamService {
7
7
  private intervalId;
8
8
  private logPath;
9
9
  private onEntry?;
10
+ private warnedPollFailure;
10
11
  constructor(logPath?: string, onEntry?: (entry: Record<string, unknown>) => void);
11
12
  /**
12
13
  * Ingest a parsed log entry directly (no file needed).
@@ -1,6 +1,5 @@
1
1
  import { open, stat } from 'node:fs/promises';
2
2
  import { log } from '../utils/logger.js';
3
- let warnedPollFailure = false;
4
3
  const LEVEL_NAMES = {
5
4
  10: 'trace',
6
5
  20: 'debug',
@@ -32,6 +31,7 @@ export class LogStreamService {
32
31
  intervalId = null;
33
32
  logPath;
34
33
  onEntry;
34
+ warnedPollFailure = false;
35
35
  constructor(logPath, onEntry) {
36
36
  this.logPath = logPath ?? null;
37
37
  this.onEntry = onEntry;
@@ -106,7 +106,7 @@ export class LogStreamService {
106
106
  if (!this.logPath)
107
107
  return;
108
108
  try {
109
- warnedPollFailure = false;
109
+ this.warnedPollFailure = false;
110
110
  const stats = await stat(this.logPath);
111
111
  // File was truncated/rotated — reset
112
112
  if (stats.size < this.lastSize) {
@@ -122,14 +122,16 @@ export class LogStreamService {
122
122
  for (const line of buffer.toString('utf-8').trim().split('\n')) {
123
123
  const entry = parseAndEnrich(line);
124
124
  if (entry) {
125
- this.recentEntries.push({ time: Date.now(), level: entry.level });
125
+ const level = typeof entry.level === 'number' ? entry.level : 30;
126
+ const time = typeof entry.time === 'number' ? entry.time : Date.now();
127
+ this.recentEntries.push({ time, level });
126
128
  this.onEntry?.(entry);
127
129
  }
128
130
  }
129
131
  }
130
132
  catch (err) {
131
- if (!warnedPollFailure) {
132
- warnedPollFailure = true;
133
+ if (!this.warnedPollFailure) {
134
+ this.warnedPollFailure = true;
133
135
  log.warn('log stream: cannot read log file — ' + err?.message);
134
136
  }
135
137
  }
@@ -18,3 +18,12 @@ export declare const ServerStatsCollector: {
18
18
  update(stats: Partial<ServerStats>): void;
19
19
  } | null;
20
20
  };
21
+ /**
22
+ * Drop the module-level collector singleton on shutdown.
23
+ *
24
+ * The `ServerStatsCollector.instance` reference outlives an individual app
25
+ * lifecycle. Leaving it set means a restart (common in tests / HMR) re-runs
26
+ * `register()` against a still-populated prom-client registry, which can throw
27
+ * "Metric already registered". Nulling it lets the next boot register cleanly.
28
+ */
29
+ export declare function resetServerStatsCollector(): void;
@@ -213,3 +213,14 @@ export function serverStatsCollector() {
213
213
  export const ServerStatsCollector = {
214
214
  instance: null,
215
215
  };
216
+ /**
217
+ * Drop the module-level collector singleton on shutdown.
218
+ *
219
+ * The `ServerStatsCollector.instance` reference outlives an individual app
220
+ * lifecycle. Leaving it set means a restart (common in tests / HMR) re-runs
221
+ * `register()` against a still-populated prom-client registry, which can throw
222
+ * "Metric already registered". Nulling it lets the next boot register cleanly.
223
+ */
224
+ export function resetServerStatsCollector() {
225
+ ServerStatsCollector.instance = null;
226
+ }
@@ -97,7 +97,22 @@ function pipeDashLogs(pinoHookActive, dashboardStore, makePath) {
97
97
  service.start().catch(() => { });
98
98
  return service;
99
99
  }
100
+ /**
101
+ * Whether this module has already installed the request-completion handler.
102
+ * `setOnRequestComplete` is a single-slot setter, so a second `initDashboardStore`
103
+ * (hot-reload / test restart) would silently clobber the first handler — and its
104
+ * `lastQueryId`/`dashboardStore` closure — leaving requests attributed to a stale
105
+ * store. We install once and warn on any later attempt. There is only ever one
106
+ * dashboard request pipe, so accumulating handlers would just double-persist.
107
+ */
108
+ let dashRequestPipeInstalled = false;
100
109
  function pipeDashRequests(debugStore, dashboardStore) {
110
+ if (dashRequestPipeInstalled) {
111
+ log.warn('dashboard: request pipe already installed — ignoring duplicate registration ' +
112
+ '(likely a hot-reload/re-init). Restart the process for a clean state.');
113
+ return;
114
+ }
115
+ dashRequestPipeInstalled = true;
101
116
  let lastQueryId = 0;
102
117
  setOnRequestComplete(({ method, url, statusCode, duration, trace, httpRequestId }) => {
103
118
  if (!dashboardStore.isReady())
@@ -27,10 +27,17 @@ export function classifyDashboardError(err) {
27
27
  * Race a promise against a timeout.
28
28
  */
29
29
  export function createStartTimeout(promise, timeoutMs) {
30
+ let timer;
30
31
  const timeoutPromise = new Promise((_, reject) => {
31
- setTimeout(() => reject(new Error(`Dashboard SQLite initialization timed out after ${timeoutMs / 1000}s`)), timeoutMs);
32
+ timer = setTimeout(() => reject(new Error(`Dashboard SQLite initialization timed out after ${timeoutMs / 1000}s`)), timeoutMs);
33
+ });
34
+ // Clear the timer once the race settles so a winning primary promise doesn't
35
+ // leave a dangling 15s timer that keeps the event loop alive (blocks clean
36
+ // test exit).
37
+ return Promise.race([promise, timeoutPromise]).finally(() => {
38
+ if (timer)
39
+ clearTimeout(timer);
32
40
  });
33
- return Promise.race([promise, timeoutPromise]);
34
41
  }
35
42
  /**
36
43
  * Build the list of URL prefixes to exclude from tracing.
@@ -13,14 +13,25 @@ interface RedisPublisher {
13
13
  interface RedisSubscriber extends RedisPublisher {
14
14
  subscribe(channel: string, handler: (message: string) => void): unknown;
15
15
  }
16
+ /** Sink that persists an ingested email (the SQLite dashboard store). */
17
+ interface DashboardEmailSink {
18
+ recordEmail(record: Record<string, unknown>): void;
19
+ }
16
20
  /** Targets for ingesting remote emails. */
17
21
  export interface EmailBridgeTargets {
18
22
  debugEmails: {
19
23
  ingest(record: Record<string, unknown>): void;
20
24
  } | null;
21
- dashboardStore: {
22
- recordEmail(record: Record<string, unknown>): void;
23
- } | null;
25
+ /**
26
+ * The persistent dashboard store, or a (possibly async) getter for it.
27
+ *
28
+ * A getter is used because the bridge subscribes during early boot —
29
+ * before the SQLite dashboard store exists — yet remote emails arrive
30
+ * later, by which time the store is available. Resolving lazily lets
31
+ * cross-process (queue-worker) emails land in SQLite, where the
32
+ * dashboard/debug APIs read from when persistence is enabled.
33
+ */
34
+ dashboardStore: DashboardEmailSink | (() => DashboardEmailSink | null | Promise<DashboardEmailSink | null>) | null;
24
35
  }
25
36
  /**
26
37
  * Ingest a remote email message from Redis pub/sub.
@@ -1,8 +1,23 @@
1
1
  /**
2
2
  * Email bridge helpers for cross-process email capture via Redis pub/sub.
3
3
  */
4
+ import { randomUUID } from 'node:crypto';
4
5
  import { log } from '../utils/logger.js';
5
6
  import { buildEmailPayload, MAIL_STATUS_MAP } from './email_helpers.js';
7
+ /** Resolve the dashboard sink whether it's a direct object, a getter, or null. */
8
+ async function resolveDashboardSink(store) {
9
+ if (!store)
10
+ return null;
11
+ if (typeof store === 'function') {
12
+ try {
13
+ return (await store()) ?? null;
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ return store;
20
+ }
6
21
  /**
7
22
  * Ingest a remote email message from Redis pub/sub.
8
23
  * Skips messages from the same process.
@@ -19,7 +34,11 @@ export function ingestRemoteEmail(message, processTag, targets) {
19
34
  text: fields.text || null,
20
35
  };
21
36
  targets.debugEmails?.ingest(record);
22
- targets.dashboardStore?.recordEmail({ id: 0, ...record });
37
+ // Persist to SQLite too (where the APIs read from). The store may be
38
+ // resolved lazily, so fire-and-forget without blocking ingestion.
39
+ void resolveDashboardSink(targets.dashboardStore).then((sink) => {
40
+ sink?.recordEmail({ id: 0, ...record });
41
+ });
23
42
  }
24
43
  catch {
25
44
  // Ignore malformed messages
@@ -61,7 +80,10 @@ async function subscribeToEmailBridge(opts) {
61
80
  * Returns the Redis instance (for cleanup) or null.
62
81
  */
63
82
  export async function setupFullEmailBridge(emitter, redis, channel, targets) {
64
- const processTag = `${process.pid}-${Date.now()}`;
83
+ // Append a random UUID: in PID-recycling environments (e.g. every container is
84
+ // pid 1) `pid-timestamp` can collide across processes, causing a peer's emails
85
+ // to be filtered out as self-sent. The UUID makes the tag unique per instance.
86
+ const processTag = `${process.pid}-${Date.now()}-${randomUUID()}`;
65
87
  registerMailEventPublisher(emitter, redis, processTag, channel);
66
88
  return subscribeToEmailBridge({
67
89
  redis,
@@ -74,7 +96,7 @@ export async function setupFullEmailBridge(emitter, redis, channel, targets) {
74
96
  * Lightweight publisher-only email bridge (for non-web environments).
75
97
  */
76
98
  export function setupPublisherOnlyBridge(emitter, redis, channel) {
77
- const tag = `${process.pid}-${Date.now()}`;
99
+ const tag = `${process.pid}-${Date.now()}-${randomUUID()}`;
78
100
  registerMailEventPublisher(emitter, redis, tag, channel);
79
101
  log.info('email bridge publisher active (queue worker → Redis)');
80
102
  }
@@ -1,5 +1,13 @@
1
1
  /**
2
- * Cap an HTML/text body to a maximum size.
2
+ * Redact token-bearing links from an email body before persistence.
3
+ *
4
+ * Conservative by design: strips values of known sensitive query params,
5
+ * neutralizes reset/verify/magic-link URLs, and redacts long opaque tokens
6
+ * that appear in URL path segments. Ordinary content is left untouched.
7
+ */
8
+ export declare function redactTokenLinks(body: string): string;
9
+ /**
10
+ * Cap an HTML/text body to a maximum size, stripping token-bearing links first.
3
11
  * Returns `null` for falsy or non-string inputs.
4
12
  */
5
13
  export declare function capHtmlSize(v: unknown, maxSize?: number): string | null;
@@ -1,15 +1,43 @@
1
1
  import { extractAddresses } from '../utils/mail_helpers.js';
2
2
  const MAX_HTML = 50_000;
3
3
  /**
4
- * Cap an HTML/text body to a maximum size.
4
+ * Sensitive query-parameter names whose values are stripped from captured
5
+ * URLs. These carry one-time login/reset/verify credentials.
6
+ */
7
+ const SENSITIVE_QUERY_PARAMS = /\b(token|code|access_token|refresh_token|api_key|apikey|key|secret|signature|sig|password|pwd|auth|otp|nonce|state)=[^\s&"'<>]*/gi;
8
+ /**
9
+ * URL path segments that indicate a credential-bearing link even when the
10
+ * secret is in the path rather than the query string (reset/verify/magic).
11
+ */
12
+ const SENSITIVE_URL_PATHS = /(https?:\/\/[^\s"'<>]*?\/(?:reset|verify|verification|confirm|magic|magic-link|activate|invite|unsubscribe)[^\s"'<>]*)/gi;
13
+ /**
14
+ * Redact token-bearing links from an email body before persistence.
15
+ *
16
+ * Conservative by design: strips values of known sensitive query params,
17
+ * neutralizes reset/verify/magic-link URLs, and redacts long opaque tokens
18
+ * that appear in URL path segments. Ordinary content is left untouched.
19
+ */
20
+ export function redactTokenLinks(body) {
21
+ return (body
22
+ // ?token=… / &code=… → keep the key, drop the value
23
+ .replace(SENSITIVE_QUERY_PARAMS, (m) => m.slice(0, m.indexOf('=') + 1) + '[redacted]')
24
+ // reset/verify/magic URLs → redact everything after the sensitive segment
25
+ .replace(SENSITIVE_URL_PATHS, (_m, url) => {
26
+ const marker = url.search(/\/(reset|verify|verification|confirm|magic|magic-link|activate|invite|unsubscribe)/i);
27
+ return marker === -1 ? url : url.slice(0, marker) + '/[redacted]';
28
+ }));
29
+ }
30
+ /**
31
+ * Cap an HTML/text body to a maximum size, stripping token-bearing links first.
5
32
  * Returns `null` for falsy or non-string inputs.
6
33
  */
7
34
  export function capHtmlSize(v, maxSize = MAX_HTML) {
8
35
  if (!v || typeof v !== 'string')
9
36
  return null;
10
- if (v.length <= maxSize)
11
- return v;
12
- return v.slice(0, maxSize) + '\n<!-- truncated -->';
37
+ const cleaned = redactTokenLinks(v);
38
+ if (cleaned.length <= maxSize)
39
+ return cleaned;
40
+ return cleaned.slice(0, maxSize) + '\n<!-- truncated -->';
13
41
  }
14
42
  /**
15
43
  * Mapping of AdonisJS mail events to their corresponding status strings.
@@ -6,10 +6,18 @@
6
6
  * Pino uses a local Symbol('pino.stream'), not a global registry symbol.
7
7
  */
8
8
  export declare function findPinoStreamSymbol(obj: object): symbol | undefined;
9
+ /** Undo a previously-applied {@link wrapWriteMethod} on the same stream. */
10
+ export declare function unwrapWriteMethod(stream: {
11
+ write: Function;
12
+ [key: string]: unknown;
13
+ }): void;
9
14
  /**
10
15
  * Wrap a stream's `write` method to intercept JSON log entries.
11
16
  * Calls `ingest` for each valid JSON entry that has a numeric `level`.
12
17
  * The original write is always called to preserve normal logging.
18
+ *
19
+ * Idempotent: if the stream's `write` is already wrapped by us, this is a
20
+ * no-op so a hot-reload can't double-ingest. Restore via {@link unwrapWriteMethod}.
13
21
  */
14
22
  export declare function wrapWriteMethod(stream: {
15
23
  write: Function;
@@ -8,14 +8,29 @@
8
8
  export function findPinoStreamSymbol(obj) {
9
9
  return Object.getOwnPropertySymbols(obj).find((s) => s.description === 'pino.stream');
10
10
  }
11
+ /** Marks a wrapped write fn so we never double-wrap and can restore the original. */
12
+ const WRAPPED_WRITE = Symbol('server-stats.wrappedWrite');
13
+ /** Undo a previously-applied {@link wrapWriteMethod} on the same stream. */
14
+ export function unwrapWriteMethod(stream) {
15
+ const current = stream.write;
16
+ const original = current?.[WRAPPED_WRITE];
17
+ if (original)
18
+ stream.write = original;
19
+ }
11
20
  /**
12
21
  * Wrap a stream's `write` method to intercept JSON log entries.
13
22
  * Calls `ingest` for each valid JSON entry that has a numeric `level`.
14
23
  * The original write is always called to preserve normal logging.
24
+ *
25
+ * Idempotent: if the stream's `write` is already wrapped by us, this is a
26
+ * no-op so a hot-reload can't double-ingest. Restore via {@link unwrapWriteMethod}.
15
27
  */
16
28
  export function wrapWriteMethod(stream, ingest) {
29
+ // Guard against re-wrapping an already-wrapped stream (hot-reload / restart).
30
+ if (stream.write?.[WRAPPED_WRITE])
31
+ return;
17
32
  const originalWrite = stream.write.bind(stream);
18
- stream.write = function wrappedWrite(chunk, ...args) {
33
+ const wrappedWrite = function wrappedWrite(chunk, ...args) {
19
34
  try {
20
35
  const str = typeof chunk === 'string'
21
36
  ? chunk
@@ -32,4 +47,7 @@ export function wrapWriteMethod(stream, ingest) {
32
47
  }
33
48
  return originalWrite(chunk, ...args);
34
49
  };
50
+ // Store the pre-wrap write so cleanup can restore it exactly.
51
+ wrappedWrite[WRAPPED_WRITE] = stream.write;
52
+ stream.write = wrappedWrite;
35
53
  }
@@ -25,6 +25,8 @@ export declare function setupDevToolbarCore(args: SetupDevToolbarArgs): Promise<
25
25
  export declare function applyToolbarResult(result: ToolbarCoreResult, tc: DevToolbarConfig, provider: ProviderFields): void;
26
26
  /** Hook the AdonisJS Pino logger into the log collector. Returns true if hooked. */
27
27
  export declare function hookPinoToLogStream(logger: unknown): boolean;
28
+ /** Restore the original pino `write`, undoing {@link hookPinoToLogStream}. */
29
+ export declare function unhookPinoFromLogStream(): void;
28
30
  /** Set up log stream broadcasting via Transmit. Returns LogStreamService if file-based fallback. */
29
31
  export declare function setupLogStreamBroadcast(transmit: {
30
32
  broadcast(ch: string, d: unknown): void;
@@ -9,7 +9,7 @@ import { getLogStreamService } from '../collectors/log_collector.js';
9
9
  import { LogStreamService } from '../log_stream/log_stream_service.js';
10
10
  import { log, dim, bold } from '../utils/logger.js';
11
11
  import { setupPublisherOnlyBridge } from './email_bridge.js';
12
- import { findPinoStreamSymbol, wrapWriteMethod } from './pino_hook.js';
12
+ import { findPinoStreamSymbol, wrapWriteMethod, unwrapWriteMethod } from './pino_hook.js';
13
13
  // Re-export toolbar/dashboard functions with provider-compatible signatures
14
14
  import { setupDevToolbarCore as coreSetup, applyToolbarResult as coreApply, } from './toolbar_setup.js';
15
15
  export { initDashboardStore } from './dashboard_init.js';
@@ -22,6 +22,12 @@ export function applyToolbarResult(result, tc, provider) {
22
22
  coreApply(result, tc, provider);
23
23
  }
24
24
  // ── hookPinoToLogStream ─────────────────────────────────────────
25
+ /**
26
+ * The pino stream whose `write` we last wrapped, kept so shutdown can restore
27
+ * it. Without this, a hot-reload would leave a stale wrapper in place and the
28
+ * next hook would stack on top — double-ingesting every log line.
29
+ */
30
+ let hookedPinoStream = null;
25
31
  /** Hook the AdonisJS Pino logger into the log collector. Returns true if hooked. */
26
32
  export function hookPinoToLogStream(logger) {
27
33
  const logStream = getLogStreamService();
@@ -38,10 +44,19 @@ export function hookPinoToLogStream(logger) {
38
44
  return false;
39
45
  if (typeof rawStream.write !== 'function')
40
46
  return false;
41
- wrapWriteMethod(rawStream, (entry) => logStream.ingest(entry));
47
+ const stream = rawStream;
48
+ wrapWriteMethod(stream, (entry) => logStream.ingest(entry));
49
+ hookedPinoStream = stream;
42
50
  log.info('log collector hooked into AdonisJS logger (zero-config)');
43
51
  return true;
44
52
  }
53
+ /** Restore the original pino `write`, undoing {@link hookPinoToLogStream}. */
54
+ export function unhookPinoFromLogStream() {
55
+ if (!hookedPinoStream)
56
+ return;
57
+ unwrapWriteMethod(hookedPinoStream);
58
+ hookedPinoStream = null;
59
+ }
45
60
  // ── setupLogStreamBroadcast ─────────────────────────────────────
46
61
  /** Set up log stream broadcasting via Transmit. Returns LogStreamService if file-based fallback. */
47
62
  export function setupLogStreamBroadcast(transmit, channelName, pinoHookActive, makePath) {
@@ -94,6 +94,7 @@ export default class ServerStatsProvider {
94
94
  debugEndpoint,
95
95
  dashboardPath,
96
96
  shouldShow: config.shouldShow,
97
+ unsafeAllowNoAuth: config.unsafeAllowNoAuth,
97
98
  whenReady: () => this.whenReady(),
98
99
  });
99
100
  const paths = collectRegisteredPaths(statsEndpoint, debugEndpoint, dashboardPath);
@@ -194,7 +195,17 @@ export default class ServerStatsProvider {
194
195
  return await this.app.container.make(binding);
195
196
  }
196
197
  catch (err) {
197
- log.info(`resolve('${binding}') failed: ${err?.message ?? err}`);
198
+ const message = err?.message ?? String(err);
199
+ // An unregistered optional binding (transmit, redis, etc.) is expected and
200
+ // stays quiet. Anything else is likely a real programming error surfacing
201
+ // during construction — surface it at warn level instead of hiding it.
202
+ const isMissingBinding = /cannot resolve|is not (bound|registered)|resolve .* binding/i.test(message);
203
+ if (isMissingBinding) {
204
+ log.info(`resolve('${binding}') failed: ${message}`);
205
+ }
206
+ else {
207
+ log.warn(`resolve('${binding}') failed unexpectedly: ${message}`);
208
+ }
198
209
  return null;
199
210
  }
200
211
  }
@@ -271,5 +282,12 @@ export default class ServerStatsProvider {
271
282
  debugStore: this.debugStore,
272
283
  engine: this.engine,
273
284
  });
285
+ if (this.prometheusActive) {
286
+ // Drop the module-level prometheus collector singleton so a restart can
287
+ // re-register metrics without hitting "Metric already registered".
288
+ const { resetServerStatsCollector } = await import('../prometheus/prometheus_collector.js');
289
+ resetServerStatsCollector();
290
+ this.prometheusActive = false;
291
+ }
274
292
  }
275
293
  }
@@ -2,6 +2,7 @@
2
2
  * Shutdown helper functions extracted from ServerStatsProvider.
3
3
  */
4
4
  import { setOnRequestComplete, setDashboardPath, setExcludedPrefixes, } from '../middleware/request_tracking_middleware.js';
5
+ import { unhookPinoFromLogStream } from './provider_helpers_extra.js';
5
6
  import { log } from '../utils/logger.js';
6
7
  /**
7
8
  * Clear all active timers and null out the references.
@@ -61,6 +62,7 @@ export function unsubscribeEmailBridge(redis, channel) {
61
62
  export async function cleanupResources(deps) {
62
63
  deps.logStreamService?.stop();
63
64
  deps.dashboardLogStream?.stop();
65
+ unhookPinoFromLogStream();
64
66
  setOnRequestComplete(null);
65
67
  setDashboardPath(null);
66
68
  setExcludedPrefixes([]);
@@ -18,7 +18,7 @@ export async function setupDevToolbarCore(opts) {
18
18
  if (!em)
19
19
  log.warn('emitter not available — query/event collection disabled');
20
20
  await debugStore.start(em, await resolve('router'));
21
- const emailBridgeRedis = await setupBridgeInternal(em, debugStore);
21
+ const emailBridgeRedis = await setupBridgeInternal(em, debugStore, app);
22
22
  const debugController = await createDebugController(debugStore, config, getDiagnostics, app);
23
23
  if (debugStore.traces)
24
24
  setTraceCollector(debugStore.traces);
@@ -66,14 +66,26 @@ function createFlushTimer(debugStore, persistPath) {
66
66
  catch { }
67
67
  }, 30_000);
68
68
  }
69
- async function setupBridgeInternal(emitter, debugStore) {
69
+ async function setupBridgeInternal(emitter, debugStore, app) {
70
70
  if (!emitter)
71
71
  return null;
72
72
  try {
73
73
  const { appImport } = await import('../utils/app_import.js');
74
74
  const mod = await appImport('@adonisjs/redis/services/main');
75
75
  const redis = mod.default;
76
- return await setupFullEmailBridge(emitter, redis, 'adonisjs-server-stats:emails', { debugEmails: debugStore.emails ?? null, dashboardStore: null });
76
+ // Lazily resolve the SQLite dashboard store: it's registered as a
77
+ // container singleton later in boot, but remote emails only arrive
78
+ // post-boot, so cross-process (queue-worker) emails get persisted
79
+ // where the dashboard/debug APIs read from.
80
+ const getDashboardStore = async () => {
81
+ try {
82
+ return (await app.container.make('dashboard.store'));
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ };
88
+ return await setupFullEmailBridge(emitter, redis, 'adonisjs-server-stats:emails', { debugEmails: debugStore.emails ?? null, dashboardStore: getDashboardStore });
77
89
  }
78
90
  catch {
79
91
  return null;
@@ -85,23 +97,26 @@ async function setupDebugBroadcastInternal(debugStore, resolve) {
85
97
  return { timer: null, transmitAvailable: false, channels: [] };
86
98
  const ch = 'server-stats/debug';
87
99
  const pending = new Set();
88
- let timer = null;
100
+ // A persistent (unref'd) interval flushes pending item types every 200ms.
101
+ // Returning this live handle lets shutdown clear it — the previous per-item
102
+ // `setTimeout` was never returned (returned `null`), so shutdown could not
103
+ // clear it and it could fire after stop() on a destroyed transmit instance.
104
+ const timer = setInterval(() => {
105
+ if (pending.size === 0)
106
+ return;
107
+ const ts = [...pending];
108
+ pending.clear();
109
+ try {
110
+ ;
111
+ t.broadcast(ch, { types: ts });
112
+ }
113
+ catch { }
114
+ }, 200);
115
+ timer.unref?.();
89
116
  debugStore.onNewItem((type) => {
90
117
  pending.add(type);
91
- if (timer)
92
- return;
93
- timer = setTimeout(() => {
94
- timer = null;
95
- const ts = [...pending];
96
- pending.clear();
97
- try {
98
- ;
99
- t.broadcast(ch, { types: ts });
100
- }
101
- catch { }
102
- }, 200);
103
118
  });
104
- return { timer: null, transmitAvailable: true, channels: [ch] };
119
+ return { timer, transmitAvailable: true, channels: [ch] };
105
120
  }
106
121
  /** Apply the toolbar core result back onto the provider instance. */
107
122
  export function applyToolbarResult(result, tc, provider) {