proteum 2.2.0 → 2.2.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/AGENTS.md CHANGED
@@ -32,7 +32,7 @@ cd <worktree path>
32
32
  npx prisma migrate dev --config ./prisma.config.ts --name <migration name>
33
33
  ```
34
34
  - After implementing a framework feature or change, do not stop at code edits. Boot both reference apps, exercise the affected flow with Playwright or the smallest real Proteum surface, run the relevant `proteum` diagnostics or perf commands, and confirm there is no meaningful regression in runtime behavior, performance, load size, SEO output, or coding-style expectations before finishing.
35
- - When starting a long-lived reference app dev server for framework work, always request elevated permissions and run `npx proteum dev` outside the sandbox. Use an explicit thread-scoped session file such as `var/run/proteum/dev/framework-<app>-<task>.json`, inspect tracked sessions plus current listeners first, for example with `npx proteum dev list --json` and `lsof -nP -iTCP -sTCP:LISTEN`, then choose a port that is not currently used before starting `npx proteum dev --session-file <path> --port <port>`.
35
+ - When starting a long-lived reference app dev server for framework work, always request elevated permissions and run `npx proteum dev` outside the sandbox. Use an explicit thread-scoped session file such as `var/run/proteum/dev/framework-<app>-<task>.json`, inspect tracked sessions plus current listeners first, for example with `npx proteum dev list --json` and `lsof -nP -iTCP -sTCP:LISTEN`, then choose a port that is not currently used before starting `npx proteum dev --session-file <path> --port <port>`. After the server is ready, print the live server URL as a clickable Markdown link such as `[http://localhost:3100](http://localhost:3100)`.
36
36
  - Do not use `--replace-existing` unless you are restarting the exact session file started by the current thread/task. Never replace another live session that belongs to a user, another thread, or an unknown owner.
37
37
  - When a reference app uses local `file:` connected projects for the affected flow, boot every connected producer app as well, each on its own free port and thread-scoped session file, and run every one of those `proteum dev` processes with elevated permissions outside the sandbox before starting or validating the consumer app.
38
38
  - Before retrying a boot on the same app, changing ports, or finishing the task, stop every framework-started dev session with `npx proteum dev stop --session-file <path>` or `npx proteum dev stop --all --stale`.
@@ -51,11 +51,13 @@ npx prisma migrate dev --config ./prisma.config.ts --name <migration name>
51
51
 
52
52
  - Validate framework changes against the reference apps:
53
53
  - `/Users/gaetan/Desktop/Projets/crosspath/platform`: Standalone app
54
+ - `/Users/gaetan/Desktop/Projets/crosspath/website`: Standalone app
54
55
  - `/Users/gaetan/Desktop/Projets/unique.domains/platform`: Monorepo including the following apps:
55
56
  - `/Users/gaetan/Desktop/Projets/unique.domains/platform/apps/product`
56
57
  - `/Users/gaetan/Desktop/Projets/unique.domains/platform/apps/website`
57
58
  - Inspect how both apps currently use the touched feature, runtime, API, compiler behavior, or generated output before proposing or implementing changes.
58
59
  - Keep the developer-facing contract synchronized when framework work changes CLI commands, profiler capabilities, or the `proteum dev` banner. Update the live surfaces together in the same pass: CLI command/help definitions, profiler panels and dev-only endpoints, banner text/examples, and the most relevant agent docs that describe them, especially `AGENTS.md`, `agents/project/AGENTS.md`, `agents/project/root/AGENTS.md`, `agents/project/app-root/AGENTS.md`, `agents/project/diagnostics.md`, and any narrower `agents/project/**/AGENTS.md` file that mentions the changed workflow.
60
+ - Keep the same-system trace contract explicit when request instrumentation changes: `TRACE_*` controls the retained dev trace store plus the trace/perf CLI, dev-only HTTP endpoints, and bottom profiler, while `ENABLE_PROFILER` enables the reduced request-local `request.profiling` snapshot and `request.finished` hook payload without retaining finished requests globally unless dev trace is also enabled.
59
61
  - Current CLI banner contract: only the bare `proteum build` and bare `proteum dev` commands print the welcome banner and include the active Proteum installation method. Any extra argument or option skips the banner. Only `proteum dev` clears the interactive terminal before rendering, exposes `CTRL+R` reload plus `CTRL+C` shutdown hotkeys in its session UI, and reports connected app names plus successful connected `/ping` checks in the ready banner.
60
62
  - Keep core changes aligned with the explicit controller/page architecture in `agents/project/root/AGENTS.md` and its standalone composition in `agents/project/AGENTS.md`.
61
63
  - Prefer removing framework magic when the same result can be expressed with explicit contracts, generated code, or typed context.
package/README.md CHANGED
@@ -6,6 +6,12 @@ It is built for teams that want explicit server contracts, server-first renderin
6
6
 
7
7
  Migration guide for older apps: [docs/migrate-from-2.1.3.md](docs/migrate-from-2.1.3.md)
8
8
 
9
+ ## Sponsor
10
+
11
+ Proteum is sponsored by [Unique Domains](https://unique.domains/?utm_source=github&utm_medium=referral&utm_campaign=repo_proteum&utm_content=top_sponsor).
12
+
13
+ [![Unique Domains](docs/assets/unique-domains-chip.png)](https://unique.domains/?utm_source=github&utm_medium=referral&utm_campaign=repo_proteum&utm_content=top_sponsor)
14
+
9
15
  ## Why Proteum
10
16
 
11
17
  Most full-stack frameworks optimize first for human convenience.
@@ -101,6 +107,7 @@ Optional trace env vars:
101
107
  - `TRACE_EVENTS_LIMIT`
102
108
  - `TRACE_CAPTURE`
103
109
  - `TRACE_PERSIST_ON_ERROR`
110
+ - `ENABLE_PROFILER`
104
111
 
105
112
  Optional `proteum.config.ts` fields:
106
113
 
@@ -464,6 +471,7 @@ Default behavior:
464
471
  - payloads are summarized, long strings are truncated, and sensitive fields such as cookies, passwords, and tokens are redacted
465
472
  - `TRACE_PERSIST_ON_ERROR` can export crashing requests under `var/traces/`
466
473
  - `proteum dev` removes auto-persisted crash traces from `var/traces/` when the dev session stops
474
+ - `ENABLE_PROFILER=true` reuses the same instrumentation path to populate `request.profiling` and the router `request.finished` hook with a reduced request/API/SQL snapshot in any environment, without retaining finished requests in the global trace buffer unless dev trace is also enabled
467
475
 
468
476
  Trace env example:
469
477
 
@@ -473,6 +481,7 @@ export TRACE_REQUESTS_LIMIT=200
473
481
  export TRACE_EVENTS_LIMIT=800
474
482
  export TRACE_CAPTURE=resolve
475
483
  export TRACE_PERSIST_ON_ERROR=true
484
+ export ENABLE_PROFILER=true
476
485
  ```
477
486
 
478
487
  Capture modes:
@@ -500,7 +509,7 @@ Proteum answers those questions with explicit artifacts:
500
509
 
501
510
  - `identity.config.ts` for app identity
502
511
  - `proteum.config.ts` for compiler and connected-project setup
503
- - `PORT`, `ENV_*`, `URL`, `URL_INTERNAL`, app-chosen connected-project config values, and `TRACE_*` env vars for the environment surface
512
+ - `PORT`, `ENV_*`, `URL`, `URL_INTERNAL`, app-chosen connected-project config values, `TRACE_*`, and `ENABLE_PROFILER` env vars for the environment surface
504
513
  - `server/index.ts` for the explicit root service graph
505
514
  - `.proteum/manifest.json` for machine-readable app structure
506
515
  - `proteum explain --json` for structured framework introspection
@@ -516,7 +525,7 @@ Proteum answers those questions with explicit artifacts:
516
525
  If you are an LLM or automation agent, start here:
517
526
 
518
527
  1. Read `identity.config.ts` and `proteum.config.ts`.
519
- 2. Read `PORT`, the relevant `ENV_*`, `URL`, `URL_INTERNAL`, any env values referenced by `proteum.config.ts`, and `TRACE_*` env vars, or run `proteum explain env`.
528
+ 2. Read `PORT`, the relevant `ENV_*`, `URL`, `URL_INTERNAL`, any env values referenced by `proteum.config.ts`, plus `TRACE_*` and `ENABLE_PROFILER`, or run `proteum explain env`.
520
529
  3. Inspect `server/index.ts` and `server/config/*.ts` for the explicit app bootstrap.
521
530
  4. Read `.proteum/manifest.json` or run `proteum explain --json`.
522
531
  5. Inspect `server/controllers/**` for request entrypoints.
@@ -17,12 +17,14 @@ Coding style source of truth: root-level `CODING_STYLE.md`.
17
17
  - Run `npx proteum refresh`.
18
18
  - Read and acknowledge the applicable `AGENTS.md` files.
19
19
  - Run `npm i`.
20
- - Run the dev server with the task-safe elevated-permissions launch workflow from `Task Lifecycle`, and keep it running so user can see the results by himself.
21
- - If the user pastes raw errors without asking for a fix, do not implement changes. List likely causes and, for each one, give probability, why, and how to fix it.
20
+ - Run the dev server with the task-safe elevated-permissions launch workflow from `Task Lifecycle`, keep it running so user can see the results by himself, and print the live server URL as a clickable Markdown link.
21
+ - If the user pastes raw errors without asking for a fix, do not implement changes. List likely causes and, for each one, give probability, why, and how to fix it. After this, everytime you implemented a fix
22
+ - test, re-run analysis and give a comparizon table of before and after
23
+ - re-print the complete list of suggested fixes, but strike the ones we already implemented or not necessary anymore
22
24
  - If the task is ambiguous, generated, connected, or multi-repo, start with `npx proteum orient <query>` before reading large parts of the codebase.
23
25
  - If the user reports an issue, or the agent encounters one during exploration, implementation, verification, or runtime reproduction, load and follow root-level `diagnostics.md`.
24
26
  - If the task touches client-side files, especially `client/**` and page files, load and apply root-level `optimizations.md` only after implementation for post-implementation checking and optimization. Skip it at task start and skip it for server-only, test-only, doc-only, and non-client refactor tasks unless the user explicitly asks for optimization work.
25
- - If the task changes UX, copy, onboarding, pricing, product semantics, or commercial positioning, read the relevant files under `./docs/` first, especially `docs/PERSONAS.md`, `docs/PRODUCT.md`, and `docs/MARKETING.md` when they exist. If a dev server is already running, print the dev server URL.
27
+ - If the task changes UX, copy, onboarding, pricing, product semantics, or commercial positioning, read the relevant files under `./docs/` first, especially `docs/PERSONAS.md`, `docs/PRODUCT.md`, and `docs/MARKETING.md` when they exist. If a dev server is already running, print the live dev server URL as a clickable Markdown link.
26
28
  - If the task needs new app or artifact boilerplate, prefer `npx proteum init ...` and `npx proteum create ...` before creating files by hand. Use `--dry-run --json` when an agent needs a machine-readable plan before writing files.
27
29
  - If you changed `schema.prisma`, do not start testing or validation yet. Ask the user to run the following command in the affected worktree directory, replacing the placeholders, and wait for the user to reply exactly `continue` before resuming validation or tests:
28
30
  ```
@@ -51,7 +53,7 @@ Coding style source of truth: root-level `CODING_STYLE.md`.
51
53
  ### During Implementation
52
54
 
53
55
  - After running `npx proteum create ...`, adapt the generated code to the real feature instead of leaving placeholder logic in place.
54
- - When starting a long-lived dev server for an agent task, always request elevated permissions and run `npx proteum dev` outside the sandbox. Use an explicit task/thread-scoped session file such as `var/run/proteum/dev/agents/<task>.json`, inspect `npx proteum dev list --json` plus current listeners first, for example with `lsof -nP -iTCP -sTCP:LISTEN`, then choose a port that is not currently used before starting `npx proteum dev --session-file <path> --port <port>`.
56
+ - When starting a long-lived dev server for an agent task, always request elevated permissions and run `npx proteum dev` outside the sandbox. Use an explicit task/thread-scoped session file such as `var/run/proteum/dev/agents/<task>.json`, inspect `npx proteum dev list --json` plus current listeners first, for example with `lsof -nP -iTCP -sTCP:LISTEN`, then choose a port that is not currently used before starting `npx proteum dev --session-file <path> --port <port>`. After the server is ready, print the live server URL as a clickable Markdown link.
55
57
  - Use `--replace-existing` only when restarting the exact session file started by the current thread/task. Never replace another live session that belongs to a user, another thread, or an unknown owner.
56
58
  - If the current app depends on local `file:` connected projects, boot every connected producer app too, each with its own task-scoped session file and free port, and run every one of those `proteum dev` processes with elevated permissions outside the sandbox before starting or verifying the consumer app.
57
59
  - For raw browser automation, use `npx proteum verify browser` when it matches the task, or direct Playwright with a disposable profile when lower-level control is required. Bootstrap protected browser state through `npx proteum session`.
@@ -60,7 +62,7 @@ Coding style source of truth: root-level `CODING_STYLE.md`.
60
62
  ### Before Finishing
61
63
 
62
64
  - Before finishing, re-check touched files against root-level `CODING_STYLE.md` and any narrower area `AGENTS.md` that applied to the edit. Re-check against root-level `optimizations.md` only for touched client-side files. Re-check against root-level `diagnostics.md` only if the task involved an issue, diagnosis, runtime reproduction, or verification failure.
63
- - Do not default to project-wide typecheck, `npx proteum check`, or Playwright after every change. Run them only when the user asks for them, when the changed surface specifically requires them, or when a real issue discovered during verification justifies escalation.
65
+ - Do not default to project-wide typecheck or `npx proteum check` after every change. After implementing a new feature or changing existing feature behavior, always update the end-to-end coverage for that behavior and run the full Playwright test suite before finishing. For docs-only, wording-only, type-only, test-only, generated-output cleanup, or clearly local non-runtime refactors, skip Playwright unless the user explicitly asks for it or verification reveals a real issue.
64
66
  - Before finishing a task, stop every `proteum dev` session started during the task and confirm cleanup with `npx proteum dev list --json` or an explicit `npx proteum dev stop --session-file <path>`.
65
67
  - When you have finished your work, ask the user whether they want a commit message. After providing a commit message or after creating a commit, immediately follow it with this exact prompt and obey it:
66
68
  `Explain in short minimalistic and few bullet points what we changed in this thread, like you would do to your grandma. Start with a verb in the past.`
@@ -96,6 +98,7 @@ Coding style source of truth: root-level `CODING_STYLE.md`.
96
98
  - Root services are public class fields instantiated with `new ServiceClass(this, config, this)`.
97
99
  - Typed root-service config lives in `server/config/*.ts` via `Services.config(ServiceClass, { ... })`.
98
100
  - Router plugins are instantiated explicitly inside the `Router` config `plugins` object.
101
+ - Router plugins can subscribe to `request` and `request.finished`; `request.profiling` exists before `request` runs and carries the finalized request/API/SQL snapshot by `request.finished`.
99
102
  - Root business services live in `server/services/<Feature>/index.ts`.
100
103
  - Root-service config lives in `server/config/*.ts` when the service needs config.
101
104
  - Business logic lives in classes that extend `Service` and use `this.services`, `this.models`, and `this.app`.
@@ -174,6 +177,7 @@ Verify at the correct layer:
174
177
  - Controller changes: exercise the generated client call or generated `/api/...` endpoint.
175
178
  - SSR changes: load the real page and inspect rendered HTML plus browser console.
176
179
  - Router or plugin changes: verify request context, auth, redirects, metrics, and validation on a running app.
180
+ - New features or feature-behavior changes: use the cheapest trustworthy verification while iterating, then update the relevant end-to-end coverage and finish by running the full Playwright suite.
177
181
  - Generated, connected, or ownership-ambiguous changes: start with `npx proteum orient <query>` and prefer `npx proteum verify owner <query>` before broad global checks.
178
182
  - Browser-visible issues: prefer `npx proteum verify browser <path>` or the narrowest targeted Playwright pass only after request-level verification is insufficient.
179
183
  - Raw browser execution beyond `npx proteum verify browser`: use direct Playwright with a disposable profile, and keep that step for the final verifier agent unless a narrower surface cannot reproduce the issue.
@@ -238,7 +242,7 @@ Proteum reads:
238
242
  - `package.json`
239
243
  - `identity.config.ts` for app identity via `Application.identity({ ... })`
240
244
  - `proteum.config.ts` for compiler setup via `Application.setup({ transpile, connect })`
241
- - `process.env` via `PORT`, `ENV_*`, `URL`, `URL_INTERNAL`, any app-chosen connected-project values referenced by `proteum.config.ts`, and `TRACE_*`
245
+ - `process.env` via `PORT`, `ENV_*`, `URL`, `URL_INTERNAL`, any app-chosen connected-project values referenced by `proteum.config.ts`, `TRACE_*`, and `ENABLE_PROFILER`
242
246
  - `server/config/*.ts`
243
247
  - `server/index.ts`
244
248
  - `commands/**/*.ts`
@@ -298,6 +302,6 @@ Prefer scaffold commands before hand-writing boilerplate:
298
302
  Edit these only when required, and keep changes minimal and explicit:
299
303
 
300
304
  - `tsconfig*.json`
301
- - `PORT`, `ENV_*`, `URL`, and `TRACE_*` env setup
305
+ - `PORT`, `ENV_*`, `URL`, `TRACE_*`, and `ENABLE_PROFILER` env setup
302
306
  - Prisma-generated files
303
307
  - symbolic links
@@ -12,5 +12,5 @@ Do not put here: reusable Proteum architecture contracts, shared verification ru
12
12
  - Run `npx proteum refresh`.
13
13
  - Read and acknowledge the applicable `AGENTS.md` files.
14
14
  - Run `npm i`.
15
- - Run the dev server with the task-safe elevated-permissions launch workflow from the reusable root `AGENTS.md`, and keep it running so user can see the results by himself.
16
- - If the task changes UX, copy, onboarding, pricing, product semantics, or commercial positioning, read the relevant files under `./docs/` first, especially `docs/PERSONAS.md`, `docs/PRODUCT.md`, and `docs/MARKETING.md` when they exist. If a dev server is already running, print the dev server URL.
15
+ - Run the dev server with the task-safe elevated-permissions launch workflow from the reusable root `AGENTS.md`, keep it running so user can see the results by himself, and print the live server URL as a clickable Markdown link.
16
+ - If the task changes UX, copy, onboarding, pricing, product semantics, or commercial positioning, read the relevant files under `./docs/` first, especially `docs/PERSONAS.md`, `docs/PRODUCT.md`, and `docs/MARKETING.md` when they exist. If a dev server is already running, print the live dev server URL as a clickable Markdown link.
@@ -12,7 +12,7 @@ This file is the canonical source of truth for diagnostics, temporary instrument
12
12
 
13
13
  ## Runtime Diagnostics
14
14
 
15
- - For long-lived dev reproductions, always request elevated permissions and run `npx proteum dev` outside the sandbox. Use an explicit task/thread-scoped session file, inspect `npx proteum dev list --json` plus current listeners first, for example with `lsof -nP -iTCP -sTCP:LISTEN`, then choose a port that is not currently used before starting `npx proteum dev --session-file <path> --port <port>`.
15
+ - For long-lived dev reproductions, always request elevated permissions and run `npx proteum dev` outside the sandbox. Use an explicit task/thread-scoped session file, inspect `npx proteum dev list --json` plus current listeners first, for example with `lsof -nP -iTCP -sTCP:LISTEN`, then choose a port that is not currently used before starting `npx proteum dev --session-file <path> --port <port>`. After the server is ready, print the live server URL as a clickable Markdown link.
16
16
  - Use `--replace-existing` only when restarting the exact session file started by the current thread/task. Never replace another live session that belongs to a user, another thread, or an unknown owner.
17
17
  - Only the bare `npx proteum build` and bare `npx proteum dev` commands print the welcome banner and active Proteum installation method. Any extra argument or option skips the banner. Only `npx proteum dev` clears the interactive terminal before rendering and reports connected app names plus successful connected `/ping` checks in the ready banner; keep that in mind when capturing or comparing command logs during diagnosis.
18
18
  - For ownership or repo discovery questions, start with `npx proteum orient <query>` instead of jumping straight into source searches.
@@ -43,7 +43,7 @@ Coding style source of truth: root-level `CODING_STYLE.md`.
43
43
  ### During Implementation
44
44
 
45
45
  - After running `npx proteum create ...`, adapt the generated code to the real feature instead of leaving placeholder logic in place.
46
- - When starting a long-lived dev server for an agent task, always request elevated permissions and run `npx proteum dev` outside the sandbox. Use an explicit task/thread-scoped session file such as `var/run/proteum/dev/agents/<task>.json`, inspect `npx proteum dev list --json` plus current listeners first, for example with `lsof -nP -iTCP -sTCP:LISTEN`, then choose a port that is not currently used before starting `npx proteum dev --session-file <path> --port <port>`.
46
+ - When starting a long-lived dev server for an agent task, always request elevated permissions and run `npx proteum dev` outside the sandbox. Use an explicit task/thread-scoped session file such as `var/run/proteum/dev/agents/<task>.json`, inspect `npx proteum dev list --json` plus current listeners first, for example with `lsof -nP -iTCP -sTCP:LISTEN`, then choose a port that is not currently used before starting `npx proteum dev --session-file <path> --port <port>`. After the server is ready, print the live server URL as a clickable Markdown link.
47
47
  - Use `--replace-existing` only when restarting the exact session file started by the current thread/task. Never replace another live session that belongs to a user, another thread, or an unknown owner.
48
48
  - If the current app depends on local `file:` connected projects, boot every connected producer app too, each with its own task-scoped session file and free port, and run every one of those `proteum dev` processes with elevated permissions outside the sandbox before starting or verifying the consumer app.
49
49
  - For raw browser automation, use `npx proteum verify browser` when it matches the task, or direct Playwright with a disposable profile when lower-level control is required. Bootstrap protected browser state through `npx proteum session`.
@@ -52,7 +52,7 @@ Coding style source of truth: root-level `CODING_STYLE.md`.
52
52
  ### Before Finishing
53
53
 
54
54
  - Before finishing, re-check touched files against root-level `CODING_STYLE.md` and any narrower area `AGENTS.md` that applied to the edit. Re-check against root-level `optimizations.md` only for touched client-side files. Re-check against root-level `diagnostics.md` only if the task involved an issue, diagnosis, runtime reproduction, or verification failure.
55
- - Do not default to project-wide typecheck, `npx proteum check`, or Playwright after every change. Run them only when the user asks for them, when the changed surface specifically requires them, or when a real issue discovered during verification justifies escalation.
55
+ - Do not default to project-wide typecheck or `npx proteum check` after every change. After implementing a new feature or changing existing feature behavior, always update the end-to-end coverage for that behavior and run the full Playwright test suite before finishing. For docs-only, wording-only, type-only, test-only, generated-output cleanup, or clearly local non-runtime refactors, skip Playwright unless the user explicitly asks for it or verification reveals a real issue.
56
56
  - Before finishing a task, stop every `proteum dev` session started during the task and confirm cleanup with `npx proteum dev list --json` or an explicit `npx proteum dev stop --session-file <path>`.
57
57
  - When you have finished your work, ask the user whether they want a commit message. After providing a commit message or after creating a commit, immediately follow it with this exact prompt and obey it:
58
58
  `Explain in short minimalistic and few bullet points what we changed in this thread, like you would do to your grandma. Start with a verb in the past.`
@@ -88,6 +88,7 @@ Coding style source of truth: root-level `CODING_STYLE.md`.
88
88
  - Root services are public class fields instantiated with `new ServiceClass(this, config, this)`.
89
89
  - Typed root-service config lives in `server/config/*.ts` via `Services.config(ServiceClass, { ... })`.
90
90
  - Router plugins are instantiated explicitly inside the `Router` config `plugins` object.
91
+ - Router plugins can subscribe to `request` and `request.finished`; `request.profiling` exists before `request` runs and carries the finalized request/API/SQL snapshot by `request.finished`.
91
92
  - Root business services live in `server/services/<Feature>/index.ts`.
92
93
  - Root-service config lives in `server/config/*.ts` when the service needs config.
93
94
  - Business logic lives in classes that extend `Service` and use `this.services`, `this.models`, and `this.app`.
@@ -166,6 +167,7 @@ Verify at the correct layer:
166
167
  - Controller changes: exercise the generated client call or generated `/api/...` endpoint.
167
168
  - SSR changes: load the real page and inspect rendered HTML plus browser console.
168
169
  - Router or plugin changes: verify request context, auth, redirects, metrics, and validation on a running app.
170
+ - New features or feature-behavior changes: use the cheapest trustworthy verification while iterating, then update the relevant end-to-end coverage and finish by running the full Playwright suite.
169
171
  - Generated, connected, or ownership-ambiguous changes: start with `npx proteum orient <query>` and prefer `npx proteum verify owner <query>` before broad global checks.
170
172
  - Browser-visible issues: prefer `npx proteum verify browser <path>` or the narrowest targeted Playwright pass only after request-level verification is insufficient.
171
173
  - Raw browser execution beyond `npx proteum verify browser`: use direct Playwright with a disposable profile, and keep that step for the final verifier agent unless a narrower surface cannot reproduce the issue.
@@ -230,7 +232,7 @@ Proteum reads:
230
232
  - `package.json`
231
233
  - `identity.config.ts` for app identity via `Application.identity({ ... })`
232
234
  - `proteum.config.ts` for compiler setup via `Application.setup({ transpile, connect })`
233
- - `process.env` via `PORT`, `ENV_*`, `URL`, `URL_INTERNAL`, any app-chosen connected-project values referenced by `proteum.config.ts`, and `TRACE_*`
235
+ - `process.env` via `PORT`, `ENV_*`, `URL`, `URL_INTERNAL`, any app-chosen connected-project values referenced by `proteum.config.ts`, `TRACE_*`, and `ENABLE_PROFILER`
234
236
  - `server/config/*.ts`
235
237
  - `server/index.ts`
236
238
  - `commands/**/*.ts`
@@ -290,6 +292,6 @@ Prefer scaffold commands before hand-writing boilerplate:
290
292
  Edit these only when required, and keep changes minimal and explicit:
291
293
 
292
294
  - `tsconfig*.json`
293
- - `PORT`, `ENV_*`, `URL`, and `TRACE_*` env setup
295
+ - `PORT`, `ENV_*`, `URL`, `TRACE_*`, and `ENABLE_PROFILER` env setup
294
296
  - Prisma-generated files
295
297
  - symbolic links
@@ -10,9 +10,16 @@ Diagnostics source of truth: root-level `diagnostics.md`.
10
10
  - Understand the real user flow and the main feature branches before writing tests.
11
11
  - Test the current controller/page runtime model, not legacy `@Route` or `api.fetch(...)` behavior.
12
12
  - Verify routing, controllers, SSR, and router plugins against a running app when behavior depends on real request handling.
13
- - After implementing a browser-visible feature or change, prefer a real browser repro against a running app first. Add targeted Playwright coverage only when the user asks for automated coverage, when a stable regression path needs automation, or when manual/browser verification is insufficient.
13
+ - After implementing a new feature or changing existing feature behavior, update the end-to-end coverage for that behavior and run the full Playwright suite before finishing. Use a real browser repro against a running app during iteration when it is the fastest trustworthy loop.
14
14
  - Exercise real URLs, generated controller calls, or real browser flows instead of re-deriving framework internals in tests.
15
+ - Organize end-to-end tests following the Crosspath platform layout under `tests/e2e/**`.
16
+ - Put runnable scenario entrypoints in `tests/e2e/features/**`, `tests/e2e/specs/<domain>/**`, or `tests/e2e/journeys/**` depending on scope.
17
+ - Put page objects and reusable UI surface wrappers in `tests/e2e/pages/**`.
18
+ - Put reusable multi-step user flows in `tests/e2e/workflows/**`.
19
+ - Put test data builders in `tests/e2e/factories/**` and generic helpers in `tests/e2e/utils/**`.
20
+ - Keep helpers out of spec files when they are reusable, and do not create ad hoc flat test files or duplicate support abstractions when an existing page, workflow, factory, or utility already fits.
15
21
  - Locate elements with `data-testid`.
16
22
  - Add `data-testid` where needed instead of relying on brittle selectors.
23
+ - Keep end-to-end tests clean, well organized, and non-redundant. Prefer extending or reshaping the most relevant existing scenario over duplicating coverage, and remove or consolidate overlap when the suite becomes repetitive.
17
24
  - Reuse root catalog files from `/client/catalogs/**`, `/server/catalogs/**`, or `/common/catalogs/**` instead of duplicating catalog constants in tests.
18
25
  - For protected dev flows, prefer `npx proteum session <email> --role <role>` over automating login unless the login flow itself is under test.
@@ -114,6 +114,7 @@ export type TTraceSqlQuery = {
114
114
  model?: string;
115
115
  operation: string;
116
116
  fingerprint?: string;
117
+ normalizedQuery?: string;
117
118
  ownerLabel?: string;
118
119
  ownerFilepath?: string;
119
120
  serviceLabel?: string;
@@ -169,6 +170,71 @@ export type TRequestTrace = {
169
170
  events: TTraceEvent[];
170
171
  };
171
172
 
173
+ export type TRequestProfilingApiCall = {
174
+ id: string;
175
+ origin: TTraceCallOrigin;
176
+ label: string;
177
+ method: string;
178
+ path: string;
179
+ fetcherId?: string;
180
+ connectedProjectNamespace?: string;
181
+ connectedControllerAccessor?: string;
182
+ ownerLabel?: string;
183
+ ownerFilepath?: string;
184
+ serviceLabel?: string;
185
+ startedAt: string;
186
+ finishedAt?: string;
187
+ durationMs?: number;
188
+ statusCode?: number;
189
+ errorMessage?: string;
190
+ requestBodyJson?: unknown;
191
+ responseBodyJson?: unknown;
192
+ };
193
+
194
+ export type TRequestProfilingSqlQuery = {
195
+ id: string;
196
+ callerCallId?: string;
197
+ callerFetcherId?: string;
198
+ callerLabel?: string;
199
+ callerMethod: string;
200
+ callerOrigin: TTraceSqlQueryCallerOrigin;
201
+ callerPath: string;
202
+ durationMs: number;
203
+ finishedAt: string;
204
+ kind: TTraceSqlQueryKind;
205
+ model?: string;
206
+ operation: string;
207
+ fingerprint?: string;
208
+ normalizedQuery?: string;
209
+ ownerLabel?: string;
210
+ ownerFilepath?: string;
211
+ serviceLabel?: string;
212
+ connectedNamespace?: string;
213
+ paramsJson?: unknown;
214
+ paramsText?: string;
215
+ query: string;
216
+ startedAt: string;
217
+ target?: string;
218
+ };
219
+
220
+ export type TRequestProfiling = {
221
+ enabled: boolean;
222
+ requestId: string;
223
+ method: string;
224
+ path: string;
225
+ url: string;
226
+ startedAt: string;
227
+ finishedAt?: string;
228
+ durationMs?: number;
229
+ statusCode?: number;
230
+ user?: string;
231
+ errorMessage?: string;
232
+ profilerOrigin?: string;
233
+ profilerParentRequestId?: string;
234
+ apiCalls: TRequestProfilingApiCall[];
235
+ sqlQueries: TRequestProfilingSqlQuery[];
236
+ };
237
+
172
238
  export type TRequestTraceListItem = Omit<TRequestTrace, 'events' | 'calls' | 'sqlQueries'> & {
173
239
  eventCount: number;
174
240
  callCount: number;
@@ -36,6 +36,7 @@ export type TProteumEnvConfig = {
36
36
  connectedProjects: Record<string, TProteumConnectedProjectEnvConfig>;
37
37
  trace: {
38
38
  enable: boolean;
39
+ profilerEnable: boolean;
39
40
  requestsLimit: number;
40
41
  eventsLimit: number;
41
42
  capture: TProteumTraceCapture;
@@ -65,7 +66,7 @@ const isProvidedEnvValue = (value: string | undefined) => typeof value === 'stri
65
66
 
66
67
  const buildRequiredEnvVariableDefinitions = (_connectedProjects: TConnectedProjectsConfig) => [...baseRequiredProteumEnvVariableDefinitions];
67
68
 
68
- const buildOptionalEnvKeys = (_connectedProjects: TConnectedProjectsConfig) => [] as string[];
69
+ const buildOptionalEnvKeys = (_connectedProjects: TConnectedProjectsConfig) => ['ENABLE_PROFILER'] as string[];
69
70
 
70
71
  const formatRequiredEnvVariableStatus = (variable: TProteumRequiredEnvVariable) =>
71
72
  `- ${variable.key} possibleValues=${variable.possibleValues.join(' | ')} provided=${variable.provided ? 'yes' : 'no'}`;
@@ -269,8 +270,8 @@ export const loadOptionalProteumDotenv = (appDir: string) => {
269
270
  };
270
271
 
271
272
  export const getLoadedProteumEnvVariableKeys = (connectedProjects: TConnectedProjectsConfig = {}) => {
272
- const requiredKeys = new Set(buildRequiredEnvVariableDefinitions(connectedProjects).map((definition) => definition.key));
273
- const optionalKeys = new Set(buildOptionalEnvKeys(connectedProjects));
273
+ const requiredKeys = new Set<string>(buildRequiredEnvVariableDefinitions(connectedProjects).map((definition) => definition.key));
274
+ const optionalKeys = new Set<string>(buildOptionalEnvKeys(connectedProjects));
274
275
 
275
276
  return Object.keys(process.env)
276
277
  .filter(
@@ -338,6 +339,11 @@ export const parseProteumEnvConfig = ({
338
339
  value: process.env.TRACE_ENABLE,
339
340
  context,
340
341
  });
342
+ const profilerEnable = parseBooleanEnvValue({
343
+ key: 'ENABLE_PROFILER',
344
+ value: process.env.ENABLE_PROFILER,
345
+ context,
346
+ });
341
347
  const tracePersistOnError = parseBooleanEnvValue({
342
348
  key: 'TRACE_PERSIST_ON_ERROR',
343
349
  value: process.env.TRACE_PERSIST_ON_ERROR,
@@ -387,6 +393,7 @@ export const parseProteumEnvConfig = ({
387
393
  connectedProjects: resolvedConnectedProjects,
388
394
  trace: {
389
395
  enable: traceEnable ?? profile === 'dev',
396
+ profilerEnable: profilerEnable ?? false,
390
397
  requestsLimit:
391
398
  traceRequestsLimit === undefined || traceRequestsLimit === ''
392
399
  ? 200
@@ -1,13 +1,18 @@
1
1
  # Request Tracing
2
2
 
3
- Proteum ships with a dev-only in-memory request trace buffer so routing, controller execution, SSR, API, Prisma SQL, render behavior, and request-time performance can be inspected without attaching a debugger or scattering temporary logs through the runtime.
3
+ Proteum ships with one request-instrumentation system with two runtime shapes:
4
+
5
+ - retained dev traces for `proteum trace`, `proteum perf`, the dev-only HTTP endpoints, and the bottom profiler
6
+ - reduced request-local profiling for `request.profiling` and the router `request.finished` hook
7
+
8
+ The same API and SQL instrumentation feeds both shapes. Dev trace keeps the in-memory buffer and event timeline. Reduced profiling keeps only the finalized request/API/SQL snapshot and releases it after the `request.finished` hook runs.
4
9
 
5
10
  ## Scope
6
11
 
7
- - tracing is available only when the app runs with `profile: dev`
12
+ - retained dev tracing is available only when the app runs with `profile: dev`
8
13
  - traces are exposed through `proteum trace`, `proteum perf`, and the dev-only `__proteum/trace` and `__proteum/perf` HTTP endpoints
9
14
  - `proteum diagnose` is a separate composite surface that reads the same framework diagnostics plus one matching request trace and buffered server logs; see [diagnostics.md](diagnostics.md)
10
- - production requests are not traced by this feature
15
+ - `ENABLE_PROFILER=true` enables reduced request-local profiling in any environment, including production
11
16
 
12
17
  ## Main Commands
13
18
 
@@ -77,6 +82,13 @@ Depending on capture mode, traces can include:
77
82
  - normalized request errors
78
83
  - additive owner, service, cache, and connected-boundary metadata propagated from route/controller resolution into downstream calls and SQL
79
84
 
85
+ Reduced request-local profiling keeps the finalized request summary plus API and SQL rows only:
86
+
87
+ - `request.profiling` exists before the router `request` hook runs
88
+ - `request.profiling.apiCalls` and `request.profiling.sqlQueries` start empty and are populated during request handling
89
+ - the router `request.finished` hook receives that same object after status, duration, API calls, and SQL queries are finalized
90
+ - when only reduced profiling is enabled, finished requests are released immediately after `request.finished` instead of being retained in the global trace buffer
91
+
80
92
  ## SQL Tracing
81
93
 
82
94
  Prisma query tracing covers both ORM operations and raw queries.
@@ -140,6 +152,7 @@ export TRACE_REQUESTS_LIMIT=200
140
152
  export TRACE_EVENTS_LIMIT=800
141
153
  export TRACE_CAPTURE=resolve
142
154
  export TRACE_PERSIST_ON_ERROR=true
155
+ export ENABLE_PROFILER=true
143
156
  ```
144
157
 
145
158
  Notes:
@@ -150,6 +163,7 @@ Notes:
150
163
  - `eventsLimit` defaults to `800`
151
164
  - `proteum dev` removes auto-persisted crash traces from `var/traces/` when the dev session stops
152
165
  - explicit `proteum trace export` files under `var/traces/exports/` are left in place
166
+ - `ENABLE_PROFILER` reuses the same request instrumentation path but skips the retained global buffer and event timeline when dev trace is otherwise off
153
167
 
154
168
  ## Memory Model
155
169
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "proteum",
3
3
  "description": "LLM-first Opinionated Typescript Framework for web applications.",
4
- "version": "2.2.0",
4
+ "version": "2.2.1",
5
5
  "author": "Gaetan Le Gac (https://github.com/gaetanlegac)",
6
6
  "repository": "git://github.com/gaetanlegac/proteum.git",
7
7
  "license": "MIT",
@@ -16,6 +16,9 @@ import {
16
16
  type TTraceSqlQueryCallerOrigin,
17
17
  type TTraceSqlQueryKind,
18
18
  type TTraceSummaryValue,
19
+ type TRequestProfiling,
20
+ type TRequestProfilingApiCall,
21
+ type TRequestProfilingSqlQuery,
19
22
  type TRequestTrace,
20
23
  type TTraceMemorySnapshot,
21
24
  type TRequestTraceListItem,
@@ -24,6 +27,7 @@ import type { TProteumManifest } from '@common/dev/proteumManifest';
24
27
 
25
28
  export type Config = {
26
29
  enable: boolean;
30
+ profilerEnable: boolean;
27
31
  requestsLimit: number;
28
32
  eventsLimit: number;
29
33
  capture: TTraceCaptureMode;
@@ -32,6 +36,12 @@ export type Config = {
32
36
 
33
37
  type TTraceInspectable = object | PrimitiveValue | bigint | symbol | null | undefined | (() => void);
34
38
  type TTraceDetails = { [key: string]: TTraceInspectable };
39
+ type TSerializeJsonValueOptions = { redactSensitive: boolean };
40
+ type TActiveRequestRecord = {
41
+ profiling: TRequestProfiling;
42
+ trace?: TRequestTrace;
43
+ capture?: TTraceCaptureMode;
44
+ };
35
45
 
36
46
  const capturePriority: Record<TTraceCaptureMode, number> = { summary: 0, resolve: 1, deep: 2 };
37
47
  const sensitiveKeyPattern =
@@ -50,8 +60,13 @@ const isSensitiveKeyPath = (keyPath: string[]) => sensitiveKeyPattern.test(keyPa
50
60
  const summarizeString = (value: string) =>
51
61
  value.length <= maxStringLength ? value : `${value.slice(0, maxStringLength)}…`;
52
62
 
53
- const serializeJsonValue = (value: unknown, keyPath: string[], seen: WeakSet<object>): unknown => {
54
- if (isSensitiveKeyPath(keyPath)) return `[redacted: Sensitive key ${keyPath[keyPath.length - 1] || 'value'}]`;
63
+ const serializeJsonValue = (
64
+ value: unknown,
65
+ keyPath: string[],
66
+ seen: WeakSet<object>,
67
+ { redactSensitive }: TSerializeJsonValueOptions,
68
+ ): unknown => {
69
+ if (redactSensitive && isSensitiveKeyPath(keyPath)) return `[redacted: Sensitive key ${keyPath[keyPath.length - 1] || 'value'}]`;
55
70
  if (value === undefined || value === null) return value;
56
71
  if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value;
57
72
  if (typeof value === 'bigint') return `${value.toString()}n`;
@@ -61,11 +76,14 @@ const serializeJsonValue = (value: unknown, keyPath: string[], seen: WeakSet<obj
61
76
  if (value instanceof Date) return value.toISOString();
62
77
  if (value instanceof Error) return { name: value.name, message: value.message, stack: value.stack };
63
78
  if (Buffer.isBuffer(value)) return `[Buffer ${value.byteLength} bytes]`;
64
- if (value instanceof Map) return Array.from(value.entries()).map(([entryKey, entryValue], index) =>
65
- serializeJsonValue([entryKey, entryValue], [...keyPath, `[${index}]`], seen),
66
- );
79
+ if (value instanceof Map)
80
+ return Array.from(value.entries()).map(([entryKey, entryValue], index) =>
81
+ serializeJsonValue([entryKey, entryValue], [...keyPath, `[${index}]`], seen, { redactSensitive }),
82
+ );
67
83
  if (value instanceof Set) {
68
- return Array.from(value.values()).map((entryValue, index) => serializeJsonValue(entryValue, [...keyPath, `[${index}]`], seen));
84
+ return Array.from(value.values()).map((entryValue, index) =>
85
+ serializeJsonValue(entryValue, [...keyPath, `[${index}]`], seen, { redactSensitive }),
86
+ );
69
87
  }
70
88
 
71
89
  if (typeof value !== 'object') return String(value);
@@ -74,19 +92,22 @@ const serializeJsonValue = (value: unknown, keyPath: string[], seen: WeakSet<obj
74
92
  seen.add(value);
75
93
 
76
94
  if (Array.isArray(value)) {
77
- return value.map((item, index) => serializeJsonValue(item, [...keyPath, `[${index}]`], seen));
95
+ return value.map((item, index) => serializeJsonValue(item, [...keyPath, `[${index}]`], seen, { redactSensitive }));
78
96
  }
79
97
 
80
98
  const serialized: Record<string, unknown> = {};
81
99
  for (const [entryKey, entryValue] of Object.entries(value)) {
82
- const nextValue = serializeJsonValue(entryValue, [...keyPath, entryKey], seen);
100
+ const nextValue = serializeJsonValue(entryValue, [...keyPath, entryKey], seen, { redactSensitive });
83
101
  if (nextValue !== undefined) serialized[entryKey] = nextValue;
84
102
  }
85
103
 
86
104
  return serialized;
87
105
  };
88
106
 
89
- const serializeCaptureValue = (value: TTraceInspectable, key: string) => serializeJsonValue(value, [key], new WeakSet<object>());
107
+ const serializeCaptureValue = (value: TTraceInspectable, key: string) =>
108
+ serializeJsonValue(value, [key], new WeakSet<object>(), { redactSensitive: true });
109
+ const serializeRawCaptureValue = (value: TTraceInspectable, key: string) =>
110
+ serializeJsonValue(value, [key], new WeakSet<object>(), { redactSensitive: false });
90
111
 
91
112
  const summarizeError = (error: Error): TTraceSummaryValue => ({
92
113
  kind: 'error',
@@ -181,7 +202,7 @@ const snapshotMemory = (): TTraceMemorySnapshot => {
181
202
  };
182
203
 
183
204
  export default class Trace {
184
- private requests = new Map<string, TRequestTrace>();
205
+ private requests = new Map<string, TActiveRequestRecord>();
185
206
  private order: string[] = [];
186
207
  private armedCapture?: TTraceCaptureMode;
187
208
  private manifestCache?: {
@@ -202,10 +223,18 @@ export default class Trace {
202
223
  private config: Config,
203
224
  ) {}
204
225
 
205
- public isEnabled() {
226
+ public isDevTraceEnabled() {
206
227
  return __DEV__ && this.config.enable && this.container.Environment.profile === 'dev';
207
228
  }
208
229
 
230
+ public isProfilingEnabled() {
231
+ return this.config.profilerEnable;
232
+ }
233
+
234
+ public shouldInstrumentRequests() {
235
+ return this.isDevTraceEnabled() || this.isProfilingEnabled();
236
+ }
237
+
209
238
  private getContextChannel() {
210
239
  return context.getStore() as ChannelInfos | undefined;
211
240
  }
@@ -270,8 +299,8 @@ export default class Trace {
270
299
  return undefined;
271
300
  }
272
301
 
273
- private createSqlFingerprint(query: string) {
274
- const normalized = query
302
+ private normalizeSqlQuery(query: string) {
303
+ return query
275
304
  .replace(sqlCommentPattern, ' ')
276
305
  .replace(sqlLineCommentPattern, ' ')
277
306
  .replace(/'([^']|'')*'/g, '?')
@@ -280,12 +309,43 @@ export default class Trace {
280
309
  .replace(/\s+/g, ' ')
281
310
  .trim()
282
311
  .toUpperCase();
312
+ }
313
+
314
+ private createSqlFingerprint(query: string) {
315
+ const normalized = this.normalizeSqlQuery(query);
283
316
 
284
317
  if (!normalized) return undefined;
285
318
 
286
319
  return createHash('sha1').update(normalized).digest('hex').slice(0, 12);
287
320
  }
288
321
 
322
+ private createProfiling(input: {
323
+ enabled: boolean;
324
+ id: string;
325
+ method: string;
326
+ path: string;
327
+ url: string;
328
+ profilerOrigin?: string;
329
+ profilerParentRequestId?: string;
330
+ }): TRequestProfiling {
331
+ return {
332
+ enabled: input.enabled,
333
+ requestId: input.id,
334
+ method: input.method,
335
+ path: input.path,
336
+ url: input.url,
337
+ startedAt: nowIso(),
338
+ profilerOrigin: input.profilerOrigin,
339
+ profilerParentRequestId: input.profilerParentRequestId,
340
+ apiCalls: [],
341
+ sqlQueries: [],
342
+ };
343
+ }
344
+
345
+ private getRecord(requestId: string) {
346
+ return this.requests.get(requestId);
347
+ }
348
+
289
349
  public armNextRequest(capture: string) {
290
350
  if (!isTraceCaptureMode(capture)) {
291
351
  throw new Error(`Unsupported trace capture mode "${capture}". Expected one of: ${traceCaptureModes.join(', ')}.`);
@@ -306,46 +366,72 @@ export default class Trace {
306
366
  profilerOrigin?: string;
307
367
  profilerParentRequestId?: string;
308
368
  }) {
309
- if (!this.isEnabled()) return;
310
-
311
- const capture = this.armedCapture ?? this.config.capture;
312
- this.armedCapture = undefined;
313
-
314
- const trace: TRequestTrace = {
369
+ const profilingEnabled = this.shouldInstrumentRequests();
370
+ const profiling = this.createProfiling({
371
+ enabled: profilingEnabled,
315
372
  id: input.id,
316
373
  method: input.method,
317
374
  path: input.path,
318
375
  url: input.url,
319
- capture,
320
- profilerSessionId: input.profilerSessionId,
321
376
  profilerOrigin: input.profilerOrigin,
322
377
  profilerParentRequestId: input.profilerParentRequestId,
323
- startedAt: nowIso(),
324
- droppedEvents: 0,
325
- requestDataJson: serializeCaptureValue(input.data, 'requestData'),
326
- calls: [],
327
- sqlQueries: [],
328
- events: [],
329
- };
378
+ });
379
+ if (!profilingEnabled) return profiling;
380
+
381
+ const traceEnabled = this.isDevTraceEnabled();
382
+ const capture = traceEnabled ? this.armedCapture ?? this.config.capture : undefined;
383
+ this.armedCapture = undefined;
384
+
385
+ const trace =
386
+ traceEnabled
387
+ ? ({
388
+ id: input.id,
389
+ method: input.method,
390
+ path: input.path,
391
+ url: input.url,
392
+ capture: capture as TTraceCaptureMode,
393
+ profilerSessionId: input.profilerSessionId,
394
+ profilerOrigin: input.profilerOrigin,
395
+ profilerParentRequestId: input.profilerParentRequestId,
396
+ startedAt: profiling.startedAt,
397
+ droppedEvents: 0,
398
+ requestDataJson: serializeCaptureValue(input.data, 'requestData'),
399
+ calls: [],
400
+ sqlQueries: [],
401
+ events: [],
402
+ } satisfies TRequestTrace)
403
+ : undefined;
404
+
405
+ this.requests.set(input.id, {
406
+ profiling,
407
+ trace,
408
+ capture,
409
+ });
410
+ this.activeMeasurements.set(input.id, { cpu: process.cpuUsage(), memory: snapshotMemory() });
330
411
 
331
- this.requests.set(trace.id, trace);
332
- this.activeMeasurements.set(trace.id, { cpu: process.cpuUsage(), memory: snapshotMemory() });
333
- this.order.push(trace.id);
334
- this.trimRequestBuffer();
412
+ if (trace) {
413
+ this.order.push(input.id);
414
+ this.trimRequestBuffer();
415
+ this.record(input.id, 'request.start', { method: input.method, path: input.path, url: input.url, headers: input.headers, data: input.data });
416
+ }
335
417
 
336
- this.record(trace.id, 'request.start', { method: input.method, path: input.path, url: input.url, headers: input.headers, data: input.data });
418
+ return profiling;
337
419
  }
338
420
 
339
421
  public setRequestUser(requestId: string, user?: string) {
340
- const trace = this.requests.get(requestId);
341
- if (!trace) return;
422
+ const record = this.getRecord(requestId);
423
+ if (!record) return;
424
+
425
+ record.profiling.user = user;
426
+ if (!record.trace) return;
342
427
 
428
+ const trace = record.trace;
343
429
  trace.user = user;
344
430
  if (user) this.record(requestId, 'request.user', { user });
345
431
  }
346
432
 
347
433
  public getCapture(requestId: string) {
348
- return this.requests.get(requestId)?.capture;
434
+ return this.getRecord(requestId)?.capture;
349
435
  }
350
436
 
351
437
  public shouldCapture(requestId: string, minimumCapture: TTraceCaptureMode) {
@@ -356,7 +442,8 @@ export default class Trace {
356
442
  }
357
443
 
358
444
  public record(requestId: string, type: TTraceEventType, details: TTraceDetails, minimumCapture: TTraceCaptureMode = 'summary') {
359
- const trace = this.requests.get(requestId);
445
+ const record = this.getRecord(requestId);
446
+ const trace = record?.trace;
360
447
  if (!trace || !this.shouldCapture(requestId, minimumCapture)) return;
361
448
 
362
449
  if (trace.events.length >= this.config.eventsLimit) {
@@ -376,28 +463,41 @@ export default class Trace {
376
463
  }
377
464
 
378
465
  public finishRequest(requestId: string, output: { statusCode: number; user?: string; errorMessage?: string }) {
379
- const trace = this.requests.get(requestId);
380
- if (!trace) return;
466
+ const record = this.getRecord(requestId);
467
+ if (!record) return;
468
+
469
+ const { profiling, trace } = record;
470
+ if (output.user) profiling.user = output.user;
471
+ profiling.statusCode = output.statusCode;
472
+ profiling.errorMessage = output.errorMessage;
381
473
 
382
- if (output.user) trace.user = output.user;
383
- trace.statusCode = output.statusCode;
384
- trace.errorMessage = output.errorMessage;
385
474
  const measurement = this.activeMeasurements.get(requestId);
386
475
  if (measurement) {
387
- const cpu = process.cpuUsage(measurement.cpu);
388
- trace.performance = {
389
- cpu: {
390
- systemMicros: cpu.system,
391
- userMicros: cpu.user,
392
- },
393
- memory: {
394
- after: snapshotMemory(),
395
- before: measurement.memory,
396
- },
397
- };
398
476
  this.activeMeasurements.delete(requestId);
477
+
478
+ if (trace) {
479
+ const cpu = process.cpuUsage(measurement.cpu);
480
+ trace.performance = {
481
+ cpu: {
482
+ systemMicros: cpu.system,
483
+ userMicros: cpu.user,
484
+ },
485
+ memory: {
486
+ after: snapshotMemory(),
487
+ before: measurement.memory,
488
+ },
489
+ };
490
+ }
399
491
  }
400
492
 
493
+ profiling.finishedAt = nowIso();
494
+ profiling.durationMs = Math.max(0, Date.parse(profiling.finishedAt) - Date.parse(profiling.startedAt));
495
+
496
+ if (!trace) return;
497
+
498
+ if (output.user) trace.user = output.user;
499
+ trace.statusCode = output.statusCode;
500
+ trace.errorMessage = output.errorMessage;
401
501
  this.record(
402
502
  requestId,
403
503
  'request.finish',
@@ -405,8 +505,8 @@ export default class Trace {
405
505
  'summary',
406
506
  );
407
507
 
408
- trace.finishedAt = nowIso();
409
- trace.durationMs = Math.max(0, Date.parse(trace.finishedAt) - Date.parse(trace.startedAt));
508
+ trace.finishedAt = profiling.finishedAt;
509
+ trace.durationMs = profiling.durationMs;
410
510
 
411
511
  if (this.config.persistOnError && trace.statusCode >= 500) {
412
512
  trace.persistedFilepath = this.exportRequest(requestId);
@@ -433,13 +533,37 @@ export default class Trace {
433
533
  requestData?: TTraceInspectable;
434
534
  },
435
535
  ) {
436
- const trace = this.requests.get(requestId);
437
- if (!trace) return undefined;
536
+ const record = this.getRecord(requestId);
537
+ if (!record) return undefined;
438
538
  const channel = this.getContextChannel();
439
539
  const inferredServiceLabel = input.serviceLabel || channel?.serviceLabel || this.inferServiceLabelFromStack(new Error().stack);
540
+ const callIndex = record.profiling.apiCalls.length;
541
+ const startedAt = nowIso();
542
+ const callId = `${requestId}:call:${callIndex}`;
543
+
544
+ const profilingCall: TRequestProfilingApiCall = {
545
+ id: callId,
546
+ origin: input.origin,
547
+ label: input.label,
548
+ method: input.method || '',
549
+ path: input.path || '',
550
+ fetcherId: input.fetcherId,
551
+ connectedProjectNamespace: input.connectedProjectNamespace,
552
+ connectedControllerAccessor: input.connectedControllerAccessor,
553
+ ownerLabel: input.ownerLabel || channel?.ownerLabel,
554
+ ownerFilepath: input.ownerFilepath || channel?.ownerFilepath,
555
+ serviceLabel: inferredServiceLabel,
556
+ startedAt,
557
+ requestBodyJson: input.requestData !== undefined ? serializeRawCaptureValue(input.requestData, 'requestData') : undefined,
558
+ };
559
+
560
+ record.profiling.apiCalls.push(profilingCall);
561
+
562
+ const trace = record.trace;
563
+ if (!trace) return callId;
440
564
 
441
565
  const call: TTraceCall = {
442
- id: `${requestId}:call:${trace.calls.length}`,
566
+ id: callId,
443
567
  parentId: input.parentId,
444
568
  origin: input.origin,
445
569
  label: input.label,
@@ -453,7 +577,7 @@ export default class Trace {
453
577
  serviceLabel: inferredServiceLabel,
454
578
  cacheKey: input.cacheKey || channel?.cacheKey,
455
579
  cachePhase: input.cachePhase || channel?.cachePhase,
456
- startedAt: nowIso(),
580
+ startedAt,
457
581
  requestDataKeys: input.requestDataKeys || [],
458
582
  requestData: input.requestData !== undefined ? summarizeCaptureValue(input.requestData, trace.capture, 'requestData') : undefined,
459
583
  requestDataJson: input.requestData !== undefined ? serializeCaptureValue(input.requestData, 'requestData') : undefined,
@@ -461,7 +585,7 @@ export default class Trace {
461
585
  };
462
586
 
463
587
  trace.calls.push(call);
464
- return call.id;
588
+ return callId;
465
589
  }
466
590
 
467
591
  public finishCall(
@@ -476,12 +600,24 @@ export default class Trace {
476
600
  ) {
477
601
  if (!callId) return;
478
602
 
479
- const trace = this.requests.get(requestId);
480
- const call = trace?.calls.find((candidate) => candidate.id === callId);
481
- if (!trace || !call) return;
603
+ const record = this.getRecord(requestId);
604
+ const profilingCall = record?.profiling.apiCalls.find((candidate) => candidate.id === callId);
605
+ if (!record || !profilingCall) return;
606
+
607
+ profilingCall.finishedAt = nowIso();
608
+ profilingCall.durationMs = Math.max(0, Date.parse(profilingCall.finishedAt) - Date.parse(profilingCall.startedAt));
609
+ profilingCall.statusCode = output.statusCode;
610
+ profilingCall.errorMessage = output.errorMessage;
611
+ profilingCall.responseBodyJson = output.result !== undefined ? serializeRawCaptureValue(output.result, 'result') : undefined;
482
612
 
483
- call.finishedAt = nowIso();
484
- call.durationMs = Math.max(0, Date.parse(call.finishedAt) - Date.parse(call.startedAt));
613
+ const trace = record.trace;
614
+ if (!trace) return;
615
+
616
+ const call = trace.calls.find((candidate) => candidate.id === callId);
617
+ if (!call) return;
618
+
619
+ call.finishedAt = profilingCall.finishedAt;
620
+ call.durationMs = profilingCall.durationMs;
485
621
  call.statusCode = output.statusCode;
486
622
  call.errorMessage = output.errorMessage;
487
623
  call.resultKeys = output.resultKeys || [];
@@ -490,7 +626,7 @@ export default class Trace {
490
626
  }
491
627
 
492
628
  public setRequestResult(requestId: string, result: TTraceInspectable) {
493
- const trace = this.requests.get(requestId);
629
+ const trace = this.getRecord(requestId)?.trace;
494
630
  if (!trace) return;
495
631
 
496
632
  trace.resultJson = serializeCaptureValue(result, 'result');
@@ -520,8 +656,8 @@ export default class Trace {
520
656
  target?: string;
521
657
  },
522
658
  ) {
523
- const trace = this.requests.get(requestId);
524
- if (!trace) return;
659
+ const record = this.getRecord(requestId);
660
+ if (!record) return;
525
661
  const channel = this.getContextChannel();
526
662
 
527
663
  const durationMs = Math.max(0, input.durationMs || 0);
@@ -530,10 +666,41 @@ export default class Trace {
530
666
  const startedAt =
531
667
  Number.isFinite(finishedAtMs) && durationMs > 0 ? new Date(finishedAtMs - durationMs).toISOString() : finishedAt;
532
668
  const fingerprint = this.createSqlFingerprint(input.query);
669
+ const normalizedQuery = this.normalizeSqlQuery(input.query) || undefined;
533
670
  const inferredServiceLabel = input.serviceLabel || channel?.serviceLabel || this.inferServiceLabelFromStack(new Error().stack);
534
671
 
672
+ const profilingQuery: TRequestProfilingSqlQuery = {
673
+ id: `${requestId}:sql:${record.profiling.sqlQueries.length}`,
674
+ callerCallId: input.callerCallId,
675
+ callerFetcherId: input.callerFetcherId,
676
+ callerLabel: input.callerLabel,
677
+ callerMethod: input.callerMethod || '',
678
+ callerOrigin: input.callerOrigin || 'request',
679
+ callerPath: input.callerPath || '',
680
+ durationMs,
681
+ finishedAt,
682
+ kind: input.kind,
683
+ model: input.model,
684
+ operation: input.operation,
685
+ fingerprint,
686
+ normalizedQuery,
687
+ ownerLabel: input.ownerLabel || channel?.ownerLabel,
688
+ ownerFilepath: input.ownerFilepath || channel?.ownerFilepath,
689
+ serviceLabel: inferredServiceLabel,
690
+ connectedNamespace: input.connectedNamespace || channel?.connectedNamespace,
691
+ paramsJson: input.paramsJson,
692
+ paramsText: input.paramsText,
693
+ query: input.query.trim(),
694
+ startedAt,
695
+ target: input.target,
696
+ };
697
+
698
+ record.profiling.sqlQueries.push(profilingQuery);
699
+ const trace = record.trace;
700
+ if (!trace) return;
701
+
535
702
  const sqlQuery: TTraceSqlQuery = {
536
- id: `${requestId}:sql:${trace.sqlQueries.length}`,
703
+ id: profilingQuery.id,
537
704
  callerCallId: input.callerCallId,
538
705
  callerFetcherId: input.callerFetcherId,
539
706
  callerLabel: input.callerLabel,
@@ -546,6 +713,7 @@ export default class Trace {
546
713
  model: input.model,
547
714
  operation: input.operation,
548
715
  fingerprint,
716
+ normalizedQuery,
549
717
  ownerLabel: input.ownerLabel || channel?.ownerLabel,
550
718
  ownerFilepath: input.ownerFilepath || channel?.ownerFilepath,
551
719
  serviceLabel: inferredServiceLabel,
@@ -564,7 +732,7 @@ export default class Trace {
564
732
  return [...this.order]
565
733
  .reverse()
566
734
  .slice(0, limit)
567
- .map((requestId) => this.requests.get(requestId))
735
+ .map((requestId) => this.getRecord(requestId)?.trace)
568
736
  .filter((trace): trace is TRequestTrace => trace !== undefined)
569
737
  .map((trace) => ({
570
738
  id: trace.id,
@@ -593,21 +761,25 @@ export default class Trace {
593
761
  return [...this.order]
594
762
  .reverse()
595
763
  .slice(0, Math.max(1, limit))
596
- .map((requestId) => this.requests.get(requestId))
764
+ .map((requestId) => this.getRecord(requestId)?.trace)
597
765
  .filter((trace): trace is TRequestTrace => trace !== undefined);
598
766
  }
599
767
 
600
768
  public getLatestRequest() {
601
769
  const latestRequestId = this.order[this.order.length - 1];
602
- return latestRequestId ? this.requests.get(latestRequestId) : undefined;
770
+ return latestRequestId ? this.getRecord(latestRequestId)?.trace : undefined;
603
771
  }
604
772
 
605
773
  public getRequest(requestId: string) {
606
- return this.requests.get(requestId);
774
+ return this.getRecord(requestId)?.trace;
775
+ }
776
+
777
+ public getProfiling(requestId: string) {
778
+ return this.getRecord(requestId)?.profiling;
607
779
  }
608
780
 
609
781
  public exportRequest(requestId: string, filepath?: string) {
610
- const trace = this.requests.get(requestId);
782
+ const trace = this.getRecord(requestId)?.trace;
611
783
  if (!trace) throw new Error(`Trace ${requestId} was not found.`);
612
784
 
613
785
  const outputFilepath =
@@ -622,6 +794,15 @@ export default class Trace {
622
794
  return outputFilepath;
623
795
  }
624
796
 
797
+ public releaseRequest(requestId: string) {
798
+ const record = this.getRecord(requestId);
799
+ if (!record) return;
800
+ if (record.trace) return;
801
+
802
+ this.requests.delete(requestId);
803
+ this.activeMeasurements.delete(requestId);
804
+ }
805
+
625
806
  private trimRequestBuffer() {
626
807
  const overflow = this.order.length - this.config.requestsLimit;
627
808
  if (overflow <= 0) return;
@@ -46,6 +46,12 @@ type TPrismaExtensionOperation = {
46
46
  query: (args: unknown) => Promise<unknown>;
47
47
  };
48
48
 
49
+ declare global {
50
+ interface BigInt {
51
+ toJSON: () => number | string;
52
+ }
53
+ }
54
+
49
55
  /*----------------------------------
50
56
  - HELPERS
51
57
  ----------------------------------*/
@@ -173,21 +179,18 @@ export default class ModelsManager extends Service<Config, Hooks, Application, A
173
179
  'DATABASE_URL is required before starting the Models service. Prisma 7 no longer auto-loads runtime env files.',
174
180
  );
175
181
 
176
- const shouldTraceQueries = this.app.container.Trace.isEnabled();
177
- const prismaClient = shouldTraceQueries
178
- ? new PrismaClient({
179
- adapter: createMariaDbAdapter(databaseUrl),
180
- log: [{ emit: 'event', level: 'query' }],
181
- })
182
- : new PrismaClient({
183
- adapter: createMariaDbAdapter(databaseUrl),
184
- });
185
-
186
- if (!shouldTraceQueries) {
187
- this.client = prismaClient;
182
+ if (!this.app.container.Trace.shouldInstrumentRequests()) {
183
+ this.client = new PrismaClient({
184
+ adapter: createMariaDbAdapter(databaseUrl),
185
+ });
188
186
  return;
189
187
  }
190
188
 
189
+ const prismaClient = new PrismaClient({
190
+ adapter: createMariaDbAdapter(databaseUrl),
191
+ log: [{ emit: 'event', level: 'query' }],
192
+ });
193
+
191
194
  prismaClient.$on('query', (event: TPrismaQueryEvent) => this.traceQuery(event));
192
195
 
193
196
  this.client = prismaClient.$extends(
@@ -510,7 +510,7 @@ export default class HttpServer<TRouter extends TServerRouter = TServerRouter> {
510
510
  private registerDevTraceRoutes(routes: express.Express) {
511
511
  if (!__DEV__ || this.app.env.profile !== 'dev') return;
512
512
 
513
- if (this.app.container.Trace.isEnabled()) {
513
+ if (this.app.container.Trace.isDevTraceEnabled()) {
514
514
  routes.get('/__proteum/trace/requests', (req, res) => {
515
515
  const rawLimit = Array.isArray(req.query.limit) ? req.query.limit[0] : req.query.limit;
516
516
  const parsedLimit = typeof rawLimit === 'string' ? Number.parseInt(rawLimit, 10) : NaN;
@@ -132,7 +132,13 @@ export type Config<
132
132
  // Set it as a function, so when we instanciate the services, we can callthis.router to pass the router instance in roiuter services
133
133
  type TRouterServicesList = { [serviceName: string]: AnyRouterService };
134
134
 
135
- export type Hooks = {};
135
+ export type Hooks = {
136
+ request: { args: [request: ServerRequest<TServerRouter>] };
137
+ 'request.finished': { args: [request: ServerRequest<TServerRouter>] };
138
+ resolve: { args: [request: ServerRequest<TServerRouter>] };
139
+ resolved: { args: [route: TMatchedRoute, request: ServerRequest<TServerRouter>, response: ServerResponse<TServerRouter>] };
140
+ render: { args: [page: Page<TServerRouter>] };
141
+ };
136
142
 
137
143
  export type TControllerDefinition = {
138
144
  path?: string;
@@ -607,6 +613,32 @@ export default class ServerRouter<
607
613
  /*----------------------------------
608
614
  - RESOLUTION
609
615
  ----------------------------------*/
616
+ private async finalizeRequest(
617
+ request: ServerRequest<this>,
618
+ output: {
619
+ statusCode: number;
620
+ user?: string;
621
+ errorMessage?: string;
622
+ },
623
+ ) {
624
+ this.app.container.Trace.finishRequest(request.id, output);
625
+
626
+ try {
627
+ await this.runHook('request.finished', request);
628
+ } catch (error) {
629
+ const typedError =
630
+ error instanceof Error ? error : new Error(typeof error === 'string' ? error : 'Unknown request.finished hook error');
631
+
632
+ try {
633
+ await this.app.runHook('error', typedError, request);
634
+ } catch (hookError) {
635
+ console.error('request.finished hook error', typedError, 'Error hook failure', hookError);
636
+ }
637
+ } finally {
638
+ this.app.container.Trace.releaseRequest(request.id);
639
+ }
640
+ }
641
+
610
642
  public async middleware(req: express.Request, res: express.Response) {
611
643
  // Create request
612
644
  let requestId = uuid();
@@ -629,7 +661,7 @@ export default class ServerRouter<
629
661
  this,
630
662
  );
631
663
 
632
- this.app.container.Trace.startRequest({
664
+ request.profiling = this.app.container.Trace.startRequest({
633
665
  id: request.id,
634
666
  method: request.method,
635
667
  path: request.path,
@@ -640,7 +672,7 @@ export default class ServerRouter<
640
672
  profilerOrigin: request.headers[profilerOriginHeader] || undefined,
641
673
  profilerParentRequestId: request.headers[profilerParentRequestIdHeader] || undefined,
642
674
  });
643
- if (this.app.container.Trace.isEnabled()) res.setHeader(profilerTraceRequestIdHeader, request.id);
675
+ if (this.app.container.Trace.isDevTraceEnabled()) res.setHeader(profilerTraceRequestIdHeader, request.id);
644
676
  if (cachedPage) {
645
677
  this.app.container.Trace.record(
646
678
  request.id,
@@ -659,7 +691,7 @@ export default class ServerRouter<
659
691
  // Bulk API Requests
660
692
  if (request.path === '/api' && typeof request.data.fetchers === 'object') {
661
693
  await this.resolveApiBatch(request.data.fetchers, request);
662
- this.app.container.Trace.finishRequest(request.id, {
694
+ await this.finalizeRequest(request, {
663
695
  statusCode: request.res.statusCode || 200,
664
696
  user: request.user?.email,
665
697
  });
@@ -696,7 +728,7 @@ export default class ServerRouter<
696
728
  },
697
729
  'summary',
698
730
  );
699
- this.app.container.Trace.finishRequest(request.id, {
731
+ await this.finalizeRequest(request, {
700
732
  statusCode: response.statusCode,
701
733
  user: request.user?.email,
702
734
  });
@@ -715,7 +747,7 @@ export default class ServerRouter<
715
747
  'summary',
716
748
  );
717
749
  res.send(cachedPage.rendered);
718
- this.app.container.Trace.finishRequest(request.id, {
750
+ await this.finalizeRequest(request, {
719
751
  statusCode: response.statusCode,
720
752
  user: request.user?.email,
721
753
  });
@@ -739,19 +771,19 @@ export default class ServerRouter<
739
771
  'summary',
740
772
  );
741
773
  res.send(response.data);
742
- this.app.container.Trace.finishRequest(request.id, {
774
+ await this.finalizeRequest(request, {
743
775
  statusCode: response.statusCode,
744
776
  user: request.user?.email,
745
777
  });
746
778
  } else if (response.data !== 'true') {
747
- this.app.container.Trace.finishRequest(request.id, {
779
+ await this.finalizeRequest(request, {
748
780
  statusCode: res.statusCode || response.statusCode,
749
781
  user: request.user?.email,
750
782
  errorMessage: "Can't return data from the controller since response has already been sent via express.",
751
783
  });
752
784
  throw new Error("Can't return data from the controller since response has already been sent via express.");
753
785
  } else {
754
- this.app.container.Trace.finishRequest(request.id, {
786
+ await this.finalizeRequest(request, {
755
787
  statusCode: res.statusCode || response.statusCode,
756
788
  user: request.user?.email,
757
789
  });
@@ -10,7 +10,7 @@ import Bowser from 'bowser';
10
10
 
11
11
  // Core
12
12
  import BaseRequest from '@common/router/request';
13
- import type { TTraceCallOrigin } from '@common/dev/requestTrace';
13
+ import type { TRequestProfiling, TTraceCallOrigin } from '@common/dev/requestTrace';
14
14
 
15
15
  // Specific
16
16
  import type { HttpMethod, HttpHeaders } from '..';
@@ -74,6 +74,7 @@ export default class ServerRequest<TRouter extends TAnyRouter = TAnyRouter> exte
74
74
  // Services
75
75
  public api: ApiClient;
76
76
  public traceCall?: TRequestTraceCallContext;
77
+ public profiling!: TRequestProfiling;
77
78
 
78
79
  /*----------------------------------
79
80
  - INITIALISATION