adonisjs-server-stats 1.15.0 → 1.16.1

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 (38) hide show
  1. package/README.md +88 -2
  2. package/dist/core/debug/types.d.ts +14 -0
  3. package/dist/core/types.d.ts +109 -4
  4. package/dist/src/dashboard/dashboard_controller.d.ts +7 -0
  5. package/dist/src/dashboard/dashboard_controller.js +10 -3
  6. package/dist/src/dashboard/dashboard_store.d.ts +1 -2
  7. package/dist/src/dashboard/dashboard_store.js +0 -3
  8. package/dist/src/dashboard/dashboard_types.d.ts +2 -0
  9. package/dist/src/dashboard/flush_manager.d.ts +1 -6
  10. package/dist/src/dashboard/flush_manager.js +3 -10
  11. package/dist/src/dashboard/integrations/config_inspector.js +9 -49
  12. package/dist/src/dashboard/sensitive_patterns.d.ts +39 -0
  13. package/dist/src/dashboard/sensitive_patterns.js +118 -0
  14. package/dist/src/dashboard/write_queue.d.ts +25 -13
  15. package/dist/src/dashboard/write_queue.js +63 -37
  16. package/dist/src/debug/debug_store.d.ts +15 -1
  17. package/dist/src/debug/debug_store.js +32 -4
  18. package/dist/src/debug/event_collector.d.ts +8 -0
  19. package/dist/src/debug/event_collector.js +12 -0
  20. package/dist/src/debug/types.d.ts +14 -0
  21. package/dist/src/define_config.js +23 -0
  22. package/dist/src/middleware/request_tracking_middleware.d.ts +2 -1
  23. package/dist/src/middleware/request_tracking_middleware.js +16 -1
  24. package/dist/src/provider/boot_helpers.d.ts +9 -0
  25. package/dist/src/provider/boot_helpers.js +31 -0
  26. package/dist/src/provider/dashboard_init.js +14 -1
  27. package/dist/src/provider/dashboard_setup.d.ts +10 -1
  28. package/dist/src/provider/dashboard_setup.js +47 -2
  29. package/dist/src/provider/server_stats_provider.d.ts +7 -0
  30. package/dist/src/provider/server_stats_provider.js +23 -7
  31. package/dist/src/provider/toolbar_setup.js +5 -1
  32. package/dist/src/routes/access_middleware.d.ts +7 -1
  33. package/dist/src/routes/access_middleware.js +6 -1
  34. package/dist/src/routes/register_routes.d.ts +13 -3
  35. package/dist/src/routes/register_routes.js +15 -1
  36. package/dist/src/stubs/config.stub +8 -0
  37. package/dist/src/types.d.ts +109 -4
  38. package/package.json +1 -1
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Shared server-side rules for recognising credentials.
3
+ *
4
+ * Both the config inspector and the SQL-binding writer need to answer "does
5
+ * this look like a secret?", and they used to answer it differently — config
6
+ * values were redacted against a real word list while query bindings were only
7
+ * truncated by length. One list, used by both, so the same secret cannot be
8
+ * masked in one view and printed in full in another.
9
+ */
10
+ // Custom word boundaries: `\b` does not match between `CLIENT` and `SECRET` in
11
+ // `GOOGLE_CLIENT_SECRET`, because `_` is a word character. These treat `_`,
12
+ // `.`, and `-` as separators, and also match a bare token on its own.
13
+ const B = '(?:^|[_.\\-])'; // boundary before
14
+ const A = '(?:$|[_.\\-])'; // boundary after
15
+ /**
16
+ * Names that identify a credential — applies to env vars, config keys, and SQL
17
+ * identifiers alike.
18
+ */
19
+ export const SECRET_NAME_PATTERNS = [
20
+ new RegExp(`${B}password${A}`, 'i'),
21
+ new RegExp(`${B}secret${A}`, 'i'),
22
+ new RegExp(`${B}token${A}`, 'i'),
23
+ new RegExp(`${B}credential${A}`, 'i'),
24
+ new RegExp(`${B}private${A}`, 'i'),
25
+ new RegExp(`${B}auth${A}`, 'i'),
26
+ // API keys: `api_key`, `apiKey`, `API_KEY`
27
+ /api[_-]?key/i,
28
+ // `_KEY` at end or `_KEY_` in middle (AWS_ACCESS_KEY_ID, ENCRYPTION_KEY, etc.)
29
+ /[_-]key([_-]|$)/i,
30
+ // ACCESS_KEY pattern (AWS credentials)
31
+ /access[_-]?key/i,
32
+ // Exact match for just "key" (standalone)
33
+ /^key$/i,
34
+ // Connection strings and DSNs
35
+ new RegExp(`${B}dsn${A}`, 'i'),
36
+ /connection[_-]?string/i,
37
+ // Webhook secrets
38
+ /webhook[_-]?secret/i,
39
+ // Signing / encryption
40
+ new RegExp(`${B}signing${A}`, 'i'),
41
+ new RegExp(`${B}encryption${A}`, 'i'),
42
+ // App key / app secret
43
+ /app[_-]key/i,
44
+ // One-time codes
45
+ new RegExp(`${B}otp${A}`, 'i'),
46
+ ];
47
+ /**
48
+ * Names that matter for env vars and config keys but NOT for SQL identifiers.
49
+ *
50
+ * An `email` env var is usually an SMTP account; an `email` *column* is ordinary
51
+ * application data, and redacting every binding of every query that touches it
52
+ * would make the query pane useless for debugging auth. Same for the service
53
+ * URLs, which are env-shaped names rather than column names.
54
+ */
55
+ export const CONFIG_ONLY_NAME_PATTERNS = [
56
+ new RegExp(`${B}email${A}`, 'i'),
57
+ new RegExp(`${B}smtp${A}`, 'i'),
58
+ /database[_-]?url/i,
59
+ /redis[_-]?url/i,
60
+ ];
61
+ /** Whether a name identifies a credential. */
62
+ export function isSecretName(name) {
63
+ return SECRET_NAME_PATTERNS.some((pattern) => pattern.test(name));
64
+ }
65
+ /** Whether a name is sensitive in a config/env context (credentials plus contact/service names). */
66
+ export function isSensitiveConfigName(name) {
67
+ return isSecretName(name) || CONFIG_ONLY_NAME_PATTERNS.some((pattern) => pattern.test(name));
68
+ }
69
+ /** Identifier-ish tokens in a SQL statement: table names, column names, aliases. */
70
+ const SQL_IDENTIFIER_RE = /[A-Za-z_][A-Za-z0-9_$]*/g;
71
+ /**
72
+ * Whether a SQL statement mentions a credential-shaped identifier.
73
+ *
74
+ * Tokenised first: the name patterns above use `_`/`.`/`-` boundaries, so
75
+ * running them across raw SQL would miss `password` sitting between spaces.
76
+ *
77
+ * Positional bindings cannot be mapped back to specific columns reliably, so a
78
+ * hit means every binding for that statement is redacted. Coarse on purpose —
79
+ * over-redacting one statement's parameters beats storing a password.
80
+ */
81
+ export function sqlMentionsSecret(sql) {
82
+ for (const match of sql.matchAll(SQL_IDENTIFIER_RE)) {
83
+ if (isSecretName(match[0]))
84
+ return true;
85
+ }
86
+ return false;
87
+ }
88
+ /**
89
+ * Whether a value looks like a credential from its shape alone, independent of
90
+ * any name.
91
+ *
92
+ * Deliberately excludes bare email addresses: they are sensitive as *config*
93
+ * (see {@link CONFIG_ONLY_NAME_PATTERNS}) but are ordinary query parameters.
94
+ */
95
+ const CREDENTIAL_VALUE_PATTERNS = [
96
+ // URL with userinfo — credentials embedded in the URL
97
+ /^[a-z][a-z0-9+.-]*:\/\/[^/]*:[^/]*@/i,
98
+ // bcrypt / argon2 / scrypt password hashes
99
+ /^\$(?:2[aby]|argon2[a-z]*|scrypt|s?crypt)\$/i,
100
+ // JWT
101
+ /^ey[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\./,
102
+ // Well-known provider key prefixes. Underscores allowed in the tail so
103
+ // `sk_live_…` / `pk_test_…` match (Stripe, GitHub, Slack, AWS).
104
+ /^(?:sk|pk|rk|whsec)_[A-Za-z0-9_]{8,}/,
105
+ /^gh[pousr]_[A-Za-z0-9]{8,}/,
106
+ /^xox[baprs]-[A-Za-z0-9-]{8,}/,
107
+ /^(?:AKIA|ASIA)[A-Z0-9]{12,}$/,
108
+ // Long hex digests — session ids, reset tokens, sha hashes
109
+ /^[0-9a-f]{32,}$/i,
110
+ ];
111
+ /** Minimum length before a base64url-shaped blob is treated as high-entropy. */
112
+ const MIN_BLOB_LEN = 40;
113
+ export function looksLikeCredentialValue(value) {
114
+ // Long high-entropy base64url blobs — length-gated, so kept out of the table.
115
+ if (value.length >= MIN_BLOB_LEN && /^[A-Za-z0-9_-]+={0,2}$/.test(value))
116
+ return true;
117
+ return CREDENTIAL_VALUE_PATTERNS.some((pattern) => pattern.test(value));
118
+ }
@@ -21,13 +21,26 @@ export declare function markWarned(path: string): void;
21
21
  */
22
22
  export declare function normalizeSql(sql: string): string;
23
23
  /**
24
- * Redact/truncate SQL bindings before persistence so secret-looking values
25
- * (long tokens, hashes, keys) are not stored in cleartext.
24
+ * Redact and truncate SQL bindings before persistence.
26
25
  *
27
- * Conservative: only long strings are truncated; short values (ids, flags,
28
- * emails, ordinary params) pass through so normal capture is unaffected.
26
+ * Two independent rules, because neither catches everything on its own:
27
+ *
28
+ * 1. **By statement.** If the SQL mentions a credential-shaped identifier
29
+ * (`password`, `remember_token`, `otp`, ...), every binding for that
30
+ * statement is redacted. Positional bindings cannot be mapped back to
31
+ * columns reliably, so this is all-or-nothing per statement — coarse, but
32
+ * it is the only thing that catches a short secret like a 6-digit OTP.
33
+ * 2. **By value shape.** Hashes, JWTs, provider key prefixes, long hex
34
+ * digests, and URLs with embedded credentials are redacted wherever they
35
+ * appear, regardless of the statement.
36
+ *
37
+ * Anything that survives both is truncated at {@link MAX_BINDING_LEN} so an
38
+ * oversized payload cannot bloat the row.
39
+ *
40
+ * Ordinary parameters — ids, flags, emails, timestamps — still pass through, so
41
+ * the query pane stays useful for debugging.
29
42
  */
30
- export declare function sanitizeBindings(bindings: unknown): unknown;
43
+ export declare function sanitizeBindings(bindings: unknown, sqlText?: string): unknown;
31
44
  export interface PreparedQuery {
32
45
  sql_text: string;
33
46
  sql_normalized: string;
@@ -51,6 +64,7 @@ export interface PreparedRequest {
51
64
  input: PersistRequestInput;
52
65
  filteredQueries: PreparedQuery[];
53
66
  traceRow: PreparedTraceRow | null;
67
+ eventRows: EventRow[];
54
68
  }
55
69
  export interface PreparedLog {
56
70
  [key: string]: unknown;
@@ -75,7 +89,6 @@ export interface EmailRow {
75
89
  }
76
90
  export interface EventRow {
77
91
  [key: string]: unknown;
78
- request_id: null;
79
92
  event_name: string;
80
93
  data: string | null;
81
94
  }
@@ -96,19 +109,18 @@ export declare function buildEmailRow(record: EmailRecord): EmailRow;
96
109
  /**
97
110
  * Transform EventRecords into SQLite-ready row objects.
98
111
  */
112
+ /**
113
+ * Build event rows. `request_id` is attached at insert time, once the owning
114
+ * request row has an id — leaving it null here (as this did previously) meant
115
+ * retention never reclaimed them, since events are only pruned via the
116
+ * `server_stats_requests` foreign-key cascade.
117
+ */
99
118
  export declare function buildEventRows(events: EventRecord[]): EventRow[];
100
119
  /**
101
120
  * Insert rows into a table in batches of 50.
102
121
  */
103
122
  export declare function batchInsert(trx: Knex.Transaction, table: string, rows: Record<string, unknown>[]): Promise<void>;
104
123
  export declare function flushRequests(trx: Knex.Transaction, preparedRequests: PreparedRequest[]): Promise<void>;
105
- /**
106
- * Flush pending events into the database.
107
- */
108
- export declare function flushEvents(trx: Knex.Transaction, events: {
109
- requestIndex: number;
110
- events: EventRecord[];
111
- }[]): Promise<void>;
112
124
  /**
113
125
  * Flush pending emails into the database.
114
126
  */
@@ -6,6 +6,7 @@
6
6
  * be tested in isolation.
7
7
  */
8
8
  import { round } from '../utils/math_helpers.js';
9
+ import { isSecretName, looksLikeCredentialValue, sqlMentionsSecret } from './sensitive_patterns.js';
9
10
  // ---------------------------------------------------------------------------
10
11
  // Warn-once tracking for write-path catch blocks
11
12
  // ---------------------------------------------------------------------------
@@ -40,27 +41,60 @@ export function normalizeSql(sql) {
40
41
  // ---------------------------------------------------------------------------
41
42
  /** Max length for a persisted string binding before it is truncated. */
42
43
  const MAX_BINDING_LEN = 256;
44
+ /** Placeholder stored in place of a binding that looks like a credential. */
45
+ const REDACTED_BINDING = '[redacted]';
43
46
  /**
44
- * Redact/truncate SQL bindings before persistence so secret-looking values
45
- * (long tokens, hashes, keys) are not stored in cleartext.
47
+ * Redact and truncate SQL bindings before persistence.
46
48
  *
47
- * Conservative: only long strings are truncated; short values (ids, flags,
48
- * emails, ordinary params) pass through so normal capture is unaffected.
49
+ * Two independent rules, because neither catches everything on its own:
50
+ *
51
+ * 1. **By statement.** If the SQL mentions a credential-shaped identifier
52
+ * (`password`, `remember_token`, `otp`, ...), every binding for that
53
+ * statement is redacted. Positional bindings cannot be mapped back to
54
+ * columns reliably, so this is all-or-nothing per statement — coarse, but
55
+ * it is the only thing that catches a short secret like a 6-digit OTP.
56
+ * 2. **By value shape.** Hashes, JWTs, provider key prefixes, long hex
57
+ * digests, and URLs with embedded credentials are redacted wherever they
58
+ * appear, regardless of the statement.
59
+ *
60
+ * Anything that survives both is truncated at {@link MAX_BINDING_LEN} so an
61
+ * oversized payload cannot bloat the row.
62
+ *
63
+ * Ordinary parameters — ids, flags, emails, timestamps — still pass through, so
64
+ * the query pane stays useful for debugging.
49
65
  */
50
- export function sanitizeBindings(bindings) {
51
- if (Array.isArray(bindings))
52
- return bindings.map(sanitizeBindings);
53
- if (typeof bindings === 'string' && bindings.length > MAX_BINDING_LEN) {
54
- return bindings.slice(0, MAX_BINDING_LEN) + `…[truncated ${bindings.length} chars]`;
66
+ export function sanitizeBindings(bindings, sqlText) {
67
+ const redactAll = sqlText !== undefined && sqlMentionsSecret(sqlText);
68
+ return sanitizeBindingValue(bindings, redactAll);
69
+ }
70
+ function sanitizeBindingValue(value, redactAll) {
71
+ if (Array.isArray(value))
72
+ return value.map((v) => sanitizeBindingValue(v, redactAll));
73
+ if (value !== null && typeof value === 'object')
74
+ return sanitizeNamedBindings(value, redactAll);
75
+ if (typeof value === 'string')
76
+ return sanitizeStringBinding(value, redactAll);
77
+ // A statement touching a secret column may bind it as a non-string — a numeric
78
+ // OTP, for instance — so redact those too rather than only masking strings.
79
+ // Booleans and null carry nothing worth hiding.
80
+ const redactable = value !== null && value !== undefined && typeof value !== 'boolean';
81
+ return redactAll && redactable ? REDACTED_BINDING : value;
82
+ }
83
+ /** Named bindings carry their own key, so use it when it is telling. */
84
+ function sanitizeNamedBindings(value, redactAll) {
85
+ const out = {};
86
+ for (const [key, nested] of Object.entries(value)) {
87
+ out[key] = sanitizeBindingValue(nested, redactAll || isSecretName(key));
55
88
  }
56
- if (bindings && typeof bindings === 'object') {
57
- const out = {};
58
- for (const [k, v] of Object.entries(bindings)) {
59
- out[k] = sanitizeBindings(v);
60
- }
61
- return out;
89
+ return out;
90
+ }
91
+ function sanitizeStringBinding(value, redactAll) {
92
+ if (redactAll || looksLikeCredentialValue(value))
93
+ return REDACTED_BINDING;
94
+ if (value.length > MAX_BINDING_LEN) {
95
+ return value.slice(0, MAX_BINDING_LEN) + `…[truncated ${value.length} chars]`;
62
96
  }
63
- return bindings;
97
+ return value;
64
98
  }
65
99
  // ---------------------------------------------------------------------------
66
100
  // Pure data-prep functions
@@ -78,13 +112,14 @@ export function prepareRequestRows(requests) {
78
112
  .map((q) => ({
79
113
  sql_text: q.sql,
80
114
  sql_normalized: normalizeSql(q.sql),
81
- bindings: q.bindings ? JSON.stringify(sanitizeBindings(q.bindings)) : null,
115
+ bindings: q.bindings ? JSON.stringify(sanitizeBindings(q.bindings, q.sql)) : null,
82
116
  duration: round(q.duration),
83
117
  method: q.method,
84
118
  model: q.model,
85
119
  connection: q.connection,
86
120
  in_transaction: q.inTransaction ? 1 : 0,
87
121
  })),
122
+ eventRows: buildEventRows(input.events ?? []),
88
123
  traceRow: input.trace
89
124
  ? {
90
125
  method: input.trace.method,
@@ -135,9 +170,14 @@ export function buildEmailRow(record) {
135
170
  /**
136
171
  * Transform EventRecords into SQLite-ready row objects.
137
172
  */
173
+ /**
174
+ * Build event rows. `request_id` is attached at insert time, once the owning
175
+ * request row has an id — leaving it null here (as this did previously) meant
176
+ * retention never reclaimed them, since events are only pruned via the
177
+ * `server_stats_requests` foreign-key cascade.
178
+ */
138
179
  export function buildEventRows(events) {
139
180
  return events.map((e) => ({
140
- request_id: null,
141
181
  event_name: e.event,
142
182
  data: e.data,
143
183
  }));
@@ -176,7 +216,7 @@ function buildRequestRow(input) {
176
216
  }
177
217
  /** Insert a single prepared request with its queries and trace. */
178
218
  async function insertOneRequest(trx, prepared) {
179
- const { input, filteredQueries, traceRow } = prepared;
219
+ const { input, filteredQueries, traceRow, eventRows } = prepared;
180
220
  const row = buildRequestRow(input);
181
221
  const [requestId] = await trx('server_stats_requests').insert(row);
182
222
  const hasId = requestId !== null && requestId !== undefined;
@@ -184,6 +224,10 @@ async function insertOneRequest(trx, prepared) {
184
224
  const rows = filteredQueries.map((q) => ({ ...q, request_id: requestId }));
185
225
  await batchInsert(trx, 'server_stats_queries', rows);
186
226
  }
227
+ if (hasId && eventRows.length > 0) {
228
+ const rows = eventRows.map((e) => ({ ...e, request_id: requestId }));
229
+ await batchInsert(trx, 'server_stats_events', rows);
230
+ }
187
231
  if (hasId && traceRow) {
188
232
  await trx('server_stats_traces').insert({ ...traceRow, request_id: requestId });
189
233
  }
@@ -202,24 +246,6 @@ export async function flushRequests(trx, preparedRequests) {
202
246
  }
203
247
  }
204
248
  }
205
- /**
206
- * Flush pending events into the database.
207
- */
208
- export async function flushEvents(trx, events) {
209
- for (const { events: evts } of events) {
210
- try {
211
- const rows = buildEventRows(evts);
212
- await batchInsert(trx, 'server_stats_events', rows);
213
- }
214
- catch (err) {
215
- if (!hasWarned('recordEvents')) {
216
- markWarned('recordEvents');
217
- const { log } = await import('../utils/logger.js');
218
- log.warn(`dashboard: recordEvents failed — ${err?.message}`);
219
- }
220
- }
221
- }
222
- }
223
249
  /**
224
250
  * Flush pending emails into the database.
225
251
  */
@@ -3,7 +3,7 @@ import { EventCollector } from './event_collector.js';
3
3
  import { QueryCollector } from './query_collector.js';
4
4
  import { RouteInspector } from './route_inspector.js';
5
5
  import { TraceCollector } from './trace_collector.js';
6
- import type { DevToolbarConfig } from './types.js';
6
+ import type { DevToolbarConfig, ResolvedCapture } from './types.js';
7
7
  /**
8
8
  * Singleton store holding all debug data collectors.
9
9
  * Bound to the AdonisJS container as `debug.store`.
@@ -14,6 +14,8 @@ export declare class DebugStore {
14
14
  readonly emails: EmailCollector;
15
15
  readonly routes: RouteInspector;
16
16
  readonly traces: TraceCollector | null;
17
+ /** Which collectors are allowed to subscribe in {@link start}. */
18
+ readonly capture: ResolvedCapture;
17
19
  constructor(config: DevToolbarConfig);
18
20
  /**
19
21
  * Register a callback that fires whenever any collector records a new item.
@@ -39,6 +41,18 @@ export declare class DebugStore {
39
41
  max: number;
40
42
  };
41
43
  };
44
+ /**
45
+ * Subscribe the enabled collectors.
46
+ *
47
+ * A disabled collector is left constructed but never subscribed, so its
48
+ * dashboard pane renders empty instead of erroring, and it costs nothing at
49
+ * runtime. This also keeps two process-wide side effects off in production:
50
+ * the event collector patches the emitter's `emit()`, and the trace collector
51
+ * patches `console.warn`.
52
+ *
53
+ * Route inspection is not gated — it reads the router table once at boot and
54
+ * captures no request data.
55
+ */
42
56
  start(emitter: unknown, router: unknown): Promise<void>;
43
57
  stop(): void;
44
58
  /** Serialize all collector data to a JSON file (atomic write). */
@@ -48,12 +48,24 @@ export class DebugStore {
48
48
  emails;
49
49
  routes;
50
50
  traces;
51
+ /** Which collectors are allowed to subscribe in {@link start}. */
52
+ capture;
51
53
  constructor(config) {
52
54
  this.queries = new QueryCollector(config.maxQueries, config.slowQueryThresholdMs);
53
55
  this.events = new EventCollector(config.maxEvents);
54
56
  this.emails = new EmailCollector(config.maxEmails);
55
57
  this.routes = new RouteInspector();
56
58
  this.traces = config.tracing ? new TraceCollector(config.maxTraces) : null;
59
+ // Default to capturing everything when unset. `DebugStore` is a public
60
+ // export (`adonisjs-server-stats/debug`), so a config built before `capture`
61
+ // existed must keep behaving exactly as it did.
62
+ this.capture = config.capture ?? {
63
+ queries: true,
64
+ events: true,
65
+ emails: true,
66
+ traces: true,
67
+ logs: true,
68
+ };
57
69
  }
58
70
  /**
59
71
  * Register a callback that fires whenever any collector records a new item.
@@ -74,17 +86,33 @@ export class DebugStore {
74
86
  traces: this.traces?.getBufferInfo() ?? { current: 0, max: 0 },
75
87
  };
76
88
  }
89
+ /**
90
+ * Subscribe the enabled collectors.
91
+ *
92
+ * A disabled collector is left constructed but never subscribed, so its
93
+ * dashboard pane renders empty instead of erroring, and it costs nothing at
94
+ * runtime. This also keeps two process-wide side effects off in production:
95
+ * the event collector patches the emitter's `emit()`, and the trace collector
96
+ * patches `console.warn`.
97
+ *
98
+ * Route inspection is not gated — it reads the router table once at boot and
99
+ * captures no request data.
100
+ */
77
101
  async start(emitter, router) {
78
102
  // Runtime-check the emitter before passing to collectors.
79
103
  // The container returns `unknown`; collectors guard internally too.
80
104
  const e = emitter;
81
- await this.queries.start(e);
82
- this.events.start(e);
83
- await this.emails.start(e);
105
+ if (this.capture.queries)
106
+ await this.queries.start(e);
107
+ if (this.capture.events)
108
+ this.events.start(e);
109
+ if (this.capture.emails)
110
+ await this.emails.start(e);
84
111
  if (router && typeof router.toJSON === 'function') {
85
112
  this.routes.inspect(router);
86
113
  }
87
- this.traces?.start(e);
114
+ if (this.capture.traces)
115
+ this.traces?.start(e);
88
116
  }
89
117
  stop() {
90
118
  this.queries.stop();
@@ -12,6 +12,14 @@ export declare class EventCollector {
12
12
  private circulars;
13
13
  private summarizeData;
14
14
  getEvents(): EventRecord[];
15
+ /**
16
+ * Events recorded after `lastId`, oldest first.
17
+ *
18
+ * Mirrors `QueryCollector.getQueriesSince` and drives the dashboard's
19
+ * request pipe, which persists whatever accumulated since the previous
20
+ * request completed.
21
+ */
22
+ getEventsSince(lastId: number): EventRecord[];
15
23
  getLatest(n?: number): EventRecord[];
16
24
  getTotalCount(): number;
17
25
  getBufferInfo(): {
@@ -135,6 +135,18 @@ export class EventCollector {
135
135
  getEvents() {
136
136
  return this.buffer.toArray().reverse();
137
137
  }
138
+ /**
139
+ * Events recorded after `lastId`, oldest first.
140
+ *
141
+ * Mirrors `QueryCollector.getQueriesSince` and drives the dashboard's
142
+ * request pipe, which persists whatever accumulated since the previous
143
+ * request completed.
144
+ */
145
+ getEventsSince(lastId) {
146
+ if (lastId <= 0)
147
+ return this.buffer.toArray();
148
+ return this.buffer.collectFromEnd((e) => e.id > lastId);
149
+ }
138
150
  getLatest(n = 100) {
139
151
  return this.buffer.latest(n);
140
152
  }
@@ -288,6 +288,20 @@ export interface DevToolbarConfig {
288
288
  dbPath: string;
289
289
  /** Base path for the debug toolbar API endpoints. */
290
290
  debugEndpoint: string;
291
+ /**
292
+ * Which capture subsystems are subscribed. Fully resolved — every field is
293
+ * present. All true outside production; all false in production unless the
294
+ * user opted in via `production.capture`.
295
+ */
296
+ capture: ResolvedCapture;
297
+ }
298
+ /** Fully-resolved capture flags. See {@link CaptureConfig} for the user-facing shape. */
299
+ export interface ResolvedCapture {
300
+ queries: boolean;
301
+ events: boolean;
302
+ emails: boolean;
303
+ traces: boolean;
304
+ logs: boolean;
291
305
  }
292
306
  /**
293
307
  * Color names available for the `badge` column format.
@@ -126,6 +126,27 @@ function resolveDevToolbar(config) {
126
126
  function first(primary, fallback, defaultVal) {
127
127
  return primary ?? fallback ?? defaultVal;
128
128
  }
129
+ /**
130
+ * Warn about a production block that cannot do what it looks like it does.
131
+ *
132
+ * Both mistakes here are silent at runtime — the dashboard simply isn't there,
133
+ * or is there with nothing in it — so they're worth naming at config time.
134
+ */
135
+ function warnAboutProduction(config) {
136
+ const production = config.production;
137
+ if (!production?.enabled)
138
+ return;
139
+ if (!config.authorize && !config.shouldShow) {
140
+ log.warn('server-stats: `production.enabled` is set but no `authorize` guard is configured — ' +
141
+ 'the routes will NOT be registered in production. `unsafeAllowNoAuth` is deliberately ' +
142
+ 'ignored there: an unauthenticated dashboard in production is never correct.');
143
+ }
144
+ const dashboardOn = config.dashboard !== undefined && config.dashboard !== false;
145
+ if (!dashboardOn && !config.toolbar) {
146
+ log.warn('server-stats: `production.enabled` is set but neither `dashboard` nor `toolbar` is ' +
147
+ 'enabled, so there is nothing to expose. Set `dashboard: true` to serve the dashboard.');
148
+ }
149
+ }
129
150
  /**
130
151
  * Warn about `domain` values that AdonisJS will never match.
131
152
  *
@@ -154,6 +175,7 @@ export function defineConfig(config) {
154
175
  logDeprecationWarnings(config);
155
176
  if (config.domain)
156
177
  warnAboutDomain(config.domain);
178
+ warnAboutProduction(config);
157
179
  return {
158
180
  intervalMs: first(config.pollInterval, config.intervalMs, 3000),
159
181
  transport: resolveTransport(config),
@@ -167,5 +189,6 @@ export function defineConfig(config) {
167
189
  unsafeAllowNoAuth: config.unsafeAllowNoAuth,
168
190
  verbose,
169
191
  domain: config.domain,
192
+ production: config.production,
170
193
  };
171
194
  }
@@ -1,10 +1,11 @@
1
1
  import type { TraceCollector } from '../debug/trace_collector.js';
2
2
  import type { TraceRecord } from '../debug/types.js';
3
+ import type { AccessGuard } from '../types.js';
3
4
  import type { HttpContext } from '@adonisjs/core/http';
4
5
  import type { NextFn } from '@adonisjs/core/types/http';
5
6
  /** Returns true if the current async context is inside an excluded request. */
6
7
  export declare function isExcludedRequest(): boolean;
7
- export declare function setShouldShow(fn: ((ctx: HttpContext) => boolean) | null): void;
8
+ export declare function setShouldShow(fn: AccessGuard | null): void;
8
9
  export declare function setTraceCollector(collector: TraceCollector | null): void;
9
10
  export declare function setDashboardPath(path: string | null): void;
10
11
  export declare function setExcludedPrefixes(prefixes: string[]): void;
@@ -9,6 +9,8 @@ export function isExcludedRequest() {
9
9
  }
10
10
  let warnedShouldShow = false;
11
11
  let shouldShowFn = null;
12
+ /** One-time latch for the async-guard-with-Edge-toolbar warning. */
13
+ let warnedAsyncShouldShow = false;
12
14
  export function setShouldShow(fn) {
13
15
  shouldShowFn = fn;
14
16
  }
@@ -36,7 +38,20 @@ function shareShouldShowWithEdge(ctx) {
36
38
  ctxView.share({
37
39
  __ssShowFn: () => {
38
40
  try {
39
- return shouldShowFn(ctx);
41
+ const visible = shouldShowFn(ctx);
42
+ // Edge evaluates this inside a compiled template statement, so it has to
43
+ // be synchronous. An async guard hands back a promise, which is truthy —
44
+ // showing the bar to everyone. Hide it instead and say why once.
45
+ if (typeof visible?.then === 'function') {
46
+ if (!warnedAsyncShouldShow) {
47
+ warnedAsyncShouldShow = true;
48
+ log.warn('the `authorize` guard is async, which the @serverStats() toolbar cannot await — ' +
49
+ 'the stats bar stays hidden. Routes are still guarded correctly. Use a ' +
50
+ 'synchronous guard if you want the bar to render.');
51
+ }
52
+ return false;
53
+ }
54
+ return visible;
40
55
  }
41
56
  catch (err) {
42
57
  if (!warnedShouldShow) {
@@ -26,5 +26,14 @@ export declare function warnAboutAuthMiddleware(config: ResolvedServerStatsConfi
26
26
  * say it out loud at boot.
27
27
  */
28
28
  export declare function warnAboutDomainWithToolbar(config: ResolvedServerStatsConfig): void;
29
+ /**
30
+ * Announce that the dashboard is live in production.
31
+ *
32
+ * Deliberately `log.warn` rather than the `log.block` its neighbours use:
33
+ * `log.block` is suppressed unless `verbose` is on, and this is the one message
34
+ * that must reach every operator who enables production mode. It only fires
35
+ * after routes were really registered, so it never over-promises.
36
+ */
37
+ export declare function announceProductionMode(config: ResolvedServerStatsConfig, paths: string[]): void;
29
38
  export declare function warnAboutSessionMiddleware(makePath: (dir: string, file: string) => string): void;
30
39
  export declare function logDashboardError(category: 'missing-dep' | 'timeout' | 'unknown', err: unknown): void;
@@ -75,6 +75,37 @@ export function warnAboutDomainWithToolbar(config) {
75
75
  dim(`served from ${config.domain}. On any other host the bar will stay empty.`),
76
76
  ]);
77
77
  }
78
+ /** Human-readable list of the capture subsystems that are switched on. */
79
+ function describeCapture(config) {
80
+ const requested = config.production?.capture;
81
+ if (!requested)
82
+ return 'nothing (request metadata only)';
83
+ const on = ['queries', 'events', 'emails', 'traces', 'logs'].filter((key) => requested[key] === true);
84
+ return on.length > 0 ? on.join(', ') : 'nothing (request metadata only)';
85
+ }
86
+ /**
87
+ * Announce that the dashboard is live in production.
88
+ *
89
+ * Deliberately `log.warn` rather than the `log.block` its neighbours use:
90
+ * `log.block` is suppressed unless `verbose` is on, and this is the one message
91
+ * that must reach every operator who enables production mode. It only fires
92
+ * after routes were really registered, so it never over-promises.
93
+ */
94
+ export function announceProductionMode(config, paths) {
95
+ if (!config.production?.enabled)
96
+ return;
97
+ const retention = config.production.retentionDays ?? 3;
98
+ const dbPath = config.devToolbar?.dbPath ?? '.adonisjs/server-stats/dashboard.sqlite3';
99
+ const lines = [
100
+ ` reachable at: ${paths.join(', ')}`,
101
+ ` guard: ${config.shouldShow ? 'authorize() configured' : 'NONE'}`,
102
+ ` capturing: ${describeCapture(config)}`,
103
+ ];
104
+ if (config.devToolbar?.dashboard) {
105
+ lines.push(` data at: ${dbPath} (retention: ${retention} days)`);
106
+ }
107
+ log.warn('DASHBOARD IS LIVE IN PRODUCTION\n' + lines.join('\n'));
108
+ }
78
109
  export function warnAboutSessionMiddleware(makePath) {
79
110
  const found = detectGlobalSessionMiddleware(makePath);
80
111
  if (found.length === 0)