@piwitests/instrumentation-nitro 0.55.0 → 0.57.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 CHANGED
@@ -6,7 +6,7 @@ During a Playwright test run, the reporter reads this header from every response
6
6
 
7
7
  **Active in development and test only, by default.** Capture and probe verification run only outside production, using the same Development/Test allow-list as the ASP.NET package. Controlled by the `PIWI_TEST_LOGS_DISABLED` environment variable:
8
8
 
9
- - unset — capture is on when `NODE_ENV` is unset, `development`, or `test`; off for any other value (`production`, `staging`, `prod`, …)
9
+ - unset — capture is on when `NODE_ENV` is `development` or `test`; off for any other value (`production`, `staging`, `prod`, …) and when `NODE_ENV` is not set at all, as when a built server is started with `node .output/server/index.mjs`
10
10
  - `PIWI_TEST_LOGS_DISABLED=true` — capture is off everywhere
11
11
  - `PIWI_TEST_LOGS_DISABLED=false` — capture is on even in production builds (useful for a production-mode test deployment)
12
12
 
@@ -42,7 +42,7 @@ export default defineNitroConfig({ modules: ['@piwitests/instrumentation-nitro/m
42
42
  export default defineNuxtConfig({ nitro: { modules: ['@piwitests/instrumentation-nitro/module'] } })
43
43
  ```
44
44
 
45
- The manifest then lists every route Nitro scanned (`routes/`, `api/`) and every handler the config declares, middleware and a fallback catch-all (`/**`) left out, each with its method (`GET` for a route that answers any method), its pattern (`/api/orders/:id`) and its file relative to the git repository. Without the module, it lists only the routes the server has answered since it started, which the reporter, reading it before any test runs, rarely sees. Nuxt pages are rendered by one catch-all route, so they are not listed: name them in a `piwi.manifest.json`.
45
+ The manifest then lists every route Nitro scanned (`routes/`, `api/`) and every handler the config declares, middleware and a fallback catch-all (`/**`) left out, each with its method (`GET` for a route that answers any method), its pattern (`/api/orders/:id`) and its file relative to the git repository. Without the module, it lists only the routes the server has answered since it started (a route handler matched, so neither a 404 nor a public asset), which the reporter, reading it before any test runs, rarely sees. Nuxt pages are rendered by one catch-all route, so they are not listed: name them in a `piwi.manifest.json`.
46
46
 
47
47
  ## What gets captured
48
48
 
@@ -63,7 +63,7 @@ Each captured entry contains:
63
63
  | `message` | Log message (truncated at 500 characters) |
64
64
  | `stack` | Shrunk stack trace when an `Error` was logged (max 5 frames, internal frames dropped) |
65
65
 
66
- 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.
66
+ Up to 50 entries per request are included, the first ones logged; an entry logged once the response ended (a timer or a lazy client the request started) is dropped. A request the server makes to itself while rendering a page (`$fetch` or `useFetch` during server-side rendering) adds its entries and spans to the page's request. The header is always emitted (with an empty array when no entries were captured) so the Piwi reporter can confirm the plugin is active.
67
67
 
68
68
  ## How it works
69
69
 
package/dist/index.d.ts CHANGED
@@ -39,7 +39,8 @@ export interface PiwiServerSpan {
39
39
  /**
40
40
  * Record a server-side span for the in-flight request. Shows up in the Piwi
41
41
  * Dashboard test-case view (and AI diagnosis) under the request's root span.
42
- * No-op outside a request scope or once the per-request span cap is reached.
42
+ * No-op outside a request scope, once the per-request span cap is reached, or
43
+ * once the request's response has ended.
43
44
  */
44
45
  export declare function recordServerSpan(span: PiwiServerSpan): void;
45
46
  declare const piwiTestLogs: NitroAppPlugin;
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import { gzipSync } from 'node:zlib';
4
4
  import { consola } from 'consola';
5
5
  import { createError } from 'h3';
6
6
  import { verifyProbeHeader, ProbeNonceCache } from './probe.js';
7
- import { buildRouteManifest, recordObservedRoute, collapsePathPattern, } from './manifest.js';
7
+ import { buildRouteManifest, recordObservedRoute, matchedRoutePattern, } from './manifest.js';
8
8
  import { declaredRoutes } from './route-table.js';
9
9
  import { routeMatchesRequest, faultStatus, faultDelayMs, isThrowFault, isExtremeFault, isDataFault, isDependencyFault, dependencyCallMatches, mutateResponseBody, appliedFaultLabel, } from './faults.js';
10
10
  export { verifyProbeHeader, signProbeMessage, probeSigningMessage, PROBE_TTL_MS, } from './probe.js';
@@ -57,6 +57,16 @@ function markProbeApplied(store) {
57
57
  if (store.probe)
58
58
  store.probeApplied = appliedFaultLabel(store.probe);
59
59
  }
60
+ /** Record a log entry on a request still in flight, up to `MAX_ENTRIES`. */
61
+ function pushLog(store, entry) {
62
+ if (!store.ended && store.logs.length < MAX_ENTRIES)
63
+ store.logs.push(entry);
64
+ }
65
+ /** Record a span on a request still in flight, up to `MAX_SPANS`. */
66
+ function pushSpan(store, span) {
67
+ if (!store.ended && store.spans.length < MAX_SPANS)
68
+ store.spans.push(span);
69
+ }
60
70
  /** Signed probe nonces already honored this process, so a header is single-use. */
61
71
  const probeNonces = new ProbeNonceCache();
62
72
  // Links consola calls and recorded spans to the request being handled. The store
@@ -66,24 +76,24 @@ const als = new AsyncLocalStorage();
66
76
  /**
67
77
  * Record a server-side span for the in-flight request. Shows up in the Piwi
68
78
  * Dashboard test-case view (and AI diagnosis) under the request's root span.
69
- * No-op outside a request scope or once the per-request span cap is reached.
79
+ * No-op outside a request scope, once the per-request span cap is reached, or
80
+ * once the request's response has ended.
70
81
  */
71
82
  export function recordServerSpan(span) {
72
83
  const store = als.getStore();
73
- if (!store || store.spans.length >= MAX_SPANS)
74
- return;
75
- store.spans.push(span);
84
+ if (store)
85
+ pushSpan(store, span);
76
86
  }
77
87
  // The consola reporter is process-global — register it only once.
78
88
  let reporterAdded = false;
79
- // Capture and probe verification run only outside production (the ASP.NET
80
- // package's default allow-list is Development and Test): only an unset/empty
81
- // NODE_ENV or `development`/`test` is treated as non-production. Any other value
82
- // (`production`, `staging`, `prod`, …) disables capture unless
83
- // PIWI_TEST_LOGS_DISABLED is explicitly `false`, so a signed probe header is
84
- // never honored on a production-like deployment that skipped `NODE_ENV=production`.
89
+ // Capture and probe verification run only in development and test (the ASP.NET
90
+ // package's default allow-list is Development and Test): NODE_ENV must be
91
+ // `development` or `test`. Any other value, or none at all (`node
92
+ // .output/server/index.mjs` sets none), disables capture unless
93
+ // PIWI_TEST_LOGS_DISABLED is explicitly `false`, so a deployment that never set
94
+ // NODE_ENV exposes no server logs, handler files or route manifest.
85
95
  const NODE_ENV = process.env.NODE_ENV;
86
- const IS_DEV_OR_TEST = !NODE_ENV || NODE_ENV === 'development' || NODE_ENV === 'test';
96
+ const IS_DEV_OR_TEST = NODE_ENV === 'development' || NODE_ENV === 'test';
87
97
  const TEST_LOGS_DISABLED = process.env.PIWI_TEST_LOGS_DISABLED === 'true' ||
88
98
  (!IS_DEV_OR_TEST && process.env.PIWI_TEST_LOGS_DISABLED !== 'false');
89
99
  /** The shared secret a probe run signs the `X-Piwi-Probe` header with. */
@@ -123,11 +133,11 @@ const piwiTestLogs = (nitroApp) => {
123
133
  if (logObj.level > 1)
124
134
  return; // Warning (1) and Error/Fatal (0) only
125
135
  const store = als.getStore();
126
- if (!store)
136
+ if (!store || store.ended)
127
137
  return;
128
138
  const msg = logObj.args.map(String).join(' ');
129
139
  const stack = logObj.args.map(extractStack).find(Boolean);
130
- store.logs.push({
140
+ pushLog(store, {
131
141
  timestamp: Date.now(),
132
142
  level: logObj.level <= 0 ? 'Error' : 'Warning',
133
143
  category: logObj.tag ?? '',
@@ -156,7 +166,13 @@ const piwiTestLogs = (nitroApp) => {
156
166
  res.end(JSON.stringify(buildRouteManifest(routes)));
157
167
  return;
158
168
  }
159
- const store = { logs: [], spans: [], startMs: Date.now() };
169
+ const enclosing = als.getStore();
170
+ const store = {
171
+ logs: [],
172
+ spans: [],
173
+ startMs: Date.now(),
174
+ ...(enclosing && !enclosing.ended ? { parent: enclosing } : {}),
175
+ };
160
176
  event.context._piwiLogs = store.logs;
161
177
  event.context._piwiSpans = store.spans;
162
178
  // The beforeResponse hook fires outside this handler's async scope (it is
@@ -204,7 +220,7 @@ const piwiTestLogs = (nitroApp) => {
204
220
  if (nitroErrors?.length) {
205
221
  for (const { error } of nitroErrors) {
206
222
  const msg = error instanceof Error ? (error.message || String(error)) : String(error);
207
- store.logs.push({
223
+ pushLog(store, {
208
224
  timestamp: Date.now(),
209
225
  level: 'Error',
210
226
  category: 'server',
@@ -213,8 +229,7 @@ const piwiTestLogs = (nitroApp) => {
213
229
  });
214
230
  }
215
231
  }
216
- const logPayload = store.logs.length > MAX_ENTRIES ? store.logs.slice(0, MAX_ENTRIES) : store.logs;
217
- res.setHeader('X-Piwi-Logs', gzipSync(Buffer.from(JSON.stringify(logPayload))).toString('base64'));
232
+ res.setHeader('X-Piwi-Logs', gzipSync(Buffer.from(JSON.stringify(store.logs))).toString('base64'));
218
233
  // Synthesize the root request span (server-side processing time, route,
219
234
  // status), correlate any app-recorded child spans under it, and ship the
220
235
  // whole tree. When the caller sent a W3C traceparent, reuse its trace id
@@ -225,13 +240,10 @@ const piwiTestLogs = (nitroApp) => {
225
240
  const statusCode = Number(res.statusCode) || 0;
226
241
  const traceId = parseTraceparent(event.node.req.headers['traceparent']) ?? randomBytes(16).toString('hex');
227
242
  const handlerFile = resolveHandlerFile(event);
228
- // Record this route for the declared-surface manifest. Prefer the matched
229
- // route's own pattern (`path` in h3 1.x, `route` in later versions); fall
230
- // back to collapsing the concrete path.
231
- const matchedRoute = event.context?.matchedRoute;
232
- const matchedPattern = matchedRoute?.route ?? matchedRoute?.path;
233
- const pattern = typeof matchedPattern === 'string' && matchedPattern ? matchedPattern : collapsePathPattern(path);
234
- recordObservedRoute(observedRoutes, { method, pattern, handler: handlerFile });
243
+ // Record the route the router matched for the declared-surface manifest.
244
+ const pattern = matchedRoutePattern(event.context?.matchedRoute);
245
+ if (pattern)
246
+ recordObservedRoute(observedRoutes, { method, pattern, handler: handlerFile });
235
247
  const rootSpan = {
236
248
  id: randomBytes(8).toString('hex'),
237
249
  name: `${method} ${path}`.trim(),
@@ -256,7 +268,14 @@ const piwiTestLogs = (nitroApp) => {
256
268
  s.parentId = rootSpan.id;
257
269
  const spanPayload = [rootSpan, ...store.spans].slice(0, MAX_SPANS);
258
270
  res.setHeader('X-Piwi-Trace', gzipSync(Buffer.from(JSON.stringify(spanPayload))).toString('base64'));
271
+ if (store.parent) {
272
+ for (const entry of store.logs)
273
+ pushLog(store.parent, entry);
274
+ for (const span of spanPayload)
275
+ pushSpan(store.parent, span);
276
+ }
259
277
  }
278
+ store.ended = true;
260
279
  return originalEnd(...args);
261
280
  };
262
281
  return als.run(store, async () => {
@@ -20,9 +20,15 @@ export interface RouteManifest {
20
20
  /**
21
21
  * Collapse a concrete request path to a pattern: numeric and uuid segments become
22
22
  * `:id` / `:uuid`, so `/api/orders/42` and `/api/orders/43` fold to one route.
23
- * Used only when the matched route carries no pattern of its own.
24
23
  */
25
24
  export declare function collapsePathPattern(path: string): string;
25
+ /**
26
+ * The pattern a request is recorded under: the one of the route the router
27
+ * matched it to (`path` in h3 1.x, `route` in later versions). Null when no
28
+ * route handler matched it: a 404, or a public asset served before the router,
29
+ * neither of which is a route of the app.
30
+ */
31
+ export declare function matchedRoutePattern(matchedRoute: unknown): string | null;
26
32
  /**
27
33
  * Record an observed route in the accumulator, keeping the first handler file
28
34
  * seen for it. Bounded so a pathological app (or an unbounded set of patterns
package/dist/manifest.js CHANGED
@@ -10,7 +10,6 @@
10
10
  /**
11
11
  * Collapse a concrete request path to a pattern: numeric and uuid segments become
12
12
  * `:id` / `:uuid`, so `/api/orders/42` and `/api/orders/43` fold to one route.
13
- * Used only when the matched route carries no pattern of its own.
14
13
  */
15
14
  export function collapsePathPattern(path) {
16
15
  const clean = path.split('?')[0] ?? path;
@@ -27,6 +26,17 @@ export function collapsePathPattern(path) {
27
26
  })
28
27
  .join('/'));
29
28
  }
29
+ /**
30
+ * The pattern a request is recorded under: the one of the route the router
31
+ * matched it to (`path` in h3 1.x, `route` in later versions). Null when no
32
+ * route handler matched it: a 404, or a public asset served before the router,
33
+ * neither of which is a route of the app.
34
+ */
35
+ export function matchedRoutePattern(matchedRoute) {
36
+ const matched = matchedRoute;
37
+ const pattern = matched?.route ?? matched?.path;
38
+ return typeof pattern === 'string' && pattern ? pattern : null;
39
+ }
30
40
  /** The unique key for a route in the accumulator: method plus pattern. */
31
41
  function routeKey(method, pattern) {
32
42
  return `${method.toUpperCase()} ${pattern}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piwitests/instrumentation-nitro",
3
- "version": "0.55.0",
3
+ "version": "0.57.0",
4
4
  "description": "Nitro/Nuxt server plugin for sending backend logs to Piwi Dashboard via the X-Piwi-Logs response header",
5
5
  "homepage": "https://piwitests.dev",
6
6
  "repository": {