adonisjs-server-stats 1.14.1 → 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 +115 -1
- package/dist/core/debug/types.d.ts +14 -0
- package/dist/core/types.d.ts +140 -4
- package/dist/src/dashboard/dashboard_controller.d.ts +7 -0
- package/dist/src/dashboard/dashboard_controller.js +10 -3
- package/dist/src/debug/debug_store.d.ts +15 -1
- package/dist/src/debug/debug_store.js +32 -4
- package/dist/src/debug/types.d.ts +14 -0
- package/dist/src/define_config.js +49 -1
- package/dist/src/middleware/request_tracking_middleware.d.ts +2 -1
- package/dist/src/middleware/request_tracking_middleware.js +16 -1
- package/dist/src/provider/boot_helpers.d.ts +19 -1
- package/dist/src/provider/boot_helpers.js +51 -2
- package/dist/src/provider/dashboard_init.js +5 -1
- package/dist/src/provider/dashboard_setup.d.ts +10 -1
- package/dist/src/provider/dashboard_setup.js +47 -2
- package/dist/src/provider/server_stats_provider.d.ts +7 -0
- package/dist/src/provider/server_stats_provider.js +26 -8
- package/dist/src/provider/toolbar_setup.js +5 -1
- package/dist/src/routes/access_middleware.d.ts +7 -1
- package/dist/src/routes/access_middleware.js +6 -1
- package/dist/src/routes/dashboard_routes.d.ts +1 -0
- package/dist/src/routes/dashboard_routes.js +5 -3
- package/dist/src/routes/debug_routes.d.ts +1 -0
- package/dist/src/routes/debug_routes.js +5 -3
- package/dist/src/routes/register_routes.d.ts +18 -3
- package/dist/src/routes/register_routes.js +24 -2
- package/dist/src/routes/router_types.d.ts +15 -5
- package/dist/src/routes/stats_routes.d.ts +10 -1
- package/dist/src/routes/stats_routes.js +9 -2
- package/dist/src/stubs/config.stub +8 -0
- package/dist/src/types.d.ts +140 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -181,6 +181,8 @@ All fields are optional. `defineConfig({})` works with zero configuration.
|
|
|
181
181
|
| `statsEndpoint` | `string \| false` | `'/admin/api/server-stats'` | HTTP endpoint. `false` to disable. |
|
|
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
|
+
| `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) |
|
|
184
186
|
| `onStats` | `(stats) => void` | -- | Callback after each collection tick |
|
|
185
187
|
| `toolbar` | `boolean \| ToolbarConfig` | -- | `true` to enable with defaults, or pass a `ToolbarConfig` object |
|
|
186
188
|
| `dashboard` | `boolean \| DashboardConfig` | -- | `true` to enable at `/__stats`, or pass a `DashboardConfig` |
|
|
@@ -204,6 +206,16 @@ All fields are optional. `defineConfig({})` works with zero configuration.
|
|
|
204
206
|
| `path` | `string` | `'/__stats'` | URL path for the dashboard page |
|
|
205
207
|
| `retentionDays` | `number` | `7` | Days to keep historical data in SQLite |
|
|
206
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
|
+
|
|
207
219
|
### `AdvancedConfig`
|
|
208
220
|
|
|
209
221
|
| Option | Type | Default | Description |
|
|
@@ -383,6 +395,81 @@ export default defineConfig({
|
|
|
383
395
|
|
|
384
396
|
---
|
|
385
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
|
+
|
|
386
473
|
## Auto-Registered Routes
|
|
387
474
|
|
|
388
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.
|
|
@@ -440,6 +527,33 @@ Registered when `dashboard` is enabled. Base path configurable via `dashboard.pa
|
|
|
440
527
|
| POST | `/api/filters` | Create saved filter |
|
|
441
528
|
| DELETE | `/api/filters/:id` | Delete saved filter |
|
|
442
529
|
|
|
530
|
+
### Custom domain
|
|
531
|
+
|
|
532
|
+
By default every route is registered on whatever host your app serves. Set `domain` to bind them to one host instead -- useful when your admin surface already lives on its own subdomain:
|
|
533
|
+
|
|
534
|
+
```ts
|
|
535
|
+
export default defineConfig({
|
|
536
|
+
domain: 'admin.example.com',
|
|
537
|
+
authorize: (ctx) => ctx.auth?.user?.role === 'admin',
|
|
538
|
+
toolbar: true,
|
|
539
|
+
dashboard: true,
|
|
540
|
+
})
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
The dashboard is then reachable at `admin.example.com/__stats` and nowhere else -- a request to `example.com/__stats` no longer matches any route. Dynamic subdomains use AdonisJS's `:param` syntax:
|
|
544
|
+
|
|
545
|
+
```ts
|
|
546
|
+
export default defineConfig({
|
|
547
|
+
domain: ':tenant.example.com', // matches acme.example.com, globex.example.com, ...
|
|
548
|
+
})
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
Pass a bare host. A protocol, path, or port (`https://admin.example.com`, `admin.example.com:3333`) produces routes that match nothing; `defineConfig` warns when it spots one.
|
|
552
|
+
|
|
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.
|
|
554
|
+
|
|
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.
|
|
556
|
+
|
|
443
557
|
### Global middleware note
|
|
444
558
|
|
|
445
559
|
Auto-registered routes bypass route-level middleware but are still subject to global/server middleware.
|
|
@@ -625,7 +739,7 @@ Found a bug? Have feedback? [Open an issue](https://github.com/simulieren/adonis
|
|
|
625
739
|
|
|
626
740
|
## Dev Toolbar
|
|
627
741
|
|
|
628
|
-
Adds a debug panel with SQL query inspection, event tracking, email capture with HTML preview, route table, live logs, and per-request tracing.
|
|
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.
|
|
629
743
|
|
|
630
744
|
```ts
|
|
631
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.
|
package/dist/core/types.d.ts
CHANGED
|
@@ -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?:
|
|
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?:
|
|
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.
|
|
@@ -804,6 +892,50 @@ export interface ServerStatsConfig {
|
|
|
804
892
|
* ```
|
|
805
893
|
*/
|
|
806
894
|
dashboard?: boolean | DashboardConfig;
|
|
895
|
+
/**
|
|
896
|
+
* Restrict all server-stats routes to a specific domain or subdomain.
|
|
897
|
+
*
|
|
898
|
+
* When set, routes are only matched when the request's `Host` header
|
|
899
|
+
* matches the given domain. Useful when admin routes live on a
|
|
900
|
+
* dedicated subdomain (e.g. `admin.example.com`).
|
|
901
|
+
*
|
|
902
|
+
* Supports dynamic subdomains using `:param` syntax
|
|
903
|
+
* (e.g. `':tenant.example.com'`).
|
|
904
|
+
*
|
|
905
|
+
* Pass a bare host — a protocol, path, or port (`'https://admin.example.com'`,
|
|
906
|
+
* `'admin.example.com:3333'`) yields routes that match nothing.
|
|
907
|
+
*
|
|
908
|
+
* Note that the `@serverStats()` toolbar and the React/Vue components request
|
|
909
|
+
* relative URLs, so they only work on pages served from this domain.
|
|
910
|
+
*
|
|
911
|
+
* @example
|
|
912
|
+
* ```ts
|
|
913
|
+
* // Fixed subdomain
|
|
914
|
+
* domain: 'admin.example.com'
|
|
915
|
+
* ```
|
|
916
|
+
*
|
|
917
|
+
* @example
|
|
918
|
+
* ```ts
|
|
919
|
+
* // Dynamic subdomain
|
|
920
|
+
* domain: ':tenant.example.com'
|
|
921
|
+
* ```
|
|
922
|
+
*/
|
|
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;
|
|
807
939
|
/**
|
|
808
940
|
* Advanced options for fine-tuning internal behavior.
|
|
809
941
|
*
|
|
@@ -849,7 +981,7 @@ export interface ResolvedServerStatsConfig {
|
|
|
849
981
|
/** Optional dev toolbar configuration. */
|
|
850
982
|
devToolbar?: DevToolbarOptions;
|
|
851
983
|
/** Optional access-control callback. */
|
|
852
|
-
shouldShow?:
|
|
984
|
+
shouldShow?: AccessGuard;
|
|
853
985
|
/** Collection interval in milliseconds (new name for {@link intervalMs}). */
|
|
854
986
|
pollInterval?: number;
|
|
855
987
|
/** Whether real-time (SSE) broadcasting is enabled (new name for {@link transport}). */
|
|
@@ -857,7 +989,7 @@ export interface ResolvedServerStatsConfig {
|
|
|
857
989
|
/** HTTP endpoint path or `false` to disable (new name for {@link endpoint}). */
|
|
858
990
|
statsEndpoint?: string | false;
|
|
859
991
|
/** Access-control callback (new name for {@link shouldShow}). */
|
|
860
|
-
authorize?:
|
|
992
|
+
authorize?: AccessGuard;
|
|
861
993
|
/**
|
|
862
994
|
* Escape hatch to register sensitive routes without an access guard.
|
|
863
995
|
* Exposes the dashboard without auth — local development only.
|
|
@@ -871,4 +1003,8 @@ export interface ResolvedServerStatsConfig {
|
|
|
871
1003
|
advanced?: AdvancedConfig;
|
|
872
1004
|
/** Whether verbose informational logging is enabled. Always present after `defineConfig()`. */
|
|
873
1005
|
verbose: boolean;
|
|
1006
|
+
/** Optional domain restriction for all routes. */
|
|
1007
|
+
domain?: string;
|
|
1008
|
+
/** Opt-in production behavior. Absent means this package is inert in production. */
|
|
1009
|
+
production?: ProductionConfig;
|
|
874
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
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.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { logDeprecationWarnings } from './config/deprecation_migration.js';
|
|
2
|
-
import { setVerbose } from './utils/logger.js';
|
|
2
|
+
import { log, setVerbose } from './utils/logger.js';
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
4
|
// Toolbar alias resolution helpers
|
|
5
5
|
// ---------------------------------------------------------------------------
|
|
@@ -126,10 +126,56 @@ 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
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Warn about `domain` values that AdonisJS will never match.
|
|
152
|
+
*
|
|
153
|
+
* `.domain()` compares against the request's host only -- a protocol, path, or
|
|
154
|
+
* port in the value silently yields routes that match nothing, which is very
|
|
155
|
+
* hard to debug from the outside. Warn instead of throwing so a bad value
|
|
156
|
+
* degrades to "dashboard not reachable" rather than "app won't boot".
|
|
157
|
+
*/
|
|
158
|
+
function warnAboutDomain(domain) {
|
|
159
|
+
const problems = [];
|
|
160
|
+
if (domain.includes('://'))
|
|
161
|
+
problems.push('remove the protocol (`http://` / `https://`)');
|
|
162
|
+
if (domain.includes('/'))
|
|
163
|
+
problems.push('remove the path — only the host is matched');
|
|
164
|
+
if (/:\d+$/.test(domain))
|
|
165
|
+
problems.push('remove the port — it is not part of the host match');
|
|
166
|
+
if (problems.length === 0)
|
|
167
|
+
return;
|
|
168
|
+
log.warn(`server-stats: \`domain: '${domain}'\` looks wrong — ${problems.join('; ')}. ` +
|
|
169
|
+
"Expected a bare host such as 'admin.example.com' or ':tenant.example.com'. " +
|
|
170
|
+
'As written, no server-stats route will ever match.');
|
|
171
|
+
}
|
|
129
172
|
export function defineConfig(config) {
|
|
130
173
|
const verbose = config.verbose ?? false;
|
|
131
174
|
setVerbose(verbose);
|
|
132
175
|
logDeprecationWarnings(config);
|
|
176
|
+
if (config.domain)
|
|
177
|
+
warnAboutDomain(config.domain);
|
|
178
|
+
warnAboutProduction(config);
|
|
133
179
|
return {
|
|
134
180
|
intervalMs: first(config.pollInterval, config.intervalMs, 3000),
|
|
135
181
|
transport: resolveTransport(config),
|
|
@@ -142,5 +188,7 @@ export function defineConfig(config) {
|
|
|
142
188
|
shouldShow: config.authorize ?? config.shouldShow,
|
|
143
189
|
unsafeAllowNoAuth: config.unsafeAllowNoAuth,
|
|
144
190
|
verbose,
|
|
191
|
+
domain: config.domain,
|
|
192
|
+
production: config.production,
|
|
145
193
|
};
|
|
146
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:
|
|
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
|
-
|
|
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) {
|