adonisjs-server-stats 1.15.0 → 1.16.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 CHANGED
@@ -182,6 +182,7 @@ All fields are optional. `defineConfig({})` works with zero configuration.
182
182
  | `authorize` | `(ctx) => boolean` | -- | Per-request visibility guard |
183
183
  | `unsafeAllowNoAuth` | `boolean` | `false` | Register the dashboard/debug/stats routes even with **no** `authorize` guard. Off by default (routes fail closed). Local dev only — exposes secrets, email bodies, and SQL. |
184
184
  | `domain` | `string` | -- | Restrict every route to one host, e.g. `'admin.example.com'`. See [Custom domain](#custom-domain) |
185
+ | `production` | `ProductionConfig` | -- | Opt in to running in production. Off by default -- see [Production](#production) |
185
186
  | `onStats` | `(stats) => void` | -- | Callback after each collection tick |
186
187
  | `toolbar` | `boolean \| ToolbarConfig` | -- | `true` to enable with defaults, or pass a `ToolbarConfig` object |
187
188
  | `dashboard` | `boolean \| DashboardConfig` | -- | `true` to enable at `/__stats`, or pass a `DashboardConfig` |
@@ -205,6 +206,16 @@ All fields are optional. `defineConfig({})` works with zero configuration.
205
206
  | `path` | `string` | `'/__stats'` | URL path for the dashboard page |
206
207
  | `retentionDays` | `number` | `7` | Days to keep historical data in SQLite |
207
208
 
209
+ ### `ProductionConfig`
210
+
211
+ | Option | Type | Default | Description |
212
+ | --------------- | --------------- | ------- | ---------------------------------------------------------------------- |
213
+ | `enabled` | `boolean` | `false` | Register routes and build the dashboard when `NODE_ENV=production` |
214
+ | `capture` | `CaptureConfig` | all off | Which capture subsystems to switch on -- opt in one at a time |
215
+ | `retentionDays` | `number` | `3` | SQLite history to keep in production (the usual default is 7) |
216
+
217
+ `CaptureConfig` fields, each `boolean` and each defaulting to `false` in production: `queries`, `events`, `emails`, `traces`, `logs`.
218
+
208
219
  ### `AdvancedConfig`
209
220
 
210
221
  | Option | Type | Default | Description |
@@ -384,6 +395,81 @@ export default defineConfig({
384
395
 
385
396
  ---
386
397
 
398
+ ## Production
399
+
400
+ By default this package does **nothing** when `NODE_ENV=production` -- no routes are registered and neither the debug nor the dashboard store is built. The metrics engine still runs, so `onStats`, Prometheus, and Transmit broadcasting keep working; there is simply no HTTP surface.
401
+
402
+ Set `production.enabled` to lift that:
403
+
404
+ ```ts
405
+ export default defineConfig({
406
+ authorize: async (ctx) => (await ctx.auth.check()) && ctx.auth.user?.isAdmin === true,
407
+ dashboard: true,
408
+
409
+ production: {
410
+ enabled: true,
411
+ capture: { queries: true }, // everything else stays off
412
+ retentionDays: 3,
413
+ },
414
+ })
415
+ ```
416
+
417
+ ### An `authorize` guard is mandatory
418
+
419
+ `unsafeAllowNoAuth` is **ignored** in production. Without a guard the routes are not registered and a warning explains why -- an unauthenticated dashboard on a production host is never correct.
420
+
421
+ When production mode is active you get an unmissable startup banner, so nobody enables this and forgets:
422
+
423
+ ```
424
+ [ server-stats ] DASHBOARD IS LIVE IN PRODUCTION
425
+ reachable at: /admin/api/server-stats, /admin/api/debug/*, /__stats/*
426
+ guard: authorize() configured
427
+ capturing: queries
428
+ data at: .adonisjs/server-stats/dashboard.sqlite3 (retention: 3 days)
429
+ ```
430
+
431
+ ### Capture is off unless you ask for it
432
+
433
+ Each capture subsystem hooks something global and stores something sensitive, so in production every one starts **off** and is enabled by name:
434
+
435
+ | `capture` flag | What it records | Why it's off by default |
436
+ | -------------- | ----------------------------------- | ------------------------------------------------ |
437
+ | `queries` | SQL text, bindings, timings | Bindings can hold values you would not log |
438
+ | `events` | Application events (memory only) | Patches the emitter's `emit()` process-wide |
439
+ | `emails` | Sent mail incl. subject and body | Highest-sensitivity payload in the package |
440
+ | `traces` | Per-request spans | Wraps every request in `AsyncLocalStorage` |
441
+ | `logs` | Log lines into SQLite | Highest write volume, and logs quote payloads |
442
+
443
+ **With no capture flags at all you still get the useful core:** the request list, the overview, and the charts. Those come from request rows and 1-minute metric buckets, at a small fraction of full dev-mode write volume. Turn capture on when you are actually debugging something.
444
+
445
+ A disabled subsystem is never subscribed, so it costs nothing -- its dashboard pane simply stays empty.
446
+
447
+ > [!NOTE]
448
+ > `toolbar: { tracing: false }` still overrides `capture.traces`. It is the older kill switch and keeps winning.
449
+
450
+ ### Disk
451
+
452
+ Retention prunes rows older than `retentionDays` on boot and hourly. It does **not** run `VACUUM`, so the `.sqlite3` file reuses freed pages rather than shrinking -- expect the file to plateau, not shrink. Watch real numbers in the dashboard's storage panel (`GET {dashboardPath}/api/storage`), which reports file size, WAL size, and per-table row counts.
453
+
454
+ There is no sampling and no hard size cap. On a high-traffic app, enable capture deliberately and keep `retentionDays` short.
455
+
456
+ ### Async guards and the toolbar
457
+
458
+ An `authorize` callback may be sync or async -- the route guard awaits it, so `async (ctx) => { await ctx.auth.authenticate(); ... }` is safe.
459
+
460
+ > [!WARNING]
461
+ > The `@serverStats()` Edge toolbar evaluates the guard **synchronously** while rendering, so it cannot await. With an async guard the stats bar hides itself rather than risk showing when it shouldn't, and logs a warning once. The HTTP routes are guarded correctly either way -- only the cosmetic bar is affected. Use a synchronous guard if you want the bar in production.
462
+
463
+ ### What this does not give you
464
+
465
+ Worth knowing before you expose it, even behind an admin guard:
466
+
467
+ - **One guard, no tiers.** Anyone who passes `authorize` can also `DELETE /api/cache/:key` and `POST /api/jobs/:id/retry`. Scope cache access with `SERVER_STATS_CACHE_KEY_PREFIX` -- unset means unrestricted.
468
+ - **No audit log and no rate limiting** on those mutating endpoints.
469
+ - **SQL bindings are truncated at 256 characters but not redacted by key name.** A short secret passed as a bound parameter is stored as-is. Config and env values *are* redacted; query bindings are not.
470
+
471
+ ---
472
+
387
473
  ## Auto-Registered Routes
388
474
 
389
475
  All API routes are registered automatically by the package during `boot()` -- no manual controllers or route definitions needed. Each route group is gated by the `authorize` callback if configured.
@@ -466,7 +552,7 @@ Pass a bare host. A protocol, path, or port (`https://admin.example.com`, `admin
466
552
 
467
553
  > **The toolbar follows the domain.** The `@serverStats()` Edge tag and the React/Vue components request **relative** URLs, so they only work on pages served from the configured domain. Render the toolbar on `example.com` while `domain` points at `admin.example.com` and the bar will sit empty -- its polls 404 against the wrong host. Either serve the toolbar from the same domain, or leave `domain` unset and rely on `authorize` alone.
468
554
 
469
- > **Reminder:** routes are never registered in production regardless of this setting (`app.inProduction` short-circuits registration), so `domain` applies to your dev and staging environments.
555
+ > **Reminder:** routes are not registered in production unless you opt in via [`production.enabled`](#production). Without that, `domain` applies to your dev and staging environments only.
470
556
 
471
557
  ### Global middleware note
472
558
 
@@ -653,7 +739,7 @@ Found a bug? Have feedback? [Open an issue](https://github.com/simulieren/adonis
653
739
 
654
740
  ## Dev Toolbar
655
741
 
656
- Adds a debug panel with SQL query inspection, event tracking, email capture with HTML preview, route table, live logs, and per-request tracing. Only active in non-production environments.
742
+ Adds a debug panel with SQL query inspection, event tracking, email capture with HTML preview, route table, live logs, and per-request tracing. Active in non-production environments by default; see [Production](#production) to opt in there.
657
743
 
658
744
  ```ts
659
745
  export default defineConfig({
@@ -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.
@@ -435,6 +435,94 @@ export interface DashboardConfig {
435
435
  */
436
436
  retentionDays?: number;
437
437
  }
438
+ /**
439
+ * Access-control callback signature.
440
+ *
441
+ * May be sync or async — the route guard awaits it. An async guard is the usual
442
+ * shape once the check has to consult `ctx.auth` or the database.
443
+ *
444
+ * One caveat: the `@serverStats()` Edge tag evaluates the guard synchronously
445
+ * while rendering the template, so with an **async** guard the toolbar hides
446
+ * itself rather than risk showing when it shouldn't. The HTTP routes await it
447
+ * properly either way — only the cosmetic bar is affected.
448
+ */
449
+ export type AccessGuard = (ctx: import('@adonisjs/core/http').HttpContext) => boolean | Promise<boolean>;
450
+ /**
451
+ * Which capture subsystems are active.
452
+ *
453
+ * Each one hooks a global: queries and events subscribe to the Lucid/app
454
+ * emitter, emails subscribe to the mail events, traces wrap every request in
455
+ * `AsyncLocalStorage`. Turning one off means its collector is never subscribed,
456
+ * so it costs nothing — its dashboard pane simply stays empty.
457
+ *
458
+ * Outside production every field defaults to `true`. In production every field
459
+ * defaults to **`false`** and must be opted into individually.
460
+ */
461
+ export interface CaptureConfig {
462
+ /** SQL text, bindings, and timings for every query. */
463
+ queries?: boolean;
464
+ /** Application events (in-memory only — never persisted to SQLite). */
465
+ events?: boolean;
466
+ /** Sent mail, including subject and body. */
467
+ emails?: boolean;
468
+ /** Per-request spans and the request timeline. */
469
+ traces?: boolean;
470
+ /** Log lines written into the dashboard's SQLite store. */
471
+ logs?: boolean;
472
+ }
473
+ /**
474
+ * Opt in to running the dashboard in production.
475
+ *
476
+ * By default this package registers **no routes** when `NODE_ENV=production`
477
+ * and never builds the debug or dashboard stores. Setting `enabled: true` lifts
478
+ * that, but only with an {@link ServerStatsConfig.authorize} guard in place —
479
+ * `unsafeAllowNoAuth` is ignored in production.
480
+ *
481
+ * Data capture stays **off** unless you ask for it. Without any `capture`
482
+ * flags you still get the request list, the overview, and the charts (those
483
+ * come from request rows and 1-minute metric buckets), at a small fraction of
484
+ * the write volume of a full dev-mode capture.
485
+ *
486
+ * @example
487
+ * ```ts
488
+ * export default defineConfig({
489
+ * authorize: async (ctx) => (await ctx.auth.check()) && ctx.auth.user?.isAdmin === true,
490
+ * dashboard: true,
491
+ * production: {
492
+ * enabled: true,
493
+ * capture: { queries: true },
494
+ * retentionDays: 3,
495
+ * },
496
+ * })
497
+ * ```
498
+ */
499
+ export interface ProductionConfig {
500
+ /**
501
+ * Register routes and build the dashboard when `NODE_ENV=production`.
502
+ *
503
+ * Requires an `authorize` guard — without one the routes are still not
504
+ * registered, and a warning explains why.
505
+ *
506
+ * @default false
507
+ */
508
+ enabled?: boolean;
509
+ /**
510
+ * Which capture subsystems to switch on. Every field defaults to `false` in
511
+ * production, so capture is opt-in one subsystem at a time.
512
+ *
513
+ * @see {@link CaptureConfig}
514
+ */
515
+ capture?: CaptureConfig;
516
+ /**
517
+ * How many days of history to keep in SQLite, overriding the usual default
518
+ * of 7. Retention deletes rows hourly but never runs `VACUUM`, so the
519
+ * database file reuses pages rather than shrinking — watch actual size via
520
+ * the dashboard's storage panel.
521
+ *
522
+ * @default 3
523
+ */
524
+ retentionDays?: number;
525
+ }
438
526
  /**
439
527
  * Advanced options that most users never need to touch.
440
528
  *
@@ -699,7 +787,7 @@ export interface ServerStatsConfig {
699
787
  *
700
788
  * @deprecated Use {@link authorize} instead. Will be removed in the next major version.
701
789
  */
702
- shouldShow?: (ctx: import('@adonisjs/core/http').HttpContext) => boolean;
790
+ shouldShow?: AccessGuard;
703
791
  /**
704
792
  * How often (in **milliseconds**) to run all collectors and
705
793
  * broadcast updated stats.
@@ -750,7 +838,7 @@ export interface ServerStatsConfig {
750
838
  * authorize: () => process.env.NODE_ENV === 'development'
751
839
  * ```
752
840
  */
753
- authorize?: (ctx: import('@adonisjs/core/http').HttpContext) => boolean;
841
+ authorize?: AccessGuard;
754
842
  /**
755
843
  * Register the sensitive dashboard/debug/stats routes WITHOUT any access
756
844
  * guard when no {@link authorize} callback is provided.
@@ -833,6 +921,21 @@ export interface ServerStatsConfig {
833
921
  * ```
834
922
  */
835
923
  domain?: string;
924
+ /**
925
+ * Opt in to running in production, where this package otherwise registers
926
+ * nothing at all.
927
+ *
928
+ * Requires an {@link authorize} guard, and leaves data capture off unless you
929
+ * enable it per subsystem.
930
+ *
931
+ * @see {@link ProductionConfig}
932
+ *
933
+ * @example
934
+ * ```ts
935
+ * production: { enabled: true, capture: { queries: true } }
936
+ * ```
937
+ */
938
+ production?: ProductionConfig;
836
939
  /**
837
940
  * Advanced options for fine-tuning internal behavior.
838
941
  *
@@ -878,7 +981,7 @@ export interface ResolvedServerStatsConfig {
878
981
  /** Optional dev toolbar configuration. */
879
982
  devToolbar?: DevToolbarOptions;
880
983
  /** Optional access-control callback. */
881
- shouldShow?: (ctx: import('@adonisjs/core/http').HttpContext) => boolean;
984
+ shouldShow?: AccessGuard;
882
985
  /** Collection interval in milliseconds (new name for {@link intervalMs}). */
883
986
  pollInterval?: number;
884
987
  /** Whether real-time (SSE) broadcasting is enabled (new name for {@link transport}). */
@@ -886,7 +989,7 @@ export interface ResolvedServerStatsConfig {
886
989
  /** HTTP endpoint path or `false` to disable (new name for {@link endpoint}). */
887
990
  statsEndpoint?: string | false;
888
991
  /** Access-control callback (new name for {@link shouldShow}). */
889
- authorize?: (ctx: import('@adonisjs/core/http').HttpContext) => boolean;
992
+ authorize?: AccessGuard;
890
993
  /**
891
994
  * Escape hatch to register sensitive routes without an access guard.
892
995
  * Exposes the dashboard without auth — local development only.
@@ -902,4 +1005,6 @@ export interface ResolvedServerStatsConfig {
902
1005
  verbose: boolean;
903
1006
  /** Optional domain restriction for all routes. */
904
1007
  domain?: string;
1008
+ /** Opt-in production behavior. Absent means this package is inert in production. */
1009
+ production?: ProductionConfig;
905
1010
  }
@@ -26,6 +26,13 @@ export default class DashboardController {
26
26
  createSavedFilter(ctx: HttpContext): Promise<void>;
27
27
  deleteSavedFilter(ctx: HttpContext): Promise<void>;
28
28
  private withDb;
29
+ /**
30
+ * Re-check the access guard for the dashboard page itself.
31
+ *
32
+ * Awaited: an async guard would otherwise return a truthy promise and let
33
+ * everyone through. The route middleware already applies the same guard, so
34
+ * this is defense in depth for the HTML page.
35
+ */
29
36
  private checkAccess;
30
37
  private getDashboardPath;
31
38
  }
@@ -23,7 +23,7 @@ export default class DashboardController {
23
23
  this.pageAssets = new DashboardPageAssets();
24
24
  }
25
25
  async page(ctx) {
26
- if (!this.checkAccess(ctx))
26
+ if (!(await this.checkAccess(ctx)))
27
27
  return ctx.response.forbidden({ error: 'Access denied' });
28
28
  const config = this.app.config.get('server_stats');
29
29
  const tc = config?.devToolbar ?? {};
@@ -163,12 +163,19 @@ export default class DashboardController {
163
163
  return response.json(emptyValue);
164
164
  }
165
165
  }
166
- checkAccess(ctx) {
166
+ /**
167
+ * Re-check the access guard for the dashboard page itself.
168
+ *
169
+ * Awaited: an async guard would otherwise return a truthy promise and let
170
+ * everyone through. The route middleware already applies the same guard, so
171
+ * this is defense in depth for the HTML page.
172
+ */
173
+ async checkAccess(ctx) {
167
174
  const config = this.app.config.get('server_stats');
168
175
  if (!config?.shouldShow)
169
176
  return true;
170
177
  try {
171
- return config.shouldShow(ctx);
178
+ return await config.shouldShow(ctx);
172
179
  }
173
180
  catch {
174
181
  return false;
@@ -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();
@@ -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)
@@ -42,7 +42,11 @@ export async function initDashboardStore(opts) {
42
42
  setDashboardPath(tc.dashboardPath);
43
43
  const DCC = (await import('../dashboard/dashboard_controller.js')).default;
44
44
  const dashboardController = new DCC(dashboardStore, app);
45
- const dashboardLogStream = pipeDashLogs(pinoHookActive, dashboardStore, app.makePath.bind(app));
45
+ // Log capture is a separate opt-in: it is high-volume and log lines routinely
46
+ // carry request payloads. With it off the dashboard's other panes still work.
47
+ const dashboardLogStream = tc.capture?.logs !== false
48
+ ? pipeDashLogs(pinoHookActive, dashboardStore, app.makePath.bind(app))
49
+ : null;
46
50
  pipeDashRequests(debugStore, dashboardStore);
47
51
  const dashboardBroadcastTimer = await setupDashBroadcast({
48
52
  container,
@@ -2,6 +2,7 @@
2
2
  * Pure helper functions for dashboard setup and configuration.
3
3
  */
4
4
  import type { DevToolbarConfig } from '../debug/types.js';
5
+ import type { ProductionConfig } from '../types.js';
5
6
  /**
6
7
  * Classify a dashboard start() error into a category.
7
8
  */
@@ -17,9 +18,17 @@ export declare function buildExcludedPrefixes(toolbarConfig: {
17
18
  debugEndpoint?: string;
18
19
  excludeFromTracing?: string[];
19
20
  }, statsEndpoint: string | false): string[];
21
+ /** Environment context needed to resolve production-sensitive defaults. */
22
+ export interface ProductionContext {
23
+ inProduction: boolean;
24
+ production?: ProductionConfig;
25
+ }
20
26
  /**
21
27
  * Resolve a partial DevToolbarConfig by filling in all defaults.
28
+ *
29
+ * Pass `ctx` to apply production-sensitive defaults (capture off, shorter
30
+ * retention). Omitting it resolves as a non-production environment.
22
31
  */
23
32
  export declare function resolveToolbarConfig(partial: Partial<DevToolbarConfig> & {
24
33
  enabled: boolean;
25
- }): DevToolbarConfig;
34
+ }, ctx?: ProductionContext): DevToolbarConfig;
@@ -73,13 +73,58 @@ function stripUndefined(obj) {
73
73
  }
74
74
  return result;
75
75
  }
76
+ /** Retention default when running in production — shorter than the usual 7 days. */
77
+ const PRODUCTION_RETENTION_DAYS = 3;
76
78
  /**
77
- * Resolve a partial DevToolbarConfig by filling in all defaults.
79
+ * Resolve which capture subsystems subscribe.
80
+ *
81
+ * Outside production everything captures, as it always has. In production every
82
+ * subsystem is off until asked for by name, because each one is the expensive,
83
+ * secret-adjacent half of this package: query bindings, mail bodies, log lines.
84
+ *
85
+ * `tracing: false` still wins over `capture.traces` — it is the pre-existing
86
+ * documented kill switch and must not be quietly re-enabled.
78
87
  */
79
- export function resolveToolbarConfig(partial) {
88
+ function resolveCapture(ctx, tracing) {
89
+ const inProduction = ctx?.inProduction === true;
90
+ const requested = (inProduction ? ctx?.production?.capture : undefined) ?? {};
91
+ const isOn = (key) => requested[key] ?? !inProduction;
80
92
  return {
93
+ queries: isOn('queries'),
94
+ events: isOn('events'),
95
+ emails: isOn('emails'),
96
+ traces: tracing && isOn('traces'),
97
+ logs: isOn('logs'),
98
+ };
99
+ }
100
+ /**
101
+ * Resolve retention, preferring an explicit value from any source over the
102
+ * production default. Order: `production.retentionDays`, then whatever
103
+ * `dashboard`/`advanced` set, then 3 days in production, then 7.
104
+ */
105
+ function resolveRetentionDays(ctx, explicit) {
106
+ const fromProduction = ctx?.inProduction ? ctx.production?.retentionDays : undefined;
107
+ if (fromProduction !== undefined)
108
+ return fromProduction;
109
+ if (explicit !== undefined)
110
+ return explicit;
111
+ return ctx?.inProduction ? PRODUCTION_RETENTION_DAYS : TOOLBAR_DEFAULTS.retentionDays;
112
+ }
113
+ /**
114
+ * Resolve a partial DevToolbarConfig by filling in all defaults.
115
+ *
116
+ * Pass `ctx` to apply production-sensitive defaults (capture off, shorter
117
+ * retention). Omitting it resolves as a non-production environment.
118
+ */
119
+ export function resolveToolbarConfig(partial, ctx) {
120
+ const merged = {
81
121
  ...TOOLBAR_DEFAULTS,
82
122
  ...stripUndefined(partial),
83
123
  enabled: partial.enabled,
84
124
  };
125
+ return {
126
+ ...merged,
127
+ retentionDays: resolveRetentionDays(ctx, partial.retentionDays),
128
+ capture: resolveCapture(ctx, merged.tracing),
129
+ };
85
130
  }
@@ -43,6 +43,13 @@ export default class ServerStatsProvider {
43
43
  whenReady(): Promise<void>;
44
44
  boot(): Promise<void>;
45
45
  private initBoot;
46
+ /**
47
+ * Whether this package should do anything in the current environment.
48
+ *
49
+ * Everything is on outside production. In production nothing is registered or
50
+ * built unless `production.enabled` opts in.
51
+ */
52
+ private isEnabledHere;
46
53
  private registerRoutes;
47
54
  ready(): Promise<void>;
48
55
  private initStats;
@@ -2,7 +2,7 @@ import { StatsEngine } from '../engine/stats_engine.js';
2
2
  import { setShouldShow, setExcludedPrefixes } from '../middleware/request_tracking_middleware.js';
3
3
  import { registerAllRoutes } from '../routes/register_routes.js';
4
4
  import { log, dim, setVerbose } from '../utils/logger.js';
5
- import { deriveEndpointPaths, computeDashboardPath, collectRegisteredPaths, warnAboutAuthMiddleware, warnAboutSessionMiddleware, warnAboutDomainWithToolbar, } from './boot_helpers.js';
5
+ import { deriveEndpointPaths, computeDashboardPath, collectRegisteredPaths, warnAboutAuthMiddleware, warnAboutSessionMiddleware, warnAboutDomainWithToolbar, announceProductionMode, } from './boot_helpers.js';
6
6
  import { resolveToolbarConfig, buildExcludedPrefixes } from './dashboard_setup.js';
7
7
  import { buildDiagnostics } from './diagnostics.js';
8
8
  import { hookPinoToLogStream, setupLogStreamBroadcast, setupStatsIntervalHelper, checkDashboardDepsHelper, registerEdgePluginHelper, setupNonWebBridgeHelper, setupDevToolbarCore, applyToolbarResult, } from './provider_helpers_extra.js';
@@ -73,16 +73,29 @@ export default class ServerStatsProvider {
73
73
  if (config.shouldShow)
74
74
  setShouldShow(config.shouldShow);
75
75
  await this.registerRoutes(config);
76
- this.edgePluginActive = await registerEdgePluginHelper(this.app, config);
76
+ // Only register the Edge tag when the routes it polls actually exist —
77
+ // otherwise the bar renders in production and 404s on every tick.
78
+ this.edgePluginActive = this.isEnabledHere(config)
79
+ ? await registerEdgePluginHelper(this.app, config)
80
+ : false;
81
+ }
82
+ /**
83
+ * Whether this package should do anything in the current environment.
84
+ *
85
+ * Everything is on outside production. In production nothing is registered or
86
+ * built unless `production.enabled` opts in.
87
+ */
88
+ isEnabledHere(config) {
89
+ return !this.app.inProduction || config.production?.enabled === true;
77
90
  }
78
91
  async registerRoutes(config) {
79
92
  const router = await this.resolve('router');
80
- if (!router || this.app.inProduction)
93
+ if (!router || !this.isEnabledHere(config))
81
94
  return;
82
95
  this.dashboardDepsAvailable = await checkDashboardDepsHelper(config, this.app);
83
96
  const { statsEndpoint, debugEndpoint } = deriveEndpointPaths(config.endpoint, config.devToolbar);
84
97
  const dashboardPath = computeDashboardPath(config.devToolbar, this.dashboardDepsAvailable);
85
- registerAllRoutes({
98
+ const registered = registerAllRoutes({
86
99
  router: router,
87
100
  getApiController: () => this.apiController,
88
101
  getStatsController: () => this.statsController,
@@ -95,16 +108,19 @@ export default class ServerStatsProvider {
95
108
  dashboardPath,
96
109
  shouldShow: config.shouldShow,
97
110
  unsafeAllowNoAuth: config.unsafeAllowNoAuth,
111
+ inProduction: this.app.inProduction,
98
112
  whenReady: () => this.whenReady(),
99
113
  domain: config.domain,
100
114
  });
101
115
  const paths = collectRegisteredPaths(statsEndpoint, debugEndpoint, dashboardPath, config.domain);
102
- if (paths.length === 0)
116
+ if (!registered || paths.length === 0)
103
117
  return;
104
118
  log.list('routes auto-registered (no manual setup needed):', paths);
105
119
  warnAboutAuthMiddleware(config, this.app.makePath.bind(this.app));
106
120
  warnAboutSessionMiddleware(this.app.makePath.bind(this.app));
107
121
  warnAboutDomainWithToolbar(config);
122
+ if (this.app.inProduction)
123
+ announceProductionMode(config, paths);
108
124
  }
109
125
  async ready() {
110
126
  const config = this.app.config.get('server_stats');
@@ -132,7 +148,7 @@ export default class ServerStatsProvider {
132
148
  this.pinoHookActive = hookPinoToLogStream(await this.resolve('logger'));
133
149
  const SC = (await import('../controller/server_stats_controller.js')).default;
134
150
  this.statsController = new SC(this.engine);
135
- if (config.devToolbar?.enabled && !this.app.inProduction) {
151
+ if (config.devToolbar?.enabled && this.isEnabledHere(config)) {
136
152
  this.checkLucidDebugFlag();
137
153
  await this.setupDevToolbar(config);
138
154
  }
@@ -158,7 +174,7 @@ export default class ServerStatsProvider {
158
174
  return r.collectors;
159
175
  }
160
176
  async setupDevToolbar(config) {
161
- const tc = resolveToolbarConfig({ enabled: true, ...config.devToolbar });
177
+ const tc = resolveToolbarConfig({ enabled: true, ...config.devToolbar }, { inProduction: this.app.inProduction, production: config.production });
162
178
  try {
163
179
  const result = await setupDevToolbarCore({
164
180
  tc,
@@ -20,7 +20,11 @@ export async function setupDevToolbarCore(opts) {
20
20
  await debugStore.start(em, await resolve('router'));
21
21
  const emailBridgeRedis = await setupBridgeInternal(em, debugStore, app);
22
22
  const debugController = await createDebugController(debugStore, config, getDiagnostics, app);
23
- if (debugStore.traces)
23
+ // Also gated on capture: installing the collector is what makes the middleware
24
+ // wrap every request in AsyncLocalStorage and write a trace row per request.
25
+ // Gating only the emitter subscription would leave that cost in place and
26
+ // persist empty traces.
27
+ if (debugStore.capture.traces && debugStore.traces)
24
28
  setTraceCollector(debugStore.traces);
25
29
  const flushTimer = persistPath ? createFlushTimer(debugStore, persistPath) : null;
26
30
  const broadcast = await setupDebugBroadcastInternal(debugStore, resolve);
@@ -1,8 +1,14 @@
1
+ import type { AccessGuard } from '../types.js';
1
2
  import type { HttpContext } from '@adonisjs/core/http';
2
3
  /**
3
4
  * Create a middleware function that gates access using the shouldShow callback.
4
5
  * Returns 403 if the callback returns false.
5
6
  *
7
+ * The guard is awaited, so an async callback (the usual shape when it has to
8
+ * consult `ctx.auth` or the database) is resolved before the decision is made.
9
+ * Returning the promise unawaited would make every async guard pass, since a
10
+ * pending promise is truthy.
11
+ *
6
12
  * Shared by stats, debug, and dashboard route registrars.
7
13
  */
8
- export declare function createAccessMiddleware(shouldShow: (ctx: HttpContext) => boolean): (ctx: HttpContext, next: () => Promise<void>) => Promise<void>;
14
+ export declare function createAccessMiddleware(shouldShow: AccessGuard): (ctx: HttpContext, next: () => Promise<void>) => Promise<void>;
@@ -4,12 +4,17 @@ let warnedShouldShow = false;
4
4
  * Create a middleware function that gates access using the shouldShow callback.
5
5
  * Returns 403 if the callback returns false.
6
6
  *
7
+ * The guard is awaited, so an async callback (the usual shape when it has to
8
+ * consult `ctx.auth` or the database) is resolved before the decision is made.
9
+ * Returning the promise unawaited would make every async guard pass, since a
10
+ * pending promise is truthy.
11
+ *
7
12
  * Shared by stats, debug, and dashboard route registrars.
8
13
  */
9
14
  export function createAccessMiddleware(shouldShow) {
10
15
  return async (ctx, next) => {
11
16
  try {
12
- if (!shouldShow(ctx)) {
17
+ if (!(await shouldShow(ctx))) {
13
18
  return ctx.response.forbidden({ error: 'Access denied' });
14
19
  }
15
20
  }
@@ -3,8 +3,8 @@ import type DebugController from '../controller/debug_controller.js';
3
3
  import type { DebugStore } from '../debug/debug_store.js';
4
4
  import type ServerStatsController from '../controller/server_stats_controller.js';
5
5
  import type DashboardController from '../dashboard/dashboard_controller.js';
6
+ import type { AccessGuard } from '../types.js';
6
7
  import type { AdonisRouter } from './router_types.js';
7
- import type { HttpContext } from '@adonisjs/core/http';
8
8
  import type { ApplicationService } from '@adonisjs/core/types';
9
9
  /**
10
10
  * Options for the unified route registration function.
@@ -20,7 +20,7 @@ export interface RegisterRoutesOptions {
20
20
  statsEndpoint?: string | false;
21
21
  debugEndpoint?: string;
22
22
  dashboardPath?: string;
23
- shouldShow?: (ctx: HttpContext) => boolean;
23
+ shouldShow?: AccessGuard;
24
24
  /**
25
25
  * Escape hatch to register the sensitive dashboard/debug/stats routes WITHOUT
26
26
  * any access guard when no `shouldShow` callback is provided. Off by default —
@@ -29,6 +29,12 @@ export interface RegisterRoutesOptions {
29
29
  * email bodies, and SQL) to anyone who can reach it. Local dev only.
30
30
  */
31
31
  unsafeAllowNoAuth?: boolean;
32
+ /**
33
+ * Whether the app is running in production. When true, `unsafeAllowNoAuth` is
34
+ * ignored — an unauthenticated dashboard in production is never correct, so
35
+ * the only way in is a real `shouldShow` guard.
36
+ */
37
+ inProduction?: boolean;
32
38
  /** Optional promise that resolves when controllers are initialized. */
33
39
  whenReady?: () => Promise<void>;
34
40
  /**
@@ -39,5 +45,9 @@ export interface RegisterRoutesOptions {
39
45
  }
40
46
  /**
41
47
  * Register all server-stats routes in a single call.
48
+ *
49
+ * Returns whether the routes were actually registered — false means the
50
+ * fail-closed guard check rejected the configuration, which callers need to
51
+ * know before announcing that anything is reachable.
42
52
  */
43
- export declare function registerAllRoutes(options: RegisterRoutesOptions): void;
53
+ export declare function registerAllRoutes(options: RegisterRoutesOptions): boolean;
@@ -8,16 +8,29 @@ import { log } from '../utils/logger.js';
8
8
  let _warnedNoAuth = false;
9
9
  /**
10
10
  * Register all server-stats routes in a single call.
11
+ *
12
+ * Returns whether the routes were actually registered — false means the
13
+ * fail-closed guard check rejected the configuration, which callers need to
14
+ * know before announcing that anything is reachable.
11
15
  */
12
16
  export function registerAllRoutes(options) {
13
17
  // Fail closed: without an access guard (`shouldShow`) the sensitive routes must
14
18
  // not be registered unless the caller explicitly opts in via `unsafeAllowNoAuth`.
15
19
  if (!options.shouldShow) {
20
+ // In production the escape hatch does not apply — there is no legitimate
21
+ // reason to serve secrets, email bodies, and SQL to unauthenticated callers
22
+ // on a production host, so the guard is the only way through.
23
+ if (options.inProduction) {
24
+ log.warn('server-stats: production mode is enabled but no `authorize`/`shouldShow` guard is ' +
25
+ 'configured — sensitive routes (dashboard, debug API, stats) will NOT be registered. ' +
26
+ '`unsafeAllowNoAuth` is ignored in production. Provide an authorize callback.');
27
+ return false;
28
+ }
16
29
  if (!options.unsafeAllowNoAuth) {
17
30
  log.warn('server-stats: no `authorize`/`shouldShow` guard configured — sensitive routes ' +
18
31
  '(dashboard, debug API, stats) will NOT be registered. Provide an authorize callback, ' +
19
32
  'or set `unsafeAllowNoAuth: true` to expose them without auth (local dev only).');
20
- return;
33
+ return false;
21
34
  }
22
35
  if (!_warnedNoAuth) {
23
36
  _warnedNoAuth = true;
@@ -63,4 +76,5 @@ export function registerAllRoutes(options) {
63
76
  domain: options.domain,
64
77
  });
65
78
  }
79
+ return true;
66
80
  }
@@ -19,4 +19,12 @@ export default defineConfig({
19
19
 
20
20
  // Log detailed initialization steps (SQLite, migrations, routes, etc.)
21
21
  // verbose: true,
22
+
23
+ // Nothing is registered in production unless you opt in here. Requires an
24
+ // `authorize` guard, and data capture stays off until you name a subsystem.
25
+ // production: {
26
+ // enabled: true,
27
+ // capture: { queries: true },
28
+ // retentionDays: 3,
29
+ // },
22
30
  })
@@ -435,6 +435,94 @@ export interface DashboardConfig {
435
435
  */
436
436
  retentionDays?: number;
437
437
  }
438
+ /**
439
+ * Access-control callback signature.
440
+ *
441
+ * May be sync or async — the route guard awaits it. An async guard is the usual
442
+ * shape once the check has to consult `ctx.auth` or the database.
443
+ *
444
+ * One caveat: the `@serverStats()` Edge tag evaluates the guard synchronously
445
+ * while rendering the template, so with an **async** guard the toolbar hides
446
+ * itself rather than risk showing when it shouldn't. The HTTP routes await it
447
+ * properly either way — only the cosmetic bar is affected.
448
+ */
449
+ export type AccessGuard = (ctx: import('@adonisjs/core/http').HttpContext) => boolean | Promise<boolean>;
450
+ /**
451
+ * Which capture subsystems are active.
452
+ *
453
+ * Each one hooks a global: queries and events subscribe to the Lucid/app
454
+ * emitter, emails subscribe to the mail events, traces wrap every request in
455
+ * `AsyncLocalStorage`. Turning one off means its collector is never subscribed,
456
+ * so it costs nothing — its dashboard pane simply stays empty.
457
+ *
458
+ * Outside production every field defaults to `true`. In production every field
459
+ * defaults to **`false`** and must be opted into individually.
460
+ */
461
+ export interface CaptureConfig {
462
+ /** SQL text, bindings, and timings for every query. */
463
+ queries?: boolean;
464
+ /** Application events (in-memory only — never persisted to SQLite). */
465
+ events?: boolean;
466
+ /** Sent mail, including subject and body. */
467
+ emails?: boolean;
468
+ /** Per-request spans and the request timeline. */
469
+ traces?: boolean;
470
+ /** Log lines written into the dashboard's SQLite store. */
471
+ logs?: boolean;
472
+ }
473
+ /**
474
+ * Opt in to running the dashboard in production.
475
+ *
476
+ * By default this package registers **no routes** when `NODE_ENV=production`
477
+ * and never builds the debug or dashboard stores. Setting `enabled: true` lifts
478
+ * that, but only with an {@link ServerStatsConfig.authorize} guard in place —
479
+ * `unsafeAllowNoAuth` is ignored in production.
480
+ *
481
+ * Data capture stays **off** unless you ask for it. Without any `capture`
482
+ * flags you still get the request list, the overview, and the charts (those
483
+ * come from request rows and 1-minute metric buckets), at a small fraction of
484
+ * the write volume of a full dev-mode capture.
485
+ *
486
+ * @example
487
+ * ```ts
488
+ * export default defineConfig({
489
+ * authorize: async (ctx) => (await ctx.auth.check()) && ctx.auth.user?.isAdmin === true,
490
+ * dashboard: true,
491
+ * production: {
492
+ * enabled: true,
493
+ * capture: { queries: true },
494
+ * retentionDays: 3,
495
+ * },
496
+ * })
497
+ * ```
498
+ */
499
+ export interface ProductionConfig {
500
+ /**
501
+ * Register routes and build the dashboard when `NODE_ENV=production`.
502
+ *
503
+ * Requires an `authorize` guard — without one the routes are still not
504
+ * registered, and a warning explains why.
505
+ *
506
+ * @default false
507
+ */
508
+ enabled?: boolean;
509
+ /**
510
+ * Which capture subsystems to switch on. Every field defaults to `false` in
511
+ * production, so capture is opt-in one subsystem at a time.
512
+ *
513
+ * @see {@link CaptureConfig}
514
+ */
515
+ capture?: CaptureConfig;
516
+ /**
517
+ * How many days of history to keep in SQLite, overriding the usual default
518
+ * of 7. Retention deletes rows hourly but never runs `VACUUM`, so the
519
+ * database file reuses pages rather than shrinking — watch actual size via
520
+ * the dashboard's storage panel.
521
+ *
522
+ * @default 3
523
+ */
524
+ retentionDays?: number;
525
+ }
438
526
  /**
439
527
  * Advanced options that most users never need to touch.
440
528
  *
@@ -699,7 +787,7 @@ export interface ServerStatsConfig {
699
787
  *
700
788
  * @deprecated Use {@link authorize} instead. Will be removed in the next major version.
701
789
  */
702
- shouldShow?: (ctx: import('@adonisjs/core/http').HttpContext) => boolean;
790
+ shouldShow?: AccessGuard;
703
791
  /**
704
792
  * How often (in **milliseconds**) to run all collectors and
705
793
  * broadcast updated stats.
@@ -750,7 +838,7 @@ export interface ServerStatsConfig {
750
838
  * authorize: () => process.env.NODE_ENV === 'development'
751
839
  * ```
752
840
  */
753
- authorize?: (ctx: import('@adonisjs/core/http').HttpContext) => boolean;
841
+ authorize?: AccessGuard;
754
842
  /**
755
843
  * Register the sensitive dashboard/debug/stats routes WITHOUT any access
756
844
  * guard when no {@link authorize} callback is provided.
@@ -833,6 +921,21 @@ export interface ServerStatsConfig {
833
921
  * ```
834
922
  */
835
923
  domain?: string;
924
+ /**
925
+ * Opt in to running in production, where this package otherwise registers
926
+ * nothing at all.
927
+ *
928
+ * Requires an {@link authorize} guard, and leaves data capture off unless you
929
+ * enable it per subsystem.
930
+ *
931
+ * @see {@link ProductionConfig}
932
+ *
933
+ * @example
934
+ * ```ts
935
+ * production: { enabled: true, capture: { queries: true } }
936
+ * ```
937
+ */
938
+ production?: ProductionConfig;
836
939
  /**
837
940
  * Advanced options for fine-tuning internal behavior.
838
941
  *
@@ -878,7 +981,7 @@ export interface ResolvedServerStatsConfig {
878
981
  /** Optional dev toolbar configuration. */
879
982
  devToolbar?: DevToolbarOptions;
880
983
  /** Optional access-control callback. */
881
- shouldShow?: (ctx: import('@adonisjs/core/http').HttpContext) => boolean;
984
+ shouldShow?: AccessGuard;
882
985
  /** Collection interval in milliseconds (new name for {@link intervalMs}). */
883
986
  pollInterval?: number;
884
987
  /** Whether real-time (SSE) broadcasting is enabled (new name for {@link transport}). */
@@ -886,7 +989,7 @@ export interface ResolvedServerStatsConfig {
886
989
  /** HTTP endpoint path or `false` to disable (new name for {@link endpoint}). */
887
990
  statsEndpoint?: string | false;
888
991
  /** Access-control callback (new name for {@link shouldShow}). */
889
- authorize?: (ctx: import('@adonisjs/core/http').HttpContext) => boolean;
992
+ authorize?: AccessGuard;
890
993
  /**
891
994
  * Escape hatch to register sensitive routes without an access guard.
892
995
  * Exposes the dashboard without auth — local development only.
@@ -902,4 +1005,6 @@ export interface ResolvedServerStatsConfig {
902
1005
  verbose: boolean;
903
1006
  /** Optional domain restriction for all routes. */
904
1007
  domain?: string;
1008
+ /** Opt-in production behavior. Absent means this package is inert in production. */
1009
+ production?: ProductionConfig;
905
1010
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adonisjs-server-stats",
3
- "version": "1.15.0",
3
+ "version": "1.16.0",
4
4
  "description": "Real-time server monitoring for AdonisJS v6 applications",
5
5
  "keywords": [
6
6
  "adonisjs",