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.
- package/README.md +88 -2
- package/dist/core/debug/types.d.ts +14 -0
- package/dist/core/types.d.ts +109 -4
- package/dist/src/dashboard/dashboard_controller.d.ts +7 -0
- package/dist/src/dashboard/dashboard_controller.js +10 -3
- package/dist/src/dashboard/dashboard_store.d.ts +1 -2
- package/dist/src/dashboard/dashboard_store.js +0 -3
- package/dist/src/dashboard/dashboard_types.d.ts +2 -0
- package/dist/src/dashboard/flush_manager.d.ts +1 -6
- package/dist/src/dashboard/flush_manager.js +3 -10
- package/dist/src/dashboard/integrations/config_inspector.js +9 -49
- package/dist/src/dashboard/sensitive_patterns.d.ts +39 -0
- package/dist/src/dashboard/sensitive_patterns.js +118 -0
- package/dist/src/dashboard/write_queue.d.ts +25 -13
- package/dist/src/dashboard/write_queue.js +63 -37
- package/dist/src/debug/debug_store.d.ts +15 -1
- package/dist/src/debug/debug_store.js +32 -4
- package/dist/src/debug/event_collector.d.ts +8 -0
- package/dist/src/debug/event_collector.js +12 -0
- package/dist/src/debug/types.d.ts +14 -0
- package/dist/src/define_config.js +23 -0
- 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 +9 -0
- package/dist/src/provider/boot_helpers.js +31 -0
- package/dist/src/provider/dashboard_init.js +14 -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 +23 -7
- 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/register_routes.d.ts +13 -3
- package/dist/src/routes/register_routes.js +15 -1
- package/dist/src/stubs/config.stub +8 -0
- package/dist/src/types.d.ts +109 -4
- 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
|
|
25
|
-
* (long tokens, hashes, keys) are not stored in cleartext.
|
|
24
|
+
* Redact and truncate SQL bindings before persistence.
|
|
26
25
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|
45
|
-
* (long tokens, hashes, keys) are not stored in cleartext.
|
|
47
|
+
* Redact and truncate SQL bindings before persistence.
|
|
46
48
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
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
|
-
|
|
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();
|
|
@@ -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:
|
|
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) {
|
|
@@ -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)
|