@piwitests/instrumentation-nitro 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # @piwitests/instrumentation-nitro
2
+
3
+ Nitro / Nuxt server plugin for [Piwi Dashboard](https://piwitests.github.io) — captures Warning and Error log entries per HTTP request and delivers them to the Piwi Dashboard reporter via the `X-Piwi-Logs` response header.
4
+
5
+ During a Playwright test run, the reporter reads this header from every response and stores the entries alongside the network request. The entries are then available in the Piwi Dashboard test-case view and are included in the AI diagnosis context.
6
+
7
+ **Active outside production by default.** Capture is controlled by the `PIWI_TEST_LOGS_DISABLED` environment variable:
8
+
9
+ - unset — capture is on, except when `NODE_ENV === 'production'`
10
+ - `PIWI_TEST_LOGS_DISABLED=true` — capture is off everywhere
11
+ - `PIWI_TEST_LOGS_DISABLED=false` — capture is on even in production builds (useful for a production-mode test deployment)
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ npm install @piwitests/instrumentation-nitro
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ Create a file in your project's server plugins directory:
22
+
23
+ ```typescript
24
+ // Nuxt: server/plugins/piwi-test-logs.ts
25
+ // Standalone Nitro: plugins/piwi-test-logs.ts
26
+ export { default } from '@piwitests/instrumentation-nitro'
27
+ ```
28
+
29
+ That's all. Nitro auto-loads every file in that directory on startup (`server/plugins/` in a Nuxt app, `plugins/` under the Nitro `srcDir` in a standalone Nitro app).
30
+
31
+ A runnable end-to-end demo lives in [`examples/playwright-fixtures`](../../examples/playwright-fixtures) — a standalone Nitro app instrumented with this package, with a Playwright spec showing the captured logs in the dashboard.
32
+
33
+ ## What gets captured
34
+
35
+ | Source | What is captured |
36
+ |--------------------------------------|------------------------------------------------|
37
+ | `consola.warn()` / `consola.error()` | Warning and Error entries logged via consola |
38
+ | Unhandled H3/Nitro errors | Errors thrown in route handlers and middleware |
39
+
40
+ > Only calls made through **consola** are captured — bare `console.warn()` / `console.error()` output is not. Nuxt server code typically logs via consola already; in a standalone Nitro app, `import { consola } from 'consola'` in your handlers.
41
+
42
+ Each captured entry contains:
43
+
44
+ | Field | Description |
45
+ |-------------|--------------------------------------------------------------------------------------|
46
+ | `timestamp` | Unix timestamp in milliseconds |
47
+ | `level` | `"Warning"` or `"Error"` |
48
+ | `category` | Logger tag/category (e.g. `"database"`) |
49
+ | `message` | Log message (truncated at 500 characters) |
50
+ | `stack` | Shrunk stack trace when an `Error` was logged (max 5 frames, internal frames dropped) |
51
+
52
+ Up to 50 entries per request are included. The header is always emitted (with an empty array when no entries were captured) so the Piwi reporter can confirm the plugin is active.
53
+
54
+ ## How it works
55
+
56
+ ```
57
+ Playwright test
58
+ └─ page.goto('/api/orders')
59
+ └─ Nitro route handler runs
60
+ ├─ consola.warn('Stock low') ← captured via consola reporter
61
+ └─ HTTP response
62
+ └─ X-Piwi-Logs: <gzip+base64 JSON>
63
+ └─ Piwi reporter reads header
64
+ └─ stored as serverLogs on the network request
65
+ └─ visible in test-case detail + AI diagnosis
66
+ ```
67
+
68
+ The plugin wraps Nitro's root H3 handler and uses three mechanisms:
69
+
70
+ 1. **`event.context._piwiLogs`** — a plain per-request array attached to the H3 event as the wrapped handler starts; everything captured for the request accumulates here.
71
+ 2. **`AsyncLocalStorage.run()`** — scopes that buffer around the entire downstream chain (hooks, middleware, route handlers), so the process-global `consola` reporter always appends to the correct request's buffer.
72
+ 3. **A patched `res.end`** — the header is written just before the response goes out, which covers **every** response, including H3 error responses that bypass Nitro's `beforeResponse` hook. Right before writing, unhandled errors are drained from `event.context.nitro.errors`, so thrown errors appear even when nothing logged via consola.
73
+
74
+ ## Peer dependencies
75
+
76
+ | Package | Version |
77
+ |-------------|-----------|
78
+ | `nitropack` | `>=2.0.0` |
79
+ | `h3` | `>=1.0.0` |
80
+ | `consola` | `>=3.0.0` |
81
+
82
+ These are already installed in any Nuxt project — no extra installs needed.
83
+
84
+ ## Building from source
85
+
86
+ ```bash
87
+ cd integrations/nitro
88
+ npm run build # emits dist/index.js + dist/index.d.ts
89
+ ```
90
+
91
+ ## License
92
+
93
+ MIT
@@ -0,0 +1,43 @@
1
+ import type { NitroAppPlugin } from 'nitropack';
2
+ export interface PiwiTestLogEntry {
3
+ timestamp: number;
4
+ level: string;
5
+ category: string;
6
+ message: string;
7
+ stack?: string;
8
+ }
9
+ /**
10
+ * A server-side span for the in-flight request. Rides back to the Piwi reporter
11
+ * in the `X-Piwi-Trace` response header (gzip+base64 JSON array) and is shown in
12
+ * the dashboard next to the network request that produced it. The plugin always
13
+ * emits a root request span; application code can contribute child spans with
14
+ * `recordServerSpan` (e.g. a DB query, a downstream call).
15
+ */
16
+ export interface PiwiServerSpan {
17
+ /** Unique span id (hex). */
18
+ id: string;
19
+ /** Parent span id — child spans nest under the request's root span. */
20
+ parentId?: string;
21
+ /** Operation name, e.g. the route or a DB query label. */
22
+ name: string;
23
+ /** Coarse kind hint for display/color, e.g. 'server', 'db', 'client', 'internal'. */
24
+ kind?: string;
25
+ /** Start time, Unix epoch milliseconds. */
26
+ startMs: number;
27
+ /** Duration in milliseconds. */
28
+ durMs: number;
29
+ /** Outcome. */
30
+ status?: 'ok' | 'error';
31
+ /** Shared W3C trace id for the request (set on the root span). */
32
+ traceId?: string;
33
+ /** Small free-form attribute bag. */
34
+ attrs?: Record<string, string | number | boolean>;
35
+ }
36
+ /**
37
+ * Record a server-side span for the in-flight request. Shows up in the Piwi
38
+ * Dashboard test-case view (and AI diagnosis) under the request's root span.
39
+ * No-op outside a request scope or once the per-request span cap is reached.
40
+ */
41
+ export declare function recordServerSpan(span: PiwiServerSpan): void;
42
+ declare const piwiTestLogs: NitroAppPlugin;
43
+ export default piwiTestLogs;
package/dist/index.js ADDED
@@ -0,0 +1,151 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { gzipSync } from 'node:zlib';
4
+ import { consola } from 'consola';
5
+ const MAX_ENTRIES = 50;
6
+ const MAX_MSG_LENGTH = 500;
7
+ const MAX_STACK_FRAMES = 5;
8
+ const MAX_SPANS = 100;
9
+ /** Parse and shrink a JS/TS stack trace: skip internal/node_modules frames, keep max 5. */
10
+ function shrinkStack(stack) {
11
+ if (!stack)
12
+ return undefined;
13
+ const lines = stack.split('\n');
14
+ const frames = [];
15
+ for (const line of lines) {
16
+ const trimmed = line.trim();
17
+ if (!trimmed.startsWith('at '))
18
+ continue; // skip error message line
19
+ if (trimmed.includes('node:internal') || trimmed.includes('node_modules'))
20
+ continue;
21
+ if (frames.length >= MAX_STACK_FRAMES)
22
+ break;
23
+ frames.push(trimmed.slice(3).trim());
24
+ }
25
+ return frames.length > 0 ? frames.join('\n') : undefined;
26
+ }
27
+ /** Extract stack from an unknown error value, shrunk, or undefined. */
28
+ function extractStack(err) {
29
+ if (err instanceof Error && err.stack)
30
+ return shrinkStack(err.stack);
31
+ return undefined;
32
+ }
33
+ /** Pull the 32-hex trace-id out of a W3C `traceparent` header, if valid. */
34
+ function parseTraceparent(tp) {
35
+ const value = Array.isArray(tp) ? tp[0] : tp;
36
+ if (!value)
37
+ return undefined;
38
+ const parts = value.split('-');
39
+ if (parts.length >= 3 && parts[1] && /^[0-9a-f]{32}$/i.test(parts[1]))
40
+ return parts[1];
41
+ return undefined;
42
+ }
43
+ // Links consola calls and recorded spans to the request being handled. The store
44
+ // is scoped with als.run() around the whole downstream handler chain —
45
+ // enterWith() from a request hook is not reliable here (the binding dies with
46
+ // the hook's own async scope, so only the first request after boot would capture).
47
+ const als = new AsyncLocalStorage();
48
+ /**
49
+ * Record a server-side span for the in-flight request. Shows up in the Piwi
50
+ * Dashboard test-case view (and AI diagnosis) under the request's root span.
51
+ * No-op outside a request scope or once the per-request span cap is reached.
52
+ */
53
+ export function recordServerSpan(span) {
54
+ const store = als.getStore();
55
+ if (!store || store.spans.length >= MAX_SPANS)
56
+ return;
57
+ store.spans.push(span);
58
+ }
59
+ // The consola reporter is process-global — register it only once.
60
+ let reporterAdded = false;
61
+ const TEST_LOGS_DISABLED = process.env.PIWI_TEST_LOGS_DISABLED === 'true' ||
62
+ (process.env.NODE_ENV === 'production' && process.env.PIWI_TEST_LOGS_DISABLED !== 'false');
63
+ const piwiTestLogs = (nitroApp) => {
64
+ if (TEST_LOGS_DISABLED)
65
+ return;
66
+ if (!reporterAdded) {
67
+ reporterAdded = true;
68
+ consola.addReporter({
69
+ log(logObj) {
70
+ if (logObj.level > 1)
71
+ return; // Warning (1) and Error/Fatal (0) only
72
+ const store = als.getStore();
73
+ if (!store)
74
+ return;
75
+ const msg = logObj.args.map(String).join(' ');
76
+ const stack = logObj.args.map(extractStack).find(Boolean);
77
+ store.logs.push({
78
+ timestamp: Date.now(),
79
+ level: logObj.level <= 0 ? 'Error' : 'Warning',
80
+ category: logObj.tag ?? '',
81
+ message: msg.length > MAX_MSG_LENGTH ? `${msg.slice(0, MAX_MSG_LENGTH)}…` : msg,
82
+ stack,
83
+ });
84
+ },
85
+ });
86
+ }
87
+ // Wrap the root h3 handler: both the node listener (dev and node-server
88
+ // production entries) and route dispatch go through h3App.handler, so the
89
+ // als.run() scope covers every hook, middleware, and route handler.
90
+ const originalHandler = nitroApp.h3App.handler;
91
+ nitroApp.h3App.handler = ((event) => {
92
+ const store = { logs: [], spans: [], startMs: Date.now() };
93
+ event.context._piwiLogs = store.logs;
94
+ event.context._piwiSpans = store.spans;
95
+ // Patch res.end so the X-Piwi-Logs / X-Piwi-Trace headers are injected for
96
+ // ALL responses, including H3 error responses where Nitro bypasses the
97
+ // 'beforeResponse' hook (h3 skips onBeforeResponse once the error handler
98
+ // has called res.end).
99
+ const res = event.node.res;
100
+ const originalEnd = res.end.bind(res);
101
+ res.end = (...args) => {
102
+ if (!res.headersSent) {
103
+ // Collect any unhandled H3/Nitro errors — they are synchronously pushed
104
+ // to event.context.nitro.errors before errorHandler runs, so they're
105
+ // always available here even for error responses.
106
+ const nitroErrors = event.context.nitro?.errors;
107
+ if (nitroErrors?.length) {
108
+ for (const { error } of nitroErrors) {
109
+ const msg = error instanceof Error ? (error.message || String(error)) : String(error);
110
+ store.logs.push({
111
+ timestamp: Date.now(),
112
+ level: 'Error',
113
+ category: 'server',
114
+ message: msg.length > MAX_MSG_LENGTH ? `${msg.slice(0, MAX_MSG_LENGTH)}…` : msg,
115
+ stack: extractStack(error),
116
+ });
117
+ }
118
+ }
119
+ const logPayload = store.logs.length > MAX_ENTRIES ? store.logs.slice(0, MAX_ENTRIES) : store.logs;
120
+ res.setHeader('X-Piwi-Logs', gzipSync(Buffer.from(JSON.stringify(logPayload))).toString('base64'));
121
+ // Synthesize the root request span (server-side processing time, route,
122
+ // status), correlate any app-recorded child spans under it, and ship the
123
+ // whole tree. When the caller sent a W3C traceparent, reuse its trace id
124
+ // so the spans line up with an external tracing backend.
125
+ const endMs = Date.now();
126
+ const method = String(event.method ?? event.node.req.method ?? 'GET');
127
+ const path = String(event.path ?? event.node.req.url ?? '').split('?')[0] ?? '';
128
+ const statusCode = Number(res.statusCode) || 0;
129
+ const traceId = parseTraceparent(event.node.req.headers['traceparent']) ?? randomBytes(16).toString('hex');
130
+ const rootSpan = {
131
+ id: randomBytes(8).toString('hex'),
132
+ name: `${method} ${path}`.trim(),
133
+ kind: 'server',
134
+ startMs: store.startMs,
135
+ durMs: Math.max(0, endMs - store.startMs),
136
+ status: statusCode >= 500 ? 'error' : 'ok',
137
+ traceId,
138
+ attrs: { 'http.method': method, 'http.route': path, 'http.status_code': statusCode },
139
+ };
140
+ for (const s of store.spans)
141
+ if (!s.parentId)
142
+ s.parentId = rootSpan.id;
143
+ const spanPayload = [rootSpan, ...store.spans].slice(0, MAX_SPANS);
144
+ res.setHeader('X-Piwi-Trace', gzipSync(Buffer.from(JSON.stringify(spanPayload))).toString('base64'));
145
+ }
146
+ return originalEnd(...args);
147
+ };
148
+ return als.run(store, () => originalHandler(event));
149
+ });
150
+ };
151
+ export default piwiTestLogs;
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@piwitests/instrumentation-nitro",
3
+ "version": "0.21.0",
4
+ "description": "Nitro/Nuxt server plugin for sending backend logs to Piwi Dashboard via the X-Piwi-Logs response header",
5
+ "homepage": "https://piwitests.github.io",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/PiwiTests/platform"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/PiwiTests/platform/issues"
12
+ },
13
+ "type": "module",
14
+ "main": "dist/index.js",
15
+ "types": "dist/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js"
20
+ }
21
+ },
22
+ "keywords": [
23
+ "nitro",
24
+ "nuxt",
25
+ "playwright",
26
+ "piwi-dashboard",
27
+ "server-logs",
28
+ "test-results"
29
+ ],
30
+ "author": "piwitests",
31
+ "license": "MIT",
32
+ "scripts": {
33
+ "build": "tsc",
34
+ "dev": "tsc --watch",
35
+ "prepublishOnly": "npm run build"
36
+ },
37
+ "files": [
38
+ "dist/"
39
+ ],
40
+ "peerDependencies": {
41
+ "consola": ">=3.0.0",
42
+ "h3": ">=1.0.0",
43
+ "nitropack": ">=2.0.0"
44
+ }
45
+ }