@nest-rn-lens/nest 0.1.1 → 0.2.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
@@ -4,14 +4,15 @@
4
4
 
5
5
  A NestJS interceptor that records every request your API handles: the app and
6
6
  the exact file and line that sent it, the endpoint and the handler that answered,
7
- the status, and the time it took.
7
+ the status, the time it took, and the request and response bodies (with secrets
8
+ redacted).
8
9
 
9
10
  ```
10
11
  [NestRnLens] mobile (ios) apps/mobile/src/screens/order-details.tsx:23 → GET /orders/:id → OrdersController.findOne 200 4ms
11
12
  ```
12
13
 
13
14
  It is the server half of **NestRN Lens**, a toolkit for Turborepo monorepos with
14
- a NestJS API and a React Native (Expo) app. On its own it gives you readable
15
+ a NestJS API and a React Native (Expo) or Next.js app. On its own it gives you readable
15
16
  request logs and a typed event for every call. With the [NestRN Lens VS Code
16
17
  extension](https://marketplace.visualstudio.com/items?itemName=IsaiasDiaz.nest-rn-lens), those events become a live traffic panel where you can click any
17
18
  request to open the screen that made it or the handler that answered it.
@@ -64,6 +65,22 @@ That's it. Every request handled by a controller is now logged:
64
65
  Requests show `unknown` until the client says who it is. The next section
65
66
  explains how.
66
67
 
68
+ ## Step by step with React Native or Next.js
69
+
70
+ 1. **API:** install the package and add `NestRnLensModule.forRoot({ app: 'api' })`
71
+ to your root module (above).
72
+ 2. **API:** enable CORS in development (`app.enableCors()` in `main.ts`), since
73
+ web builds call the API from another origin. See [Browsers and CORS](#browsers-and-cors).
74
+ 3. **App:** route your API calls through one helper that adds the
75
+ `x-nest-rn-lens-app` header (and, on the web, `x-nest-rn-lens-caller` with
76
+ `window.location.pathname`), only in development.
77
+ 4. **Watch:** read the log lines, use `onEvent`, or open the
78
+ [NestRN Lens VS Code extension](https://marketplace.visualstudio.com/items?itemName=IsaiasDiaz.nest-rn-lens),
79
+ which starts everything and shows the traffic live.
80
+
81
+ Copy-paste helpers for React Native (Expo) and Next.js are in the extension's
82
+ guide: [Integrate your app, step by step](https://github.com/isa95Ar/nest-rn-lens-vscode#integrate-your-app-step-by-step).
83
+
67
84
  ## Telling the API who's calling
68
85
 
69
86
  A request on its own doesn't say which app or which screen sent it. The client
@@ -72,7 +89,7 @@ adds that with three headers:
72
89
  | Header | Example | Used for |
73
90
  | ------------------------- | ----------------------------------------------- | ------------------------------------------------- |
74
91
  | `x-nest-rn-lens-app` | `mobile` | Which app made the request |
75
- | `x-nest-rn-lens-caller` | `/repo/apps/mobile/src/screens/orders.tsx:23` | The file and line that made it |
92
+ | `x-nest-rn-lens-caller` | `/repo/apps/mobile/src/screens/orders.tsx:23` | The file and line that made it, or the page path for web apps (`/orders/42`) |
76
93
  | `x-nest-rn-lens-trace-id` | `5f0c…` | Linking every hop of one request chain |
77
94
 
78
95
  You don't have to write these by hand. The upcoming React Native client,
@@ -105,6 +122,9 @@ NestRnLensModule.forRoot({
105
122
  enabled: true,
106
123
  log: true,
107
124
  onEvent: (event) => {},
125
+ captureBodies: true,
126
+ maxBodyBytes: 16 * 1024,
127
+ redactKeys: ['pin'],
108
128
  });
109
129
  ```
110
130
 
@@ -114,6 +134,9 @@ NestRnLensModule.forRoot({
114
134
  | `enabled` | `boolean` | `true` unless `NODE_ENV=production` | Turns the interceptor on or off. When off, requests pass through untouched. |
115
135
  | `log` | `boolean` | `true` | Logs a readable line per request, plus the full event as JSON at debug level. |
116
136
  | `onEvent` | `(event: NestRnLensEvent) => void` | none | Called with every event. Forward traffic to your own tooling, tests or metrics. |
137
+ | `captureBodies` | `boolean` | `true` | Adds the request (headers, query, route params, body) and the response body to each event. |
138
+ | `maxBodyBytes` | `number` | `16384` (16 KB) | Bodies larger than this are cut to a preview of that many characters. |
139
+ | `redactKeys` | `string[]` | `[]` | Extra field or header names to hide, added to the built-in list (see below). |
117
140
 
118
141
  ### The event
119
142
 
@@ -138,13 +161,61 @@ interface NestRnLensEvent {
138
161
  };
139
162
  status: number; // 200, 201, 404, 500…
140
163
  error?: string; // exception message, when the handler threw
164
+ request?: {
165
+ headers: Record<string, string>; // sensitive ones redacted
166
+ query?: unknown; // { type: "water" }
167
+ params?: unknown; // { id: "42" } for /orders/:id
168
+ body?: CapturedBody;
169
+ };
170
+ response?: {
171
+ body?: CapturedBody; // the handler's return value, or the error body Nest sends
172
+ };
173
+ }
174
+
175
+ interface CapturedBody {
176
+ size: number; // bytes, once serialized
177
+ value?: unknown; // the body, redacted (absent when truncated or summarized)
178
+ truncated?: boolean; // larger than maxBodyBytes...
179
+ preview?: string; // ...so this holds its start
180
+ summary?: string; // "[binary 12.0 KB]", "[stream]" for non-JSON bodies
141
181
  }
142
182
  ```
143
183
 
184
+ `request` and `response` are present when `captureBodies` is on (the default).
185
+
144
186
  `route` is the route pattern, not the URL, so all calls to one endpoint group
145
187
  together. `status` follows Nest's rules: `@HttpCode()` when set, `201` for
146
188
  `POST`, the exception's status when a handler throws, and `500` for other errors.
147
189
 
190
+ ## Request and response bodies
191
+
192
+ Each event carries what the client sent and what the API answered, so you can
193
+ see the data behind every request, not just its status:
194
+
195
+ - **Request:** headers, query parameters, route parameters and the parsed body.
196
+ - **Response:** the value the handler returned. When a handler throws, it's the
197
+ error body Nest sends, for example
198
+ `{ "statusCode": 404, "message": "Order #42 not found", "error": "Not Found" }`.
199
+
200
+ **Secrets are redacted.** Any field or header whose name contains one of these
201
+ words is replaced with `"[redacted]"`, at any depth, in bodies, query, params
202
+ and headers:
203
+
204
+ `password`, `passwd`, `secret`, `token`, `authorization`, `cookie`, `apiKey`,
205
+ `privateKey`, `creditCard`, `cardNumber`, `cvv`, `ssn`
206
+
207
+ Matching ignores case and separators, so `api_key`, `API-Key` and `apiKey` are
208
+ all covered, and `token` also hides `accessToken` and `refreshToken`. Add your
209
+ own with `redactKeys`.
210
+
211
+ **Large and binary bodies are summarized.** A body bigger than `maxBodyBytes`
212
+ (16 KB by default) keeps only a text preview of its start. Files and streams
213
+ (`StreamableFile`, buffers) show as `[binary 12.0 KB]` or `[stream]`, and are
214
+ never read.
215
+
216
+ Turn it all off with `captureBodies: false`, for example if your API handles
217
+ data that shouldn't appear in development logs at all.
218
+
148
219
  ## Safe by design
149
220
 
150
221
  NestRN Lens is a development tool. It is built so that it can't hurt the API it
@@ -155,6 +226,8 @@ watches:
155
226
  - **Never breaks a request.** If your `onEvent` callback or the logger throws,
156
227
  the error is swallowed and the response goes out as usual.
157
228
  - **Doesn't change responses.** The only thing it adds is the trace id header.
229
+ - **Redacts secrets.** Passwords, tokens, cookies and similar fields never
230
+ appear in captured bodies or headers (see above).
158
231
  - **HTTP only.** Microservice, WebSocket and GraphQL contexts pass straight
159
232
  through.
160
233
 
@@ -0,0 +1,25 @@
1
+ import type { CapturedBody } from './types.js';
2
+ export declare const DEFAULT_MAX_BODY_BYTES: number;
3
+ /**
4
+ * Field and header names whose values are never captured, matched ignoring case
5
+ * and separators ("api_key", "apiKey" and "API-Key" are the same).
6
+ */
7
+ export declare const DEFAULT_REDACT_KEYS: string[];
8
+ export declare const REDACTED = "[redacted]";
9
+ export interface CaptureOptions {
10
+ maxBodyBytes: number;
11
+ redactKeys: string[];
12
+ }
13
+ export declare function isSensitive(key: string, redactKeys: string[]): boolean;
14
+ /**
15
+ * Turns a request or response body into something safe to log: sensitive
16
+ * fields redacted, binary data and streams summarized, and anything larger
17
+ * than `maxBodyBytes` cut to a text preview.
18
+ */
19
+ export declare function captureBody(value: unknown, { maxBodyBytes, redactKeys }: CaptureOptions): CapturedBody | undefined;
20
+ /** Request headers with sensitive values replaced. */
21
+ export declare function captureHeaders(headers: Record<string, string | string[] | undefined>, redactKeys: string[]): Record<string, string>;
22
+ /** The body Nest sends for an exception: HttpException's response, or its generic 500. */
23
+ export declare function errorResponseBody(error: unknown): unknown;
24
+ /** Leaves out empty query and params objects: there's nothing to show. */
25
+ export declare function nonEmpty(value: unknown): unknown;
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.REDACTED = exports.DEFAULT_REDACT_KEYS = exports.DEFAULT_MAX_BODY_BYTES = void 0;
4
+ exports.isSensitive = isSensitive;
5
+ exports.captureBody = captureBody;
6
+ exports.captureHeaders = captureHeaders;
7
+ exports.errorResponseBody = errorResponseBody;
8
+ exports.nonEmpty = nonEmpty;
9
+ const common_1 = require("@nestjs/common");
10
+ exports.DEFAULT_MAX_BODY_BYTES = 16 * 1024;
11
+ /**
12
+ * Field and header names whose values are never captured, matched ignoring case
13
+ * and separators ("api_key", "apiKey" and "API-Key" are the same).
14
+ */
15
+ exports.DEFAULT_REDACT_KEYS = [
16
+ 'password',
17
+ 'passwd',
18
+ 'secret',
19
+ 'token',
20
+ 'authorization',
21
+ 'cookie',
22
+ 'apikey',
23
+ 'privatekey',
24
+ 'creditcard',
25
+ 'cardnumber',
26
+ 'cvv',
27
+ 'ssn',
28
+ ];
29
+ exports.REDACTED = '[redacted]';
30
+ const normalize = (key) => key.toLowerCase().replace(/[^a-z0-9]/g, '');
31
+ function isSensitive(key, redactKeys) {
32
+ const name = normalize(key);
33
+ return name.length > 0 && redactKeys.some((pattern) => name.includes(normalize(pattern)));
34
+ }
35
+ /**
36
+ * Turns a request or response body into something safe to log: sensitive
37
+ * fields redacted, binary data and streams summarized, and anything larger
38
+ * than `maxBodyBytes` cut to a text preview.
39
+ */
40
+ function captureBody(value, { maxBodyBytes, redactKeys }) {
41
+ if (value === undefined) {
42
+ return undefined;
43
+ }
44
+ if (isBinary(value)) {
45
+ return { size: value.byteLength, summary: `[binary ${formatBytes(value.byteLength)}]` };
46
+ }
47
+ if (isStream(value)) {
48
+ return { size: 0, summary: '[stream]' };
49
+ }
50
+ let json;
51
+ try {
52
+ json = JSON.stringify(value, (key, field) => (key && isSensitive(key, redactKeys) ? exports.REDACTED : field));
53
+ }
54
+ catch {
55
+ return { size: 0, summary: '[could not be serialized]' };
56
+ }
57
+ if (json === undefined) {
58
+ return undefined;
59
+ }
60
+ const size = Buffer.byteLength(json);
61
+ if (size > maxBodyBytes) {
62
+ return { size, truncated: true, preview: json.slice(0, maxBodyBytes) };
63
+ }
64
+ return { size, value: JSON.parse(json) };
65
+ }
66
+ /** Request headers with sensitive values replaced. */
67
+ function captureHeaders(headers, redactKeys) {
68
+ const captured = {};
69
+ for (const [name, value] of Object.entries(headers)) {
70
+ if (value === undefined) {
71
+ continue;
72
+ }
73
+ captured[name] = isSensitive(name, redactKeys) ? exports.REDACTED : Array.isArray(value) ? value.join(', ') : value;
74
+ }
75
+ return captured;
76
+ }
77
+ /** The body Nest sends for an exception: HttpException's response, or its generic 500. */
78
+ function errorResponseBody(error) {
79
+ if (error instanceof common_1.HttpException) {
80
+ const response = error.getResponse();
81
+ return typeof response === 'string' ? { statusCode: error.getStatus(), message: response } : response;
82
+ }
83
+ return { statusCode: 500, message: 'Internal server error' };
84
+ }
85
+ /** Leaves out empty query and params objects: there's nothing to show. */
86
+ function nonEmpty(value) {
87
+ if (value && typeof value === 'object' && Object.keys(value).length === 0) {
88
+ return undefined;
89
+ }
90
+ return value;
91
+ }
92
+ function isBinary(value) {
93
+ return value instanceof Uint8Array || value instanceof ArrayBuffer;
94
+ }
95
+ // Readable streams and Nest's StreamableFile.
96
+ function isStream(value) {
97
+ if (!value || typeof value !== 'object') {
98
+ return false;
99
+ }
100
+ const candidate = value;
101
+ return typeof candidate.pipe === 'function' || typeof candidate.getStream === 'function';
102
+ }
103
+ function formatBytes(bytes) {
104
+ return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`;
105
+ }
@@ -15,6 +15,10 @@ export interface HttpRequest {
15
15
  };
16
16
  /** Fastify 3. */
17
17
  routerPath?: string;
18
+ /** Parsed by Nest's body parser (Express) or by Fastify. */
19
+ body?: unknown;
20
+ query?: unknown;
21
+ params?: unknown;
18
22
  }
19
23
  export interface HttpResponse {
20
24
  statusCode: number;
@@ -1,4 +1,4 @@
1
1
  export { NestRnLensModule } from './module.js';
2
2
  export { NestRnLensInterceptor } from './interceptor.js';
3
3
  export { NEST_RN_LENS_HEADERS } from './types.js';
4
- export type { NestRnLensEvent, NestRnLensOptions, Platform } from './types.js';
4
+ export type { CapturedBody, NestRnLensEvent, NestRnLensOptions, Platform } from './types.js';
@@ -11,6 +11,7 @@ export declare class NestRnLensInterceptor implements NestInterceptor {
11
11
  private readonly reporter;
12
12
  constructor(options: NestRnLensOptions, reporter: NestRnLensReporter);
13
13
  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown>;
14
+ private captureOptions;
14
15
  /**
15
16
  * The status Nest is about to send. Depending on the platform it may not be
16
17
  * written to the response yet, so follow Nest's own rules: @HttpCode(),
@@ -16,6 +16,7 @@ exports.NestRnLensInterceptor = void 0;
16
16
  const common_1 = require("@nestjs/common");
17
17
  const node_crypto_1 = require("node:crypto");
18
18
  const rxjs_1 = require("rxjs");
19
+ const capture_js_1 = require("./capture.js");
19
20
  const constants_js_1 = require("./constants.js");
20
21
  const http_js_1 = require("./http.js");
21
22
  const reporter_js_1 = require("./reporter.js");
@@ -46,7 +47,15 @@ let NestRnLensInterceptor = class NestRnLensInterceptor {
46
47
  const traceId = (0, http_js_1.readHeader)(req, types_js_1.NEST_RN_LENS_HEADERS.traceId) ?? (0, node_crypto_1.randomUUID)();
47
48
  // Lets the client, and the next service in the chain, reuse the trace id.
48
49
  (0, http_js_1.writeHeader)(res, types_js_1.NEST_RN_LENS_HEADERS.traceId, traceId);
49
- const finish = (status, error) => {
50
+ // Captured before the handler runs, in case it mutates the request.
51
+ const capture = this.captureOptions();
52
+ const request = capture && {
53
+ headers: (0, capture_js_1.captureHeaders)(req.headers, capture.redactKeys),
54
+ query: captureValue((0, capture_js_1.nonEmpty)(req.query), capture),
55
+ params: captureValue((0, capture_js_1.nonEmpty)(req.params), capture),
56
+ body: (0, capture_js_1.captureBody)(req.body, capture),
57
+ };
58
+ const finish = (status, error, responseBody) => {
50
59
  const event = {
51
60
  id: (0, node_crypto_1.randomUUID)(),
52
61
  traceId,
@@ -67,14 +76,22 @@ let NestRnLensInterceptor = class NestRnLensInterceptor {
67
76
  },
68
77
  status,
69
78
  error,
79
+ ...(capture && { request, response: { body: (0, capture_js_1.captureBody)(responseBody, capture) } }),
70
80
  };
71
81
  this.reporter.report(event);
72
82
  };
73
83
  return next.handle().pipe((0, rxjs_1.tap)({
74
- next: () => finish(this.successStatus(context, req, res)),
75
- error: (err) => finish(err instanceof common_1.HttpException ? err.getStatus() : 500, err instanceof Error ? err.message : String(err)),
84
+ next: (body) => finish(this.successStatus(context, req, res), undefined, body),
85
+ error: (err) => finish(err instanceof common_1.HttpException ? err.getStatus() : 500, err instanceof Error ? err.message : String(err), (0, capture_js_1.errorResponseBody)(err)),
76
86
  }));
77
87
  }
88
+ captureOptions() {
89
+ const { captureBodies, maxBodyBytes, redactKeys } = this.options;
90
+ if (captureBodies === false) {
91
+ return undefined;
92
+ }
93
+ return { maxBodyBytes: maxBodyBytes ?? capture_js_1.DEFAULT_MAX_BODY_BYTES, redactKeys: redactKeys ?? capture_js_1.DEFAULT_REDACT_KEYS };
94
+ }
78
95
  /**
79
96
  * The status Nest is about to send. Depending on the platform it may not be
80
97
  * written to the response yet, so follow Nest's own rules: @HttpCode(),
@@ -98,3 +115,7 @@ exports.NestRnLensInterceptor = NestRnLensInterceptor = __decorate([
98
115
  __param(0, (0, common_1.Inject)(constants_js_1.NEST_RN_LENS_OPTIONS)),
99
116
  __metadata("design:paramtypes", [Object, reporter_js_1.NestRnLensReporter])
100
117
  ], NestRnLensInterceptor);
118
+ /** Small values (query, params) keep their structure; their redacted value is all we need. */
119
+ function captureValue(value, capture) {
120
+ return (0, capture_js_1.captureBody)(value, capture)?.value;
121
+ }
@@ -10,6 +10,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
10
10
  exports.NestRnLensModule = void 0;
11
11
  const common_1 = require("@nestjs/common");
12
12
  const core_1 = require("@nestjs/core");
13
+ const capture_js_1 = require("./capture.js");
13
14
  const constants_js_1 = require("./constants.js");
14
15
  const interceptor_js_1 = require("./interceptor.js");
15
16
  const reporter_js_1 = require("./reporter.js");
@@ -27,6 +28,9 @@ let NestRnLensModule = NestRnLensModule_1 = class NestRnLensModule {
27
28
  ...options,
28
29
  enabled: options.enabled ?? process.env.NODE_ENV !== 'production',
29
30
  log: options.log ?? true,
31
+ captureBodies: options.captureBodies ?? true,
32
+ maxBodyBytes: options.maxBodyBytes ?? capture_js_1.DEFAULT_MAX_BODY_BYTES,
33
+ redactKeys: [...capture_js_1.DEFAULT_REDACT_KEYS, ...(options.redactKeys ?? [])],
30
34
  };
31
35
  return {
32
36
  module: NestRnLensModule_1,
@@ -10,3 +10,9 @@ export declare class NestRnLensReporter {
10
10
  private describe;
11
11
  private shortPath;
12
12
  }
13
+ /**
14
+ * Callers that are source locations are shown relative to the monorepo root
15
+ * (clients send absolute paths so editors can open them). Anything else, such as
16
+ * a web page path like "/orders/42", is shown as sent.
17
+ */
18
+ export declare function formatCaller(caller: string, repoRoot: string): string;
@@ -13,6 +13,7 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
13
13
  };
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.NestRnLensReporter = void 0;
16
+ exports.formatCaller = formatCaller;
16
17
  const common_1 = require("@nestjs/common");
17
18
  const node_fs_1 = require("node:fs");
18
19
  const node_path_1 = require("node:path");
@@ -48,14 +49,12 @@ let NestRnLensReporter = class NestRnLensReporter {
48
49
  const from = [`${source.app} (${source.platform})`, this.shortPath(source.caller)].filter(Boolean).join(' ');
49
50
  return `${from} → ${target.method} ${target.route} → ${target.controller}.${target.handler} ${status} ${durationMs}ms`;
50
51
  }
51
- // Clients send absolute paths (editors need them to open the file); logs are
52
- // easier to read relative to the monorepo root.
53
- shortPath(file) {
54
- if (!file?.startsWith('/')) {
55
- return file;
52
+ shortPath(caller) {
53
+ if (!caller || !SOURCE_LOCATION.test(caller)) {
54
+ return caller;
56
55
  }
57
56
  this.repoRoot ??= findRepoRoot(process.cwd());
58
- return (0, node_path_1.relative)(this.repoRoot, file);
57
+ return formatCaller(caller, this.repoRoot);
59
58
  }
60
59
  };
61
60
  exports.NestRnLensReporter = NestRnLensReporter;
@@ -64,6 +63,16 @@ exports.NestRnLensReporter = NestRnLensReporter = __decorate([
64
63
  __param(0, (0, common_1.Inject)(constants_js_1.NEST_RN_LENS_OPTIONS)),
65
64
  __metadata("design:paramtypes", [Object])
66
65
  ], NestRnLensReporter);
66
+ // "/repo/apps/mobile/src/screens/Orders.tsx:23": an absolute file path plus a line.
67
+ const SOURCE_LOCATION = /^\/.+:\d+$/;
68
+ /**
69
+ * Callers that are source locations are shown relative to the monorepo root
70
+ * (clients send absolute paths so editors can open them). Anything else, such as
71
+ * a web page path like "/orders/42", is shown as sent.
72
+ */
73
+ function formatCaller(caller, repoRoot) {
74
+ return SOURCE_LOCATION.test(caller) ? (0, node_path_1.relative)(repoRoot, caller) : caller;
75
+ }
67
76
  const ROOT_MARKERS = ['turbo.json', 'pnpm-workspace.yaml', '.git'];
68
77
  function findRepoRoot(from) {
69
78
  let dir = from;
@@ -11,6 +11,18 @@ export declare const NEST_RN_LENS_HEADERS: {
11
11
  readonly traceId: "x-nest-rn-lens-trace-id";
12
12
  };
13
13
  export type Platform = 'ios' | 'android' | 'web' | 'unknown';
14
+ /** A request or response body, made safe to log. */
15
+ export interface CapturedBody {
16
+ /** Size of the serialized body, in bytes. */
17
+ size: number;
18
+ /** The body, with sensitive fields redacted. Absent when truncated or summarized. */
19
+ value?: unknown;
20
+ /** Set when the body was larger than `maxBodyBytes`; `preview` holds its start. */
21
+ truncated?: boolean;
22
+ preview?: string;
23
+ /** For bodies that aren't JSON: "[binary 12.0 KB]", "[stream]". */
24
+ summary?: string;
25
+ }
14
26
  /** One request, as seen by the API. */
15
27
  export interface NestRnLensEvent {
16
28
  id: string;
@@ -40,6 +52,20 @@ export interface NestRnLensEvent {
40
52
  status: number;
41
53
  /** Message of the exception, when the handler threw. */
42
54
  error?: string;
55
+ /** What the client sent. Only present when `captureBodies` is on (the default). */
56
+ request?: {
57
+ /** Request headers; sensitive ones redacted. */
58
+ headers: Record<string, string>;
59
+ query?: unknown;
60
+ /** Route parameters, e.g. { id: "42" } for /orders/:id. */
61
+ params?: unknown;
62
+ body?: CapturedBody;
63
+ };
64
+ /** What the API answered. Only present when `captureBodies` is on (the default). */
65
+ response?: {
66
+ /** The handler's return value, or the error body Nest sends for an exception. */
67
+ body?: CapturedBody;
68
+ };
43
69
  }
44
70
  export interface NestRnLensOptions {
45
71
  /** Name of this API in the traffic graph, e.g. "api". */
@@ -59,4 +85,19 @@ export interface NestRnLensOptions {
59
85
  * debug level that the NestRN Lens VS Code extension reads). Defaults to `true`.
60
86
  */
61
87
  log?: boolean;
88
+ /**
89
+ * Include request and response bodies, query, route params and request
90
+ * headers in each event. Defaults to `true`. Sensitive fields are redacted
91
+ * (see `redactKeys`).
92
+ */
93
+ captureBodies?: boolean;
94
+ /** Bodies larger than this are cut to a preview. Defaults to 16 KB. */
95
+ maxBodyBytes?: number;
96
+ /**
97
+ * Extra field or header names to redact, added to the defaults (password,
98
+ * secret, token, authorization, cookie, apiKey, privateKey, creditCard,
99
+ * cardNumber, cvv, ssn). Matched ignoring case and separators, anywhere in
100
+ * the name: "token" also covers "accessToken".
101
+ */
102
+ redactKeys?: string[];
62
103
  }
@@ -0,0 +1,25 @@
1
+ import type { CapturedBody } from './types.js';
2
+ export declare const DEFAULT_MAX_BODY_BYTES: number;
3
+ /**
4
+ * Field and header names whose values are never captured, matched ignoring case
5
+ * and separators ("api_key", "apiKey" and "API-Key" are the same).
6
+ */
7
+ export declare const DEFAULT_REDACT_KEYS: string[];
8
+ export declare const REDACTED = "[redacted]";
9
+ export interface CaptureOptions {
10
+ maxBodyBytes: number;
11
+ redactKeys: string[];
12
+ }
13
+ export declare function isSensitive(key: string, redactKeys: string[]): boolean;
14
+ /**
15
+ * Turns a request or response body into something safe to log: sensitive
16
+ * fields redacted, binary data and streams summarized, and anything larger
17
+ * than `maxBodyBytes` cut to a text preview.
18
+ */
19
+ export declare function captureBody(value: unknown, { maxBodyBytes, redactKeys }: CaptureOptions): CapturedBody | undefined;
20
+ /** Request headers with sensitive values replaced. */
21
+ export declare function captureHeaders(headers: Record<string, string | string[] | undefined>, redactKeys: string[]): Record<string, string>;
22
+ /** The body Nest sends for an exception: HttpException's response, or its generic 500. */
23
+ export declare function errorResponseBody(error: unknown): unknown;
24
+ /** Leaves out empty query and params objects: there's nothing to show. */
25
+ export declare function nonEmpty(value: unknown): unknown;
@@ -0,0 +1,97 @@
1
+ import { HttpException } from '@nestjs/common';
2
+ export const DEFAULT_MAX_BODY_BYTES = 16 * 1024;
3
+ /**
4
+ * Field and header names whose values are never captured, matched ignoring case
5
+ * and separators ("api_key", "apiKey" and "API-Key" are the same).
6
+ */
7
+ export const DEFAULT_REDACT_KEYS = [
8
+ 'password',
9
+ 'passwd',
10
+ 'secret',
11
+ 'token',
12
+ 'authorization',
13
+ 'cookie',
14
+ 'apikey',
15
+ 'privatekey',
16
+ 'creditcard',
17
+ 'cardnumber',
18
+ 'cvv',
19
+ 'ssn',
20
+ ];
21
+ export const REDACTED = '[redacted]';
22
+ const normalize = (key) => key.toLowerCase().replace(/[^a-z0-9]/g, '');
23
+ export function isSensitive(key, redactKeys) {
24
+ const name = normalize(key);
25
+ return name.length > 0 && redactKeys.some((pattern) => name.includes(normalize(pattern)));
26
+ }
27
+ /**
28
+ * Turns a request or response body into something safe to log: sensitive
29
+ * fields redacted, binary data and streams summarized, and anything larger
30
+ * than `maxBodyBytes` cut to a text preview.
31
+ */
32
+ export function captureBody(value, { maxBodyBytes, redactKeys }) {
33
+ if (value === undefined) {
34
+ return undefined;
35
+ }
36
+ if (isBinary(value)) {
37
+ return { size: value.byteLength, summary: `[binary ${formatBytes(value.byteLength)}]` };
38
+ }
39
+ if (isStream(value)) {
40
+ return { size: 0, summary: '[stream]' };
41
+ }
42
+ let json;
43
+ try {
44
+ json = JSON.stringify(value, (key, field) => (key && isSensitive(key, redactKeys) ? REDACTED : field));
45
+ }
46
+ catch {
47
+ return { size: 0, summary: '[could not be serialized]' };
48
+ }
49
+ if (json === undefined) {
50
+ return undefined;
51
+ }
52
+ const size = Buffer.byteLength(json);
53
+ if (size > maxBodyBytes) {
54
+ return { size, truncated: true, preview: json.slice(0, maxBodyBytes) };
55
+ }
56
+ return { size, value: JSON.parse(json) };
57
+ }
58
+ /** Request headers with sensitive values replaced. */
59
+ export function captureHeaders(headers, redactKeys) {
60
+ const captured = {};
61
+ for (const [name, value] of Object.entries(headers)) {
62
+ if (value === undefined) {
63
+ continue;
64
+ }
65
+ captured[name] = isSensitive(name, redactKeys) ? REDACTED : Array.isArray(value) ? value.join(', ') : value;
66
+ }
67
+ return captured;
68
+ }
69
+ /** The body Nest sends for an exception: HttpException's response, or its generic 500. */
70
+ export function errorResponseBody(error) {
71
+ if (error instanceof HttpException) {
72
+ const response = error.getResponse();
73
+ return typeof response === 'string' ? { statusCode: error.getStatus(), message: response } : response;
74
+ }
75
+ return { statusCode: 500, message: 'Internal server error' };
76
+ }
77
+ /** Leaves out empty query and params objects: there's nothing to show. */
78
+ export function nonEmpty(value) {
79
+ if (value && typeof value === 'object' && Object.keys(value).length === 0) {
80
+ return undefined;
81
+ }
82
+ return value;
83
+ }
84
+ function isBinary(value) {
85
+ return value instanceof Uint8Array || value instanceof ArrayBuffer;
86
+ }
87
+ // Readable streams and Nest's StreamableFile.
88
+ function isStream(value) {
89
+ if (!value || typeof value !== 'object') {
90
+ return false;
91
+ }
92
+ const candidate = value;
93
+ return typeof candidate.pipe === 'function' || typeof candidate.getStream === 'function';
94
+ }
95
+ function formatBytes(bytes) {
96
+ return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`;
97
+ }
@@ -15,6 +15,10 @@ export interface HttpRequest {
15
15
  };
16
16
  /** Fastify 3. */
17
17
  routerPath?: string;
18
+ /** Parsed by Nest's body parser (Express) or by Fastify. */
19
+ body?: unknown;
20
+ query?: unknown;
21
+ params?: unknown;
18
22
  }
19
23
  export interface HttpResponse {
20
24
  statusCode: number;
@@ -1,4 +1,4 @@
1
1
  export { NestRnLensModule } from './module.js';
2
2
  export { NestRnLensInterceptor } from './interceptor.js';
3
3
  export { NEST_RN_LENS_HEADERS } from './types.js';
4
- export type { NestRnLensEvent, NestRnLensOptions, Platform } from './types.js';
4
+ export type { CapturedBody, NestRnLensEvent, NestRnLensOptions, Platform } from './types.js';
@@ -11,6 +11,7 @@ export declare class NestRnLensInterceptor implements NestInterceptor {
11
11
  private readonly reporter;
12
12
  constructor(options: NestRnLensOptions, reporter: NestRnLensReporter);
13
13
  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown>;
14
+ private captureOptions;
14
15
  /**
15
16
  * The status Nest is about to send. Depending on the platform it may not be
16
17
  * written to the response yet, so follow Nest's own rules: @HttpCode(),
@@ -13,6 +13,7 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
13
13
  import { HttpException, Inject, Injectable, } from '@nestjs/common';
14
14
  import { randomUUID } from 'node:crypto';
15
15
  import { tap } from 'rxjs';
16
+ import { captureBody, captureHeaders, DEFAULT_MAX_BODY_BYTES, DEFAULT_REDACT_KEYS, errorResponseBody, nonEmpty, } from './capture.js';
16
17
  import { NEST_RN_LENS_OPTIONS } from './constants.js';
17
18
  import { detectPlatform, readHeader, requestPath, routePattern, writeHeader, } from './http.js';
18
19
  import { NestRnLensReporter } from './reporter.js';
@@ -43,7 +44,15 @@ let NestRnLensInterceptor = class NestRnLensInterceptor {
43
44
  const traceId = readHeader(req, NEST_RN_LENS_HEADERS.traceId) ?? randomUUID();
44
45
  // Lets the client, and the next service in the chain, reuse the trace id.
45
46
  writeHeader(res, NEST_RN_LENS_HEADERS.traceId, traceId);
46
- const finish = (status, error) => {
47
+ // Captured before the handler runs, in case it mutates the request.
48
+ const capture = this.captureOptions();
49
+ const request = capture && {
50
+ headers: captureHeaders(req.headers, capture.redactKeys),
51
+ query: captureValue(nonEmpty(req.query), capture),
52
+ params: captureValue(nonEmpty(req.params), capture),
53
+ body: captureBody(req.body, capture),
54
+ };
55
+ const finish = (status, error, responseBody) => {
47
56
  const event = {
48
57
  id: randomUUID(),
49
58
  traceId,
@@ -64,14 +73,22 @@ let NestRnLensInterceptor = class NestRnLensInterceptor {
64
73
  },
65
74
  status,
66
75
  error,
76
+ ...(capture && { request, response: { body: captureBody(responseBody, capture) } }),
67
77
  };
68
78
  this.reporter.report(event);
69
79
  };
70
80
  return next.handle().pipe(tap({
71
- next: () => finish(this.successStatus(context, req, res)),
72
- error: (err) => finish(err instanceof HttpException ? err.getStatus() : 500, err instanceof Error ? err.message : String(err)),
81
+ next: (body) => finish(this.successStatus(context, req, res), undefined, body),
82
+ error: (err) => finish(err instanceof HttpException ? err.getStatus() : 500, err instanceof Error ? err.message : String(err), errorResponseBody(err)),
73
83
  }));
74
84
  }
85
+ captureOptions() {
86
+ const { captureBodies, maxBodyBytes, redactKeys } = this.options;
87
+ if (captureBodies === false) {
88
+ return undefined;
89
+ }
90
+ return { maxBodyBytes: maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES, redactKeys: redactKeys ?? DEFAULT_REDACT_KEYS };
91
+ }
75
92
  /**
76
93
  * The status Nest is about to send. Depending on the platform it may not be
77
94
  * written to the response yet, so follow Nest's own rules: @HttpCode(),
@@ -95,3 +112,7 @@ NestRnLensInterceptor = __decorate([
95
112
  __metadata("design:paramtypes", [Object, NestRnLensReporter])
96
113
  ], NestRnLensInterceptor);
97
114
  export { NestRnLensInterceptor };
115
+ /** Small values (query, params) keep their structure; their redacted value is all we need. */
116
+ function captureValue(value, capture) {
117
+ return captureBody(value, capture)?.value;
118
+ }
@@ -7,6 +7,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
7
7
  var NestRnLensModule_1;
8
8
  import { Module } from '@nestjs/common';
9
9
  import { APP_INTERCEPTOR } from '@nestjs/core';
10
+ import { DEFAULT_MAX_BODY_BYTES, DEFAULT_REDACT_KEYS } from './capture.js';
10
11
  import { NEST_RN_LENS_OPTIONS } from './constants.js';
11
12
  import { NestRnLensInterceptor } from './interceptor.js';
12
13
  import { NestRnLensReporter } from './reporter.js';
@@ -24,6 +25,9 @@ let NestRnLensModule = NestRnLensModule_1 = class NestRnLensModule {
24
25
  ...options,
25
26
  enabled: options.enabled ?? process.env.NODE_ENV !== 'production',
26
27
  log: options.log ?? true,
28
+ captureBodies: options.captureBodies ?? true,
29
+ maxBodyBytes: options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES,
30
+ redactKeys: [...DEFAULT_REDACT_KEYS, ...(options.redactKeys ?? [])],
27
31
  };
28
32
  return {
29
33
  module: NestRnLensModule_1,
@@ -10,3 +10,9 @@ export declare class NestRnLensReporter {
10
10
  private describe;
11
11
  private shortPath;
12
12
  }
13
+ /**
14
+ * Callers that are source locations are shown relative to the monorepo root
15
+ * (clients send absolute paths so editors can open them). Anything else, such as
16
+ * a web page path like "/orders/42", is shown as sent.
17
+ */
18
+ export declare function formatCaller(caller: string, repoRoot: string): string;
@@ -45,14 +45,12 @@ let NestRnLensReporter = class NestRnLensReporter {
45
45
  const from = [`${source.app} (${source.platform})`, this.shortPath(source.caller)].filter(Boolean).join(' ');
46
46
  return `${from} → ${target.method} ${target.route} → ${target.controller}.${target.handler} ${status} ${durationMs}ms`;
47
47
  }
48
- // Clients send absolute paths (editors need them to open the file); logs are
49
- // easier to read relative to the monorepo root.
50
- shortPath(file) {
51
- if (!file?.startsWith('/')) {
52
- return file;
48
+ shortPath(caller) {
49
+ if (!caller || !SOURCE_LOCATION.test(caller)) {
50
+ return caller;
53
51
  }
54
52
  this.repoRoot ??= findRepoRoot(process.cwd());
55
- return relative(this.repoRoot, file);
53
+ return formatCaller(caller, this.repoRoot);
56
54
  }
57
55
  };
58
56
  NestRnLensReporter = __decorate([
@@ -61,6 +59,16 @@ NestRnLensReporter = __decorate([
61
59
  __metadata("design:paramtypes", [Object])
62
60
  ], NestRnLensReporter);
63
61
  export { NestRnLensReporter };
62
+ // "/repo/apps/mobile/src/screens/Orders.tsx:23": an absolute file path plus a line.
63
+ const SOURCE_LOCATION = /^\/.+:\d+$/;
64
+ /**
65
+ * Callers that are source locations are shown relative to the monorepo root
66
+ * (clients send absolute paths so editors can open them). Anything else, such as
67
+ * a web page path like "/orders/42", is shown as sent.
68
+ */
69
+ export function formatCaller(caller, repoRoot) {
70
+ return SOURCE_LOCATION.test(caller) ? relative(repoRoot, caller) : caller;
71
+ }
64
72
  const ROOT_MARKERS = ['turbo.json', 'pnpm-workspace.yaml', '.git'];
65
73
  function findRepoRoot(from) {
66
74
  let dir = from;
@@ -11,6 +11,18 @@ export declare const NEST_RN_LENS_HEADERS: {
11
11
  readonly traceId: "x-nest-rn-lens-trace-id";
12
12
  };
13
13
  export type Platform = 'ios' | 'android' | 'web' | 'unknown';
14
+ /** A request or response body, made safe to log. */
15
+ export interface CapturedBody {
16
+ /** Size of the serialized body, in bytes. */
17
+ size: number;
18
+ /** The body, with sensitive fields redacted. Absent when truncated or summarized. */
19
+ value?: unknown;
20
+ /** Set when the body was larger than `maxBodyBytes`; `preview` holds its start. */
21
+ truncated?: boolean;
22
+ preview?: string;
23
+ /** For bodies that aren't JSON: "[binary 12.0 KB]", "[stream]". */
24
+ summary?: string;
25
+ }
14
26
  /** One request, as seen by the API. */
15
27
  export interface NestRnLensEvent {
16
28
  id: string;
@@ -40,6 +52,20 @@ export interface NestRnLensEvent {
40
52
  status: number;
41
53
  /** Message of the exception, when the handler threw. */
42
54
  error?: string;
55
+ /** What the client sent. Only present when `captureBodies` is on (the default). */
56
+ request?: {
57
+ /** Request headers; sensitive ones redacted. */
58
+ headers: Record<string, string>;
59
+ query?: unknown;
60
+ /** Route parameters, e.g. { id: "42" } for /orders/:id. */
61
+ params?: unknown;
62
+ body?: CapturedBody;
63
+ };
64
+ /** What the API answered. Only present when `captureBodies` is on (the default). */
65
+ response?: {
66
+ /** The handler's return value, or the error body Nest sends for an exception. */
67
+ body?: CapturedBody;
68
+ };
43
69
  }
44
70
  export interface NestRnLensOptions {
45
71
  /** Name of this API in the traffic graph, e.g. "api". */
@@ -59,4 +85,19 @@ export interface NestRnLensOptions {
59
85
  * debug level that the NestRN Lens VS Code extension reads). Defaults to `true`.
60
86
  */
61
87
  log?: boolean;
88
+ /**
89
+ * Include request and response bodies, query, route params and request
90
+ * headers in each event. Defaults to `true`. Sensitive fields are redacted
91
+ * (see `redactKeys`).
92
+ */
93
+ captureBodies?: boolean;
94
+ /** Bodies larger than this are cut to a preview. Defaults to 16 KB. */
95
+ maxBodyBytes?: number;
96
+ /**
97
+ * Extra field or header names to redact, added to the defaults (password,
98
+ * secret, token, authorization, cookie, apiKey, privateKey, creditCard,
99
+ * cardNumber, cvv, ssn). Matched ignoring case and separators, anywhere in
100
+ * the name: "token" also covers "accessToken".
101
+ */
102
+ redactKeys?: string[];
62
103
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nest-rn-lens/nest",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "NestJS interceptor that reports every request together with the React Native screen that made it. Part of NestRN Lens.",
5
5
  "keywords": [
6
6
  "nestjs",
@@ -12,7 +12,8 @@
12
12
  "monorepo",
13
13
  "observability",
14
14
  "tracing",
15
- "devtools"
15
+ "devtools",
16
+ "nextjs"
16
17
  ],
17
18
  "license": "MIT",
18
19
  "author": "isa",