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.
- package/README.md +11 -6
- package/dist/configure.js +6 -1
- package/dist/core/collectors/collector.d.ts +83 -0
- package/dist/core/core/types.d.ts +186 -0
- package/dist/core/debug/types.d.ts +467 -0
- package/dist/core/index.js +184 -169
- package/dist/core/types.d.ts +855 -167
- package/dist/react/{CacheSection-CX2duJuc.js → CacheSection-DkLtukWz.js} +1 -1
- package/dist/react/{CacheTab-CgT4t0oZ.js → CacheTab-DWeXnEBI.js} +51 -46
- package/dist/react/ConfigContent-Bwvfl_G7.js +350 -0
- package/dist/react/{ConfigSection-Dt0FyeaW.js → ConfigSection-DaVbFPDg.js} +2 -2
- package/dist/react/{ConfigTab-KtiTtsrj.js → ConfigTab-D4640WJZ.js} +2 -2
- package/dist/react/{CustomPaneTab-15QoEplP.js → CustomPaneTab-DglmAVMC.js} +1 -1
- package/dist/react/{EmailsSection-YcJYR5eA.js → EmailsSection-vYx6ExTb.js} +1 -1
- package/dist/react/{EmailsTab-CUjubln_.js → EmailsTab-DKDrRmRH.js} +54 -49
- package/dist/react/{EventsSection-GGPoYSNv.js → EventsSection-CapH5xut.js} +1 -1
- package/dist/react/{EventsTab-Wdwr0_ny.js → EventsTab-BNVEySAm.js} +1 -1
- package/dist/react/{InternalsContent-BNOnSoi9.js → InternalsContent-C9lIA92C.js} +1 -1
- package/dist/react/{InternalsSection-BwrTfpjA.js → InternalsSection-lOv_UcSV.js} +1 -1
- package/dist/react/{InternalsTab--RD-L1dX.js → InternalsTab-BygoSorC.js} +1 -1
- package/dist/react/{JobsSection-BqqIh_DR.js → JobsSection-ZIQsJWid.js} +1 -1
- package/dist/react/{JobsTab-CBrU-ryL.js → JobsTab-DXQzXrDt.js} +1 -1
- package/dist/react/{LogEntryRow-BOrRkhRU.js → LogEntryRow-BWkHE51-.js} +1 -1
- package/dist/react/{LogsSection-Cm_lphM6.js → LogsSection-ConXdBkL.js} +2 -2
- package/dist/react/LogsTab-CJM47LPn.js +82 -0
- package/dist/react/{OverviewSection-XF7bakyM.js → OverviewSection-DOMu2qvl.js} +131 -131
- package/dist/react/{QueriesSection-DsQBKrNK.js → QueriesSection-DFsaOSJI.js} +1 -1
- package/dist/react/{QueriesTab-CxCC1GVq.js → QueriesTab-CY9CG_7L.js} +1 -1
- package/dist/react/RequestsSection-CC-eVHsl.js +326 -0
- package/dist/react/{RoutesSection-D0y5JQP4.js → RoutesSection-p1DMq41y.js} +1 -1
- package/dist/react/{RoutesTab-Y_alJVMV.js → RoutesTab-Cnqy8UcK.js} +1 -1
- package/dist/react/{SplitPaneWrapper-CZl1ouIT.js → SplitPaneWrapper-XgkA0QxE.js} +1 -1
- package/dist/react/{TimelineTab-HyqZpfbp.js → TimelineTab-1YOERxe5.js} +3 -3
- package/dist/react/index-DOSlCpZ9.js +1159 -0
- package/dist/react/index.js +1 -1
- package/dist/react/useApiClient-CtEG7rHs.js +11 -0
- package/dist/src/collectors/adonisjs_queue_collector.d.ts +21 -0
- package/dist/src/collectors/adonisjs_queue_collector.js +61 -0
- package/dist/src/collectors/auto_detect.js +8 -2
- package/dist/src/collectors/index.d.ts +2 -0
- package/dist/src/collectors/index.js +1 -0
- package/dist/src/collectors/queue_collector.js +22 -6
- package/dist/src/config/deprecation_migration.js +11 -0
- package/dist/src/dashboard/cache_handlers.js +22 -2
- package/dist/src/dashboard/flush_manager.d.ts +2 -0
- package/dist/src/dashboard/flush_manager.js +17 -0
- package/dist/src/dashboard/inspector_manager.d.ts +4 -4
- package/dist/src/dashboard/inspector_manager.js +20 -10
- package/dist/src/dashboard/integrations/adonisjs_queue_inspector.d.ts +42 -0
- package/dist/src/dashboard/integrations/adonisjs_queue_inspector.js +135 -0
- package/dist/src/dashboard/integrations/adonisjs_queue_store.d.ts +166 -0
- package/dist/src/dashboard/integrations/adonisjs_queue_store.js +629 -0
- package/dist/src/dashboard/integrations/config_inspector.d.ts +0 -1
- package/dist/src/dashboard/integrations/config_inspector.js +4 -2
- package/dist/src/dashboard/integrations/index.d.ts +4 -1
- package/dist/src/dashboard/integrations/index.js +2 -0
- package/dist/src/dashboard/integrations/queue_inspector.d.ts +5 -61
- package/dist/src/dashboard/integrations/queue_inspector.js +5 -1
- package/dist/src/dashboard/integrations/queue_inspector_contract.d.ts +73 -0
- package/dist/src/dashboard/integrations/queue_inspector_contract.js +15 -0
- package/dist/src/dashboard/paginate_helper.d.ts +5 -0
- package/dist/src/dashboard/paginate_helper.js +16 -4
- package/dist/src/dashboard/query_explain_handler.d.ts +6 -1
- package/dist/src/dashboard/query_explain_handler.js +12 -2
- package/dist/src/dashboard/write_queue.d.ts +13 -0
- package/dist/src/dashboard/write_queue.js +34 -2
- package/dist/src/debug/debug_store.js +8 -1
- package/dist/src/debug/event_collector.js +4 -0
- package/dist/src/debug/query_collector.js +2 -0
- package/dist/src/debug/ring_buffer.js +8 -1
- package/dist/src/debug/trace_collector.js +34 -10
- package/dist/src/debug/types.d.ts +2 -0
- package/dist/src/define_config.js +1 -0
- package/dist/src/edge/client/dashboard.js +2 -2
- package/dist/src/edge/client/debug-panel-deferred.js +1 -1
- package/dist/src/edge/client/stats-bar.js +1 -1
- package/dist/src/edge/client-vue/dashboard.js +5 -5
- package/dist/src/edge/client-vue/debug-panel-deferred.js +2 -2
- package/dist/src/engine/request_metrics.js +8 -10
- package/dist/src/log_stream/log_stream_service.d.ts +1 -0
- package/dist/src/log_stream/log_stream_service.js +7 -5
- package/dist/src/prometheus/prometheus_collector.d.ts +9 -0
- package/dist/src/prometheus/prometheus_collector.js +11 -0
- package/dist/src/provider/dashboard_init.js +15 -0
- package/dist/src/provider/dashboard_setup.js +9 -2
- package/dist/src/provider/email_bridge.d.ts +14 -3
- package/dist/src/provider/email_bridge.js +25 -3
- package/dist/src/provider/email_helpers.d.ts +9 -1
- package/dist/src/provider/email_helpers.js +32 -4
- package/dist/src/provider/pino_hook.d.ts +8 -0
- package/dist/src/provider/pino_hook.js +19 -1
- package/dist/src/provider/provider_helpers_extra.d.ts +2 -0
- package/dist/src/provider/provider_helpers_extra.js +17 -2
- package/dist/src/provider/server_stats_provider.js +19 -1
- package/dist/src/provider/shutdown_helpers.js +2 -0
- package/dist/src/provider/toolbar_setup.js +32 -17
- package/dist/src/routes/dashboard_routes.js +96 -62
- package/dist/src/routes/debug_routes.js +45 -35
- package/dist/src/routes/no_session_middleware.d.ts +6 -3
- package/dist/src/routes/no_session_middleware.js +7 -3
- package/dist/src/routes/register_routes.d.ts +8 -0
- package/dist/src/routes/register_routes.js +19 -0
- package/dist/src/types.d.ts +31 -0
- package/dist/src/utils/app_import.js +12 -4
- package/dist/vue/CacheSection-CxEBVVkF.js +156 -0
- package/dist/vue/ConfigSection-BiRAiaHj.js +415 -0
- package/dist/vue/EmailsSection-Dl44qyqY.js +208 -0
- package/dist/vue/{EmailsTab-CwIF1fik.js → EmailsTab-Diya54CP.js} +6 -5
- package/dist/vue/{EventsSection-BpgkWIwM.js → EventsSection-CWjeitjU.js} +1 -1
- package/dist/vue/{InternalsTab-521fxYYj.js → InternalsTab-Z3c82glB.js} +15 -15
- package/dist/vue/{JobsSection-DrghFEKL.js → JobsSection-DOBb4LjZ.js} +1 -1
- package/dist/vue/LogsSection-CXx-HOWJ.js +260 -0
- package/dist/vue/{OverviewSection-BAgZTPjY.js → OverviewSection-CyfNQ8uV.js} +318 -302
- package/dist/vue/{QueriesSection-CUpwhp7u.js → QueriesSection-CXBsFp-y.js} +1 -1
- package/dist/vue/{RequestsSection-D8P2xpF2.js → RequestsSection-YTIaZGZd.js} +151 -145
- package/dist/vue/{RoutesSection-0qB81hTT.js → RoutesSection-CDKMey49.js} +1 -1
- package/dist/vue/composables/useApiClient.d.ts +8 -1
- package/dist/vue/index-14x39RI_.js +1239 -0
- package/dist/vue/index.js +1 -1
- package/dist/vue/style.css +1 -1
- package/package.json +16 -3
- package/dist/react/ConfigContent-CnsEI4j3.js +0 -397
- package/dist/react/LogsTab-jKwv9G7Q.js +0 -79
- package/dist/react/RequestsSection-D8cMbZU0.js +0 -321
- package/dist/react/index-CsprmgzI.js +0 -1121
- package/dist/react/useApiClient-BVtNCmnL.js +0 -9
- package/dist/vue/CacheSection-DRqV3YX2.js +0 -149
- package/dist/vue/ConfigSection-C6pQCHAL.js +0 -576
- package/dist/vue/EmailsSection-BTNw3ZU2.js +0 -206
- package/dist/vue/LogsSection-BDxx9Bfi.js +0 -258
- package/dist/vue/index-30jLw-_w.js +0 -1232
- /package/dist/core/{api-client.d.ts → core/api-client.d.ts} +0 -0
- /package/dist/core/{config-utils.d.ts → core/config-utils.d.ts} +0 -0
- /package/dist/core/{constants.d.ts → core/constants.d.ts} +0 -0
- /package/dist/core/{dashboard-api.d.ts → core/dashboard-api.d.ts} +0 -0
- /package/dist/core/{dashboard-data-controller.d.ts → core/dashboard-data-controller.d.ts} +0 -0
- /package/dist/core/{dashboard-data-helpers.d.ts → core/dashboard-data-helpers.d.ts} +0 -0
- /package/dist/core/{debug-data-controller.d.ts → core/debug-data-controller.d.ts} +0 -0
- /package/dist/core/{define-config-helpers.d.ts → core/define-config-helpers.d.ts} +0 -0
- /package/dist/core/{explain-utils.d.ts → core/explain-utils.d.ts} +0 -0
- /package/dist/core/{feature-detect-helpers.d.ts → core/feature-detect-helpers.d.ts} +0 -0
- /package/dist/core/{feature-detect.d.ts → core/feature-detect.d.ts} +0 -0
- /package/dist/core/{field-resolvers.d.ts → core/field-resolvers.d.ts} +0 -0
- /package/dist/core/{formatters-helpers.d.ts → core/formatters-helpers.d.ts} +0 -0
- /package/dist/core/{formatters.d.ts → core/formatters.d.ts} +0 -0
- /package/dist/core/{history-buffer.d.ts → core/history-buffer.d.ts} +0 -0
- /package/dist/core/{icons.d.ts → core/icons.d.ts} +0 -0
- /package/dist/core/{index.d.ts → core/index.d.ts} +0 -0
- /package/dist/core/{internals-utils.d.ts → core/internals-utils.d.ts} +0 -0
- /package/dist/core/{job-utils.d.ts → core/job-utils.d.ts} +0 -0
- /package/dist/core/{log-utils-helpers.d.ts → core/log-utils-helpers.d.ts} +0 -0
- /package/dist/core/{log-utils.d.ts → core/log-utils.d.ts} +0 -0
- /package/dist/core/{metrics.d.ts → core/metrics.d.ts} +0 -0
- /package/dist/core/{pagination.d.ts → core/pagination.d.ts} +0 -0
- /package/dist/core/{queries-columns.d.ts → core/queries-columns.d.ts} +0 -0
- /package/dist/core/{queries-controller.d.ts → core/queries-controller.d.ts} +0 -0
- /package/dist/core/{query-utils.d.ts → core/query-utils.d.ts} +0 -0
- /package/dist/core/{resizable-columns.d.ts → core/resizable-columns.d.ts} +0 -0
- /package/dist/core/{routes.d.ts → core/routes.d.ts} +0 -0
- /package/dist/core/{server-stats-controller.d.ts → core/server-stats-controller.d.ts} +0 -0
- /package/dist/core/{sparkline.d.ts → core/sparkline.d.ts} +0 -0
- /package/dist/core/{split-pane.d.ts → core/split-pane.d.ts} +0 -0
- /package/dist/core/{theme.d.ts → core/theme.d.ts} +0 -0
- /package/dist/core/{trace-utils.d.ts → core/trace-utils.d.ts} +0 -0
- /package/dist/core/{transmit-adapter.d.ts → core/transmit-adapter.d.ts} +0 -0
- /package/dist/core/{transmit-helpers.d.ts → core/transmit-helpers.d.ts} +0 -0
- /package/dist/core/{types-dashboard.d.ts → core/types-dashboard.d.ts} +0 -0
- /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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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) {
|