opticore-profiler 1.0.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.
Files changed (33) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/dist/assets/css/debug-message.css +16 -0
  4. package/dist/assets/css/home-page.css +177 -0
  5. package/dist/assets/css/profiler-detail.css +215 -0
  6. package/dist/assets/css/profiler-list.css +131 -0
  7. package/dist/assets/css/toolbar-standalone.css +213 -0
  8. package/dist/assets/js/home-particles.js +65 -0
  9. package/dist/assets/js/profiler-detail.js +77 -0
  10. package/dist/assets/js/profiler-list.js +76 -0
  11. package/dist/assets/js/toolbar.js +230 -0
  12. package/dist/index.cjs +1942 -0
  13. package/dist/index.d.cts +488 -0
  14. package/dist/index.d.ts +488 -0
  15. package/dist/index.js +1893 -0
  16. package/dist/utils/translations/message.translation.en.json +190 -0
  17. package/dist/utils/translations/message.translation.fr.json +190 -0
  18. package/dist/views/templates/home.njk +120 -0
  19. package/dist/views/templates/macros/icons.njk +19 -0
  20. package/dist/views/templates/macros/tables.njk +17 -0
  21. package/dist/views/templates/message.njk +14 -0
  22. package/dist/views/templates/panels/configuration.njk +16 -0
  23. package/dist/views/templates/panels/database.njk +36 -0
  24. package/dist/views/templates/panels/exception.njk +26 -0
  25. package/dist/views/templates/panels/logs.njk +44 -0
  26. package/dist/views/templates/panels/performance.njk +56 -0
  27. package/dist/views/templates/panels/request.njk +141 -0
  28. package/dist/views/templates/panels/routes.njk +51 -0
  29. package/dist/views/templates/panels/routing.njk +22 -0
  30. package/dist/views/templates/profiler-detail.njk +114 -0
  31. package/dist/views/templates/profiler-list.njk +163 -0
  32. package/dist/views/templates/toolbar-wdt.njk +161 -0
  33. package/package.json +73 -0
@@ -0,0 +1,488 @@
1
+ import { EventEmitter } from 'events';
2
+ import { Request, Response, RequestHandler, NextFunction, Application } from 'opticore-express';
3
+ import { TFeatureRoutes } from 'opticore-router';
4
+
5
+ /**
6
+ * A DataCollector observes one facet of a request/response cycle. Collectors
7
+ * never see or alter business code: they are wired up once, at bootstrap, via
8
+ * decoration (DB client, logger) or via the profiler middleware's own hooks
9
+ * (res.on('finish'), Express error middleware). `collect()` is called exactly
10
+ * once per profiled request, after the response has been sent.
11
+ */
12
+ interface CollectorContext {
13
+ token: string;
14
+ req: Request;
15
+ res: Response;
16
+ /** Date.now() captured when the profiler middleware started handling this request. */
17
+ startTime: number;
18
+ /** process.hrtime.bigint() captured at the same instant, for sub-ms precision. */
19
+ startHrTime: bigint;
20
+ /** process.memoryUsage().heapUsed captured at the same instant. */
21
+ heapUsedBefore: number;
22
+ /** The response body exactly as sent to the client, captured by the profiler middleware's res.end() interception (installHtmlInjection) — undefined when nothing was ever written. */
23
+ responseBody?: {
24
+ buffer: Buffer;
25
+ contentType: string;
26
+ };
27
+ }
28
+ interface DataCollector<T = unknown> {
29
+ /** Unique, stable identifier — also the key under which data is stored in Profile.collectors. */
30
+ getName(): string;
31
+ /** Called once, after the response has finished sending. May be async (e.g. flushing buffers). */
32
+ collect(ctx: CollectorContext, error?: Error | null): void | Promise<void>;
33
+ /** Returns the JSON-serializable data gathered by this collector. */
34
+ getData(): T;
35
+ /** Restores the collector to its initial state. Registry creates one instance per request, so
36
+ * reset() mainly matters for collectors that are reused (e.g. tests). */
37
+ reset(): void;
38
+ }
39
+ type CollectorFactory = () => DataCollector;
40
+ interface Profile {
41
+ token: string;
42
+ ip: string;
43
+ method: string;
44
+ url: string;
45
+ statusCode: number;
46
+ /** Unix ms timestamp — when the request started. */
47
+ time: number;
48
+ /** Total request duration in ms — convenience mirror of the "time" collector's data. */
49
+ duration: number;
50
+ /** Response Content-Type — convenience mirror of the "request" collector's data, kept at the top level (like duration) so lightweight index entries (FileStorage.find(), which never load collector payloads) can still tell an HTML page navigation apart from a JSON/API response. */
51
+ contentType?: string;
52
+ /** collector name -> collector.getData() */
53
+ collectors: Record<string, unknown>;
54
+ }
55
+ interface ProfilerStorage {
56
+ write(profile: Profile): Promise<void> | void;
57
+ read(token: string): Promise<Profile | undefined> | Profile | undefined;
58
+ /** Most recent profiles first. */
59
+ find(limit?: number): Promise<Profile[]> | Profile[];
60
+ /** Removes profiles older than `olderThanMs` (relative to Date.now()). No-op if omitted from options. */
61
+ purge(olderThanMs?: number): Promise<void> | void;
62
+ clear(): Promise<void> | void;
63
+ }
64
+ interface SecurityConfig {
65
+ /** Header/body/query keys that get masked before being persisted or displayed (case-insensitive). */
66
+ sensitiveKeys: string[];
67
+ maxUrlLength: number;
68
+ }
69
+ interface ProfilerOptions {
70
+ /** Master switch. Defaults to false — must be explicitly enabled (e.g. NODE_ENV === 'development'). */
71
+ enabled: boolean;
72
+ /** URL prefix under which the profiler UI/API is mounted. Requests under this prefix are never profiled. */
73
+ routePrefix: string;
74
+ storage: ProfilerStorage;
75
+ /** Collector factories to run for every profiled request. Defaults to the 6 built-in collectors. */
76
+ collectors?: CollectorFactory[];
77
+ /** Retention window in ms for automatic purge (0 disables automatic purge). */
78
+ retentionMs?: number;
79
+ security?: Partial<SecurityConfig>;
80
+ /** Max number of AJAX/main requests remembered in the toolbar's HTTP list. */
81
+ maxRequests?: number;
82
+ /**
83
+ * Paths never profiled — no token, no toolbar injection, no entry in the
84
+ * list/SSE/toolbar HTTP-requests table. Exact match or a trailing "/*"
85
+ * prefix match (e.g. "/.well-known/*"). Defaults to the browser-noise
86
+ * requests every site gets for free (favicon, Chrome DevTools probe) so
87
+ * the HTTP-requests table only ever shows requests the app actually
88
+ * handles as business logic.
89
+ */
90
+ excludePaths?: string[];
91
+ }
92
+ declare const DEFAULT_EXCLUDE_PATHS: readonly string[];
93
+ declare const DEFAULT_SENSITIVE_KEYS: readonly string[];
94
+ interface ISqlQuery {
95
+ sql: string;
96
+ duration: number;
97
+ timestamp: number;
98
+ bindings?: unknown[];
99
+ error?: string;
100
+ type?: string;
101
+ /** Origin call site, captured non-intrusively at the DB client decoration point. */
102
+ stack?: string;
103
+ }
104
+ interface ILogEntry {
105
+ level: "debug" | "info" | "warning" | "error" | "critical" | "deprecation";
106
+ message: string;
107
+ timestamp: number;
108
+ context?: Record<string, unknown>;
109
+ }
110
+ interface IRouteInfo {
111
+ path: string;
112
+ method: string;
113
+ handler?: string;
114
+ middlewares?: string[];
115
+ params?: Record<string, string | string[]>;
116
+ controller?: string;
117
+ }
118
+ interface IRequestInfo {
119
+ method: string;
120
+ url: string;
121
+ headers: Record<string, string | string[] | undefined>;
122
+ query: Record<string, unknown>;
123
+ body: Record<string, unknown>;
124
+ params: Record<string, string | string[]>;
125
+ ip: string;
126
+ cookies?: Record<string, string>;
127
+ protocol?: string;
128
+ hostname?: string;
129
+ /** `req.session`'s own data properties (e.g. express-session) — undefined when no session middleware is installed, distinct from an empty/absent session object so the Session tab can tell "not tracked" apart from "tracked but empty". */
130
+ session?: Record<string, unknown>;
131
+ }
132
+ interface IResponseInfo {
133
+ statusCode: number;
134
+ statusMessage: string;
135
+ headers: Record<string, string>;
136
+ contentType?: string;
137
+ /** Parsed from Set-Cookie response header(s), one entry per cookie name -> value — masked per-name like request cookies (see Masker), not blanket-redacted like the raw Set-Cookie header in `headers`. */
138
+ cookies?: Record<string, string>;
139
+ body?: unknown;
140
+ }
141
+
142
+ /**
143
+ * Central registry of collector factories. The profiler middleware asks the
144
+ * registry for a fresh set of collector *instances* at the start of every
145
+ * profiled request (never reusing instances across requests — that would
146
+ * leak data between unrelated requests running concurrently).
147
+ *
148
+ * Applications register custom collectors the same way built-ins are
149
+ * registered: `registry.register(() => new MyCollector())`.
150
+ */
151
+ declare class CollectorRegistry {
152
+ private readonly factories;
153
+ register(factory: CollectorFactory): void;
154
+ unregister(name: string): void;
155
+ has(name: string): boolean;
156
+ names(): string[];
157
+ /** Builds one fresh instance per registered factory, keyed by collector name. */
158
+ createAll(): Map<string, DataCollector>;
159
+ }
160
+
161
+ interface OpticoreProfiler extends RequestHandler {
162
+ registry: CollectorRegistry;
163
+ options: Readonly<Required<Pick<ProfilerOptions, "enabled" | "routePrefix" | "storage" | "retentionMs" | "maxRequests" | "excludePaths">> & {
164
+ security: SecurityConfig;
165
+ }>;
166
+ /** Purges every stored profile. */
167
+ clear(): Promise<void> | void;
168
+ /**
169
+ * Emits a "profile" event, with the lightweight IndexEntry-shaped
170
+ * summary, right after every profile is persisted — the push side of the
171
+ * profiler list's live updates (GET /_profiler/stream), so the list page
172
+ * never has to poll the server.
173
+ */
174
+ events: EventEmitter;
175
+ }
176
+ /**
177
+ * Builds the profiler middleware. This is the single integration point an
178
+ * application needs: `app.use(opticoreProfiler({ storage }))`.
179
+ *
180
+ * Per request (when enabled and outside routePrefix):
181
+ * 1. generates a token and a fresh set of collector instances,
182
+ * 2. runs the rest of the request inside an AsyncLocalStorage context so
183
+ * instrumented code (DB driver, logger) can reach those collectors,
184
+ * 3. on res.on('finish'), asks every collector for its data, assembles a
185
+ * Profile and hands it to `storage.write()` — after the response has
186
+ * already been sent, so this adds no perceived latency,
187
+ * 4. optionally rewrites an HTML response to inject the toolbar loader
188
+ * snippet before `</body>`.
189
+ */
190
+ declare function opticoreProfiler(options: Partial<ProfilerOptions> & {
191
+ storage: ProfilerOptions["storage"];
192
+ }): OpticoreProfiler;
193
+
194
+ /**
195
+ * Express error-handling middleware (4-arg signature) that records the error
196
+ * into the current request's ExceptionCollector, then forwards it unchanged
197
+ * via next(err) — it never sends a response itself, so the application's own
198
+ * error handling (JSON error responses, custom error pages, ...) behaves
199
+ * exactly as if the profiler weren't there. Mount it after all routes, same
200
+ * as any other Express error middleware:
201
+ *
202
+ * app.use(...routes)
203
+ * app.use(profilerErrorHandler())
204
+ * app.use(myAppErrorHandler)
205
+ */
206
+ declare function profilerErrorHandler(): (err: Error, _req: Request, _res: Response, next: NextFunction) => void;
207
+
208
+ /**
209
+ * Wires the profiler's *presentation* layer onto an Express app: the
210
+ * Nunjucks view engine, static assets (CSS/JS) and the "/" landing page.
211
+ * The profiler's own data routes (list/detail/wdt) are registered
212
+ * separately via `createProfilerRouter(profiler)`, alongside the
213
+ * application's other feature routers — see README.md for the full
214
+ * integration example.
215
+ *
216
+ * Call this once, before `app.use(opticoreProfiler(...))` handles any
217
+ * request, so the view engine and static assets are ready.
218
+ */
219
+ declare function registerProfilerViews(app: Application, profiler: OpticoreProfiler): void;
220
+
221
+ declare function createProfilerRouter(profiler: OpticoreProfiler): TFeatureRoutes;
222
+
223
+ type SqlArg = string | {
224
+ sql: string;
225
+ [key: string]: unknown;
226
+ };
227
+ type MakeQueryFn = (sql: SqlArg, values?: unknown[], callback?: (...args: unknown[]) => void) => Promise<unknown>;
228
+ interface MySQLDriverCtor {
229
+ prototype: {
230
+ makeQuery: MakeQueryFn;
231
+ };
232
+ }
233
+ /**
234
+ * Decorates a MySQL driver's prototype so every query it runs is timed and
235
+ * recorded against the request currently active on the profiler's
236
+ * AsyncLocalStorage context — application code that calls `db.query(...)`
237
+ * or `db.makeQuery(...)` is completely unaware this happens. Call once at
238
+ * bootstrap, before any request is handled. Idempotent: safe to call more
239
+ * than once (e.g. under hot reload) — re-wrapping is a no-op.
240
+ */
241
+ declare function instrumentMySQL(DriverClass: MySQLDriverCtor): void;
242
+
243
+ interface ILogSuccessArg {
244
+ title?: string;
245
+ message?: string;
246
+ }
247
+ interface ILogInfoArg {
248
+ title?: string;
249
+ message?: string;
250
+ }
251
+ interface ILogWarnArg {
252
+ title?: string;
253
+ message: string;
254
+ }
255
+ interface ILogErrorArg {
256
+ message: string;
257
+ title?: string;
258
+ errorType?: unknown;
259
+ stackTrace?: unknown;
260
+ httpCodeValue?: unknown;
261
+ }
262
+ interface LoggerCoreLike {
263
+ success(arg: ILogSuccessArg): void;
264
+ info(arg: ILogInfoArg): void;
265
+ warn(arg: ILogWarnArg): void;
266
+ error(arg: ILogErrorArg): void;
267
+ debug(message: string): void;
268
+ }
269
+ interface LoggerCoreCtor {
270
+ prototype: LoggerCoreLike;
271
+ }
272
+ /**
273
+ * Decorates LoggerCore's prototype so every log call the application makes
274
+ * (through its normal `logger.info(...)`, `logger.error(...)`, etc.) is
275
+ * mirrored into the active request's LoggerCollector, in addition to
276
+ * whatever the logger already does (console/file/remote transports keep
277
+ * working unchanged). Call once at bootstrap. Idempotent.
278
+ */
279
+ declare function instrumentLogger(LoggerClass: LoggerCoreCtor): void;
280
+
281
+ /**
282
+ * Generates a short (default 8 chars) alphanumeric token by rejection-sampling
283
+ * random bytes against a 36-symbol alphabet, so every character is unbiased.
284
+ */
285
+ declare function generateToken(length?: number): string;
286
+ declare function isValidToken(token: unknown): token is string;
287
+
288
+ /**
289
+ * Looks up the named collector for the request currently being handled on
290
+ * this async chain. Returns undefined outside of a profiled request (e.g.
291
+ * profiling disabled, or the call happened outside any HTTP request) —
292
+ * callers must treat that as "do nothing", never throw.
293
+ */
294
+ declare function getActiveCollector<T extends DataCollector>(name: string): T | undefined;
295
+
296
+ /**
297
+ * Circular buffer of the N most recent profiles, kept in process memory.
298
+ * Lost on restart — pick FileStorage when profiles need to survive that.
299
+ */
300
+ declare class MemoryStorage implements ProfilerStorage {
301
+ private readonly limit;
302
+ private readonly profiles;
303
+ private readonly order;
304
+ constructor(limit?: number);
305
+ write(profile: Profile): void;
306
+ read(token: string): Profile | undefined;
307
+ find(limit?: number): Profile[];
308
+ purge(olderThanMs?: number): void;
309
+ clear(): void;
310
+ count(): number;
311
+ }
312
+
313
+ /**
314
+ * Persists profiles as JSON files under `<dir>/<token>.json`, plus a single
315
+ * `<dir>/index.json` holding lightweight metadata (no collector payloads)
316
+ * for the last N profiles, most-recent-first — reading the index to list
317
+ * profiles never has to open every per-token file, mirroring Symfony's
318
+ * FileProfilerStorage split between index and full data.
319
+ */
320
+ declare class FileStorage implements ProfilerStorage {
321
+ private readonly dir;
322
+ private readonly maxIndexSize;
323
+ private readonly indexPath;
324
+ constructor(dir?: string, maxIndexSize?: number);
325
+ private ensureDir;
326
+ private profilePath;
327
+ private readIndex;
328
+ private writeIndex;
329
+ write(profile: Profile): void;
330
+ read(token: string): Profile | undefined;
331
+ find(limit?: number): Profile[];
332
+ purge(olderThanMs?: number): void;
333
+ clear(): void;
334
+ private deleteFile;
335
+ }
336
+
337
+ /**
338
+ * Masks sensitive values before they are persisted or rendered. Applied to
339
+ * headers, cookies, query and body objects captured by RequestCollector —
340
+ * masking happens at collection time (not at render time) so redacted data
341
+ * never even reaches disk in FileStorage.
342
+ */
343
+ declare class Masker {
344
+ private readonly keys;
345
+ constructor(sensitiveKeys?: readonly string[]);
346
+ isSensitive(key: string): boolean;
347
+ /** Shallow-masks a flat object (headers, query, cookies, single-level body). */
348
+ maskShallow<T extends Record<string, unknown>>(obj: T | undefined | null): T;
349
+ /** Recursively masks nested objects/arrays (request/response bodies). */
350
+ maskDeep(value: unknown, depth?: number): unknown;
351
+ }
352
+
353
+ interface RequestCollectorData {
354
+ method: string;
355
+ url: string;
356
+ statusCode: number;
357
+ statusMessage: string;
358
+ request: IRequestInfo;
359
+ response: IResponseInfo;
360
+ route: IRouteInfo;
361
+ }
362
+ /**
363
+ * Observes the request/response objects purely by reading their already-public
364
+ * properties inside collect() — nothing here touches how the application
365
+ * builds its request or sends its response.
366
+ */
367
+ declare class RequestCollector implements DataCollector<RequestCollectorData> {
368
+ private readonly masker;
369
+ private data;
370
+ constructor(masker?: Masker);
371
+ getName(): string;
372
+ /** Parses the buffered response body as JSON when the response declared a JSON content-type — undefined otherwise (HTML pages, empty bodies, non-JSON payloads), so the detail view falls back to its "no body captured" state instead of dumping raw bytes. */
373
+ private parseResponseBody;
374
+ /** Parses Set-Cookie response header(s) into name -> value, mirroring how request cookies are captured — masked per cookie name (Masker), not blanket-redacted like the raw "set-cookie" header. */
375
+ private parseResponseCookies;
376
+ /**
377
+ * Reads `req.session` (express-session or any middleware that assigns a
378
+ * plain, JSON-serializable object there) — own data properties only,
379
+ * methods (save/destroy/reload/...) live on the prototype so a plain
380
+ * Object.entries() already excludes them. Returns undefined (not `{}`)
381
+ * when there's no session object at all, so the Session tab can tell
382
+ * "this app doesn't use sessions" apart from "session exists but empty".
383
+ */
384
+ private parseSession;
385
+ collect(ctx: CollectorContext): void;
386
+ getData(): RequestCollectorData;
387
+ reset(): void;
388
+ }
389
+
390
+ interface TimePhase {
391
+ name: string;
392
+ /** ms elapsed since the request started. */
393
+ offset: number;
394
+ }
395
+ interface TimeCollectorData {
396
+ startedAt: number;
397
+ duration: number;
398
+ phases: TimePhase[];
399
+ }
400
+ /**
401
+ * Total wall-clock duration plus any named checkpoints other code marks
402
+ * along the way (e.g. "middleware.done", "handler.done") via mark().
403
+ * mark() is looked up through the request's AsyncLocalStorage-scoped
404
+ * collector instance (context.getActiveCollector), so it never requires
405
+ * threading a token through business code.
406
+ */
407
+ declare class TimeCollector implements DataCollector<TimeCollectorData> {
408
+ private data;
409
+ private readonly phases;
410
+ getName(): string;
411
+ /** Records a named checkpoint at the current elapsed time, relative to the active request's start. */
412
+ mark(name: string): void;
413
+ collect(ctx: CollectorContext): void;
414
+ getData(): TimeCollectorData;
415
+ reset(): void;
416
+ }
417
+
418
+ interface MemoryCollectorData {
419
+ heapUsedBefore: number;
420
+ heapUsedAfter: number;
421
+ delta: number;
422
+ }
423
+ /** Heap usage before the request started vs. right after the response finished. */
424
+ declare class MemoryCollector implements DataCollector<MemoryCollectorData> {
425
+ private data;
426
+ getName(): string;
427
+ collect(ctx: CollectorContext): void;
428
+ getData(): MemoryCollectorData;
429
+ reset(): void;
430
+ }
431
+
432
+ /**
433
+ * Holds the SQL queries recorded for one request. Instances are never
434
+ * populated by business code directly — see instrumentation/mysql.instrumentation.ts,
435
+ * which decorates the DB driver once at bootstrap and pushes into whichever
436
+ * DatabaseCollector is active on the current AsyncLocalStorage context.
437
+ */
438
+ declare class DatabaseCollector implements DataCollector<ISqlQuery[]> {
439
+ private readonly queries;
440
+ getName(): string;
441
+ record(query: Omit<ISqlQuery, "timestamp">): void;
442
+ collect(_ctx: CollectorContext): void;
443
+ getData(): ISqlQuery[];
444
+ reset(): void;
445
+ }
446
+
447
+ /**
448
+ * Holds the log entries emitted for one request, grouped implicitly by level
449
+ * (the view layer groups/counts them). Like DatabaseCollector, entries are
450
+ * pushed in real time by instrumentation/logger.instrumentation.ts — business
451
+ * code keeps calling `logger.info(...)` / `logger.error(...)` unmodified.
452
+ */
453
+ declare class LoggerCollector implements DataCollector<ILogEntry[]> {
454
+ private readonly logs;
455
+ getName(): string;
456
+ record(entry: Omit<ILogEntry, "timestamp">): void;
457
+ collect(_ctx: CollectorContext): void;
458
+ getData(): ILogEntry[];
459
+ byLevel(level: ILogEntry["level"]): ILogEntry[];
460
+ reset(): void;
461
+ }
462
+
463
+ interface ExceptionData {
464
+ name: string;
465
+ message: string;
466
+ stack: string;
467
+ }
468
+ interface ExceptionCollectorData {
469
+ error: ExceptionData | null;
470
+ }
471
+ /**
472
+ * Captures the error that made a request fail, if any. Fed by:
473
+ * - middleware/errorHandler.middleware.ts, an Express error-handling
474
+ * middleware the app mounts after its routes (standard Express wiring —
475
+ * not a change to business logic, same as mounting any other middleware).
476
+ * - the process-level uncaughtException/unhandledRejection fallback, for
477
+ * errors that escape Express's error-handling chain entirely.
478
+ */
479
+ declare class ExceptionCollector implements DataCollector<ExceptionCollectorData> {
480
+ private error;
481
+ getName(): string;
482
+ setError(err: Error): void;
483
+ collect(_ctx: CollectorContext, error?: Error | null): void;
484
+ getData(): ExceptionCollectorData;
485
+ reset(): void;
486
+ }
487
+
488
+ export { type CollectorContext, type CollectorFactory, CollectorRegistry, DEFAULT_EXCLUDE_PATHS, DEFAULT_SENSITIVE_KEYS, type DataCollector, DatabaseCollector, ExceptionCollector, FileStorage, LoggerCollector, MemoryCollector, MemoryStorage, type OpticoreProfiler, type Profile, type ProfilerOptions, type ProfilerStorage, RequestCollector, type SecurityConfig, TimeCollector, createProfilerRouter, generateToken, getActiveCollector, instrumentLogger, instrumentMySQL, isValidToken, opticoreProfiler, profilerErrorHandler, registerProfilerViews };