@terreno/api 0.20.2 → 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.
Files changed (65) hide show
  1. package/.ai/guidelines/core.md +71 -0
  2. package/.ai/skills/mongoose-schema-safety/SKILL.md +143 -0
  3. package/README.md +54 -1
  4. package/dist/__tests__/versionCheckPlugin.test.js +29 -7
  5. package/dist/actions.openApi.test.js +13 -11
  6. package/dist/api.js +98 -11
  7. package/dist/api.query.test.js +31 -1
  8. package/dist/api.test.js +211 -0
  9. package/dist/auth.test.js +10 -10
  10. package/dist/betterAuth.d.ts +1 -1
  11. package/dist/consentApp.test.js +1 -0
  12. package/dist/example.js +4 -4
  13. package/dist/expressServer.d.ts +0 -22
  14. package/dist/expressServer.js +1 -125
  15. package/dist/expressServer.test.js +90 -91
  16. package/dist/githubAuth.test.js +22 -22
  17. package/dist/logger.d.ts +154 -0
  18. package/dist/logger.js +445 -26
  19. package/dist/logger.test.js +435 -0
  20. package/dist/middleware.d.ts +7 -0
  21. package/dist/middleware.js +58 -1
  22. package/dist/middleware.test.js +159 -0
  23. package/dist/openApi.test.js +10 -17
  24. package/dist/openApiBuilder.test.js +18 -10
  25. package/dist/realtime/changeStreamWatcher.d.ts +4 -4
  26. package/dist/realtime/changeStreamWatcher.js +2 -4
  27. package/dist/realtime/queryMatcher.d.ts +1 -1
  28. package/dist/realtime/queryMatcher.js +39 -14
  29. package/dist/realtime/types.d.ts +3 -3
  30. package/dist/requestContext.d.ts +61 -0
  31. package/dist/requestContext.js +74 -0
  32. package/dist/secretProviders.test.js +335 -0
  33. package/dist/terrenoApp.d.ts +27 -15
  34. package/dist/terrenoApp.js +24 -14
  35. package/dist/terrenoApp.test.js +52 -0
  36. package/dist/tests/bunSetup.js +61 -7
  37. package/dist/tests.js +27 -4
  38. package/package.json +1 -1
  39. package/src/__tests__/versionCheckPlugin.test.ts +43 -15
  40. package/src/actions.openApi.test.ts +12 -10
  41. package/src/api.query.test.ts +24 -1
  42. package/src/api.test.ts +169 -0
  43. package/src/api.ts +71 -0
  44. package/src/auth.test.ts +10 -10
  45. package/src/betterAuth.ts +1 -1
  46. package/src/consentApp.test.ts +1 -0
  47. package/src/example.ts +4 -4
  48. package/src/expressServer.test.ts +82 -85
  49. package/src/expressServer.ts +1 -213
  50. package/src/githubAuth.test.ts +22 -22
  51. package/src/logger.test.ts +466 -1
  52. package/src/logger.ts +477 -14
  53. package/src/middleware.test.ts +74 -2
  54. package/src/middleware.ts +57 -0
  55. package/src/openApi.test.ts +10 -17
  56. package/src/openApiBuilder.test.ts +18 -10
  57. package/src/realtime/changeStreamWatcher.ts +15 -10
  58. package/src/realtime/queryMatcher.ts +54 -27
  59. package/src/realtime/types.ts +4 -4
  60. package/src/requestContext.ts +86 -0
  61. package/src/secretProviders.test.ts +219 -1
  62. package/src/terrenoApp.test.ts +38 -0
  63. package/src/terrenoApp.ts +37 -15
  64. package/src/tests/bunSetup.ts +16 -3
  65. package/src/tests.ts +17 -4
package/src/logger.ts CHANGED
@@ -1,8 +1,39 @@
1
+ /**
2
+ * Backend logging for `@terreno/api`.
3
+ *
4
+ * Three building blocks cooperate so a single request or background job can be followed across
5
+ * many log lines, both in plain-text consoles and in structured transports (Google Cloud Logging,
6
+ * Sentry):
7
+ *
8
+ * - **{@link logger}** – the global logger (`debug` / `info` / `warn` / `error` / `catch`). Use it
9
+ * for one-off messages.
10
+ * - **{@link createScopedLogger}** – returns a logger that prepends a stable `prefix` and/or
11
+ * attaches `labels` (workflow dimensions such as `invoiceId`) to every line. Use it when a
12
+ * handler, job, or service runs multiple steps that should share identifiers.
13
+ * - **{@link createFeatureFlaggedLogger}** – wraps any {@link ScopedLogger} behind an
14
+ * `isEnabled()` predicate so verbose diagnostics can be toggled with a feature flag or env var
15
+ * without a redeploy.
16
+ *
17
+ * **Correlation** is automatic: while a request/job AsyncLocalStorage scope is active (see
18
+ * `requestContext.ts` – HTTP middleware or `runWithRequestContext`), every log line is enriched
19
+ * with `requestId`, `userId`, `traceId`, etc. and a nested `terrenoRequestLog` object, regardless
20
+ * of which logger above emitted it.
21
+ *
22
+ * @see {@link createScopedLogger}
23
+ * @see {@link createFeatureFlaggedLogger}
24
+ * @see {@link formatLogContextSuffix}
25
+ * @module logger
26
+ */
1
27
  import fs from "node:fs";
28
+ import {join} from "node:path";
2
29
  import {inspect} from "node:util";
3
30
  import * as Sentry from "@sentry/bun";
4
31
  import winston from "winston";
5
- import {getCurrentLogContext} from "./requestContext";
32
+ import {
33
+ getCurrentLogContext,
34
+ getCurrentRequestContext,
35
+ type RequestContext,
36
+ } from "./requestContext";
6
37
 
7
38
  const isPrimitive = (val: unknown) => {
8
39
  return val === null || (typeof val !== "object" && typeof val !== "function");
@@ -15,19 +46,93 @@ const formatWithInspect = (val: unknown) => {
15
46
  return prefix + (shouldFormat ? inspect(val, {colors: true, depth: null}) : val);
16
47
  };
17
48
 
49
+ const buildTerrenoRequestLog = (active: RequestContext): TerrenoRequestLogEntry => {
50
+ return {
51
+ requestId: active.requestId,
52
+ userId: active.userId ?? null,
53
+ };
54
+ };
55
+
56
+ const mergeActiveRequestIntoInfo = (
57
+ info: winston.Logform.TransformableInfo,
58
+ active: RequestContext
59
+ ): winston.Logform.TransformableInfo => {
60
+ const next: winston.Logform.TransformableInfo = {
61
+ ...info,
62
+ requestId: active.requestId,
63
+ terrenoRequestLog: buildTerrenoRequestLog(active),
64
+ };
65
+ if (active.jobId) {
66
+ next.jobId = active.jobId;
67
+ }
68
+ if (active.sessionId) {
69
+ next.sessionId = active.sessionId;
70
+ }
71
+ if (active.userId) {
72
+ next.userId = active.userId;
73
+ }
74
+ if (active.spanId) {
75
+ next.spanId = active.spanId;
76
+ }
77
+ if (active.traceId) {
78
+ next.traceId = active.traceId;
79
+ }
80
+ if (active.traceSampled !== undefined) {
81
+ next.traceSampled = active.traceSampled;
82
+ }
83
+ return next;
84
+ };
85
+
18
86
  const addRequestContextFormat = winston.format((info) => {
19
- const context = getCurrentLogContext();
20
- return {...context, ...info};
87
+ const active = getCurrentRequestContext();
88
+ if (!active) {
89
+ return {...info};
90
+ }
91
+ return mergeActiveRequestIntoInfo(info, active);
21
92
  });
22
93
 
23
- const formatContext = (info: winston.Logform.TransformableInfo): string => {
24
- const contextParts = [
25
- info.requestId ? `requestId=${info.requestId}` : undefined,
26
- info.jobId ? `jobId=${info.jobId}` : undefined,
27
- info.sessionId ? `sessionId=${info.sessionId}` : undefined,
28
- info.userId ? `userId=${info.userId}` : undefined,
29
- info.traceId ? `traceId=${info.traceId}` : undefined,
30
- ].filter(Boolean);
94
+ /** Always attached to Winston metadata while a request/job ALS scope is active. */
95
+ export interface TerrenoRequestLogEntry {
96
+ requestId: string;
97
+ userId: string | null;
98
+ }
99
+
100
+ export interface LogContextFields {
101
+ jobId?: string;
102
+ requestId?: string;
103
+ sessionId?: string;
104
+ terrenoLabels?: Record<string, string>;
105
+ terrenoLogPrefix?: string;
106
+ traceId?: string;
107
+ userId?: string;
108
+ }
109
+
110
+ /**
111
+ * Builds the ` key=value ...` suffix appended to console/file log lines after the message.
112
+ * Request-scoped fields come from AsyncLocalStorage via Winston metadata; `terrenoLabels` and
113
+ * `terrenoLogPrefix` come from {@link createScopedLogger}. Nested `terrenoRequestLog`
114
+ * (`requestId` + `userId` including `null` when anonymous) is attached on the Winston info
115
+ * object for structured transports only, not repeated in this suffix.
116
+ */
117
+ export const formatLogContextSuffix = (fields: LogContextFields): string => {
118
+ const contextParts: string[] = [
119
+ fields.requestId ? `requestId=${fields.requestId}` : undefined,
120
+ fields.jobId ? `jobId=${fields.jobId}` : undefined,
121
+ fields.sessionId ? `sessionId=${fields.sessionId}` : undefined,
122
+ fields.userId ? `userId=${fields.userId}` : undefined,
123
+ fields.traceId ? `traceId=${fields.traceId}` : undefined,
124
+ fields.terrenoLogPrefix ? `logPrefix=${fields.terrenoLogPrefix}` : undefined,
125
+ ].filter(Boolean) as string[];
126
+
127
+ if (fields.terrenoLabels && typeof fields.terrenoLabels === "object") {
128
+ const sortedKeys = Object.keys(fields.terrenoLabels).sort();
129
+ for (const key of sortedKeys) {
130
+ const value = fields.terrenoLabels[key];
131
+ if (value !== undefined && value !== "") {
132
+ contextParts.push(`${key}=${value}`);
133
+ }
134
+ }
135
+ }
31
136
 
32
137
  if (contextParts.length === 0) {
33
138
  return "";
@@ -35,6 +140,18 @@ const formatContext = (info: winston.Logform.TransformableInfo): string => {
35
140
  return ` ${contextParts.join(" ")}`;
36
141
  };
37
142
 
143
+ const formatContext = (info: winston.Logform.TransformableInfo): string => {
144
+ return formatLogContextSuffix({
145
+ jobId: info.jobId as string | undefined,
146
+ requestId: info.requestId as string | undefined,
147
+ sessionId: info.sessionId as string | undefined,
148
+ terrenoLabels: info.terrenoLabels as Record<string, string> | undefined,
149
+ terrenoLogPrefix: info.terrenoLogPrefix as string | undefined,
150
+ traceId: info.traceId as string | undefined,
151
+ userId: info.userId as string | undefined,
152
+ });
153
+ };
154
+
38
155
  // Winston doesn't operate like console.log by default, e.g. `logger.error('error',
39
156
  // error)` only prints the message and no args. Add handling for all the args,
40
157
  // while also supporting splat logging.
@@ -52,6 +169,55 @@ const printf = (timestamp = false) => {
52
169
  };
53
170
  };
54
171
 
172
+ let terrenoDevJsonlAttached = false;
173
+
174
+ const shouldAttachTerrenoDevJsonl = (): boolean => {
175
+ if (process.env.TERRENO_LOG_FILE === "false" || process.env.TERRENO_LOG_FILE === "0") {
176
+ return false;
177
+ }
178
+ if (process.env.TERRENO_LOG_FILE === "true" || process.env.TERRENO_LOG_FILE === "1") {
179
+ return true;
180
+ }
181
+ return process.env.NODE_ENV !== "production";
182
+ };
183
+
184
+ const attachTerrenoDevJsonlTransportIfEnabled = (
185
+ logger: winston.Logger,
186
+ options?: {disable?: boolean}
187
+ ): void => {
188
+ if (options?.disable) {
189
+ return;
190
+ }
191
+ if (!shouldAttachTerrenoDevJsonl()) {
192
+ return;
193
+ }
194
+ if (terrenoDevJsonlAttached) {
195
+ return;
196
+ }
197
+ const logDir = join(process.cwd(), ".terreno", "logs");
198
+ if (!fs.existsSync(logDir)) {
199
+ fs.mkdirSync(logDir, {recursive: true});
200
+ }
201
+ const maxBytes = 5 * 1024 * 1024;
202
+ logger.add(
203
+ new winston.transports.File({
204
+ filename: join(logDir, "app.log"),
205
+ format: winston.format.combine(
206
+ addRequestContextFormat(),
207
+ winston.format.timestamp(),
208
+ winston.format.json()
209
+ ),
210
+ handleExceptions: true,
211
+ handleRejections: true,
212
+ level: "debug",
213
+ maxFiles: 3,
214
+ maxsize: maxBytes,
215
+ options: {flags: "a", mode: 0o600},
216
+ })
217
+ );
218
+ terrenoDevJsonlAttached = true;
219
+ };
220
+
55
221
  // Setup a global, default rejection handler.
56
222
  winston.add(
57
223
  new winston.transports.Console({
@@ -88,17 +254,52 @@ export const winstonLogger = winston.createLogger({
88
254
  ],
89
255
  });
90
256
 
257
+ const mergeSentryLogAttributes = (extra?: Record<string, string>): Record<string, unknown> => {
258
+ const active = getCurrentRequestContext();
259
+ const out: Record<string, unknown> = {...getCurrentLogContext(), ...(extra ?? {})};
260
+ if (active) {
261
+ out.terrenoRequestLog = buildTerrenoRequestLog(active);
262
+ }
263
+ return out;
264
+ };
265
+
266
+ attachTerrenoDevJsonlTransportIfEnabled(winstonLogger);
267
+
91
268
  // Helper function to send logs to Sentry if enabled
92
- const sendToSentry = (message: string, level: "debug" | "info" | "warn" | "error"): void => {
269
+ const sendToSentry = (
270
+ message: string,
271
+ level: "debug" | "info" | "warn" | "error",
272
+ extraAttributes?: Record<string, string>
273
+ ): void => {
93
274
  if (process.env.USE_SENTRY_LOGGING === "true" && Sentry.logger) {
94
275
  const logWithContext = Sentry.logger[level] as (
95
276
  message: string,
96
277
  attributes?: Record<string, unknown>
97
278
  ) => void;
98
- logWithContext(message, getCurrentLogContext());
279
+ logWithContext(message, mergeSentryLogAttributes(extraAttributes));
99
280
  }
100
281
  };
101
282
 
283
+ /**
284
+ * Global application logger. Each method writes through Winston (console/file transports) and, when
285
+ * `USE_SENTRY_LOGGING=true`, mirrors the line to Sentry with the active request context attached.
286
+ *
287
+ * Prefer {@link createScopedLogger} when a workflow spans multiple log lines that should share a
288
+ * prefix or labels.
289
+ *
290
+ * @example
291
+ * ```typescript
292
+ * import {logger} from "@terreno/api";
293
+ *
294
+ * logger.info("Server started", {port: 4000});
295
+ * logger.warn("Slow query", {ms: 500});
296
+ * logger.error("Failed to process", {error});
297
+ * logger.debug("Request details", {body: req.body});
298
+ *
299
+ * // Convenient `.catch` handler for promises – logs and captures the exception.
300
+ * await chargeCard(id).catch(logger.catch);
301
+ * ```
302
+ */
102
303
  export const logger = {
103
304
  // simple way to log a caught exception. e.g. promise().catch(logger.catch)
104
305
  catch: (e: unknown) => {
@@ -108,7 +309,7 @@ export const logger = {
108
309
  if (e instanceof Error) {
109
310
  Sentry.captureException(e);
110
311
  } else if (Sentry.logger) {
111
- Sentry.logger.error(errorMsg);
312
+ Sentry.logger.error(errorMsg, mergeSentryLogAttributes());
112
313
  }
113
314
  }
114
315
  },
@@ -130,6 +331,261 @@ export const logger = {
130
331
  },
131
332
  };
132
333
 
334
+ const normalizeLogLabels = (
335
+ labels?: Record<string, string | number | boolean | undefined>
336
+ ): Record<string, string> | undefined => {
337
+ if (!labels) {
338
+ return undefined;
339
+ }
340
+ const out: Record<string, string> = {};
341
+ for (const [key, value] of Object.entries(labels)) {
342
+ if (value === undefined || value === null) {
343
+ continue;
344
+ }
345
+ out[key] = String(value);
346
+ }
347
+ return Object.keys(out).length > 0 ? out : undefined;
348
+ };
349
+
350
+ const applyMessagePrefix = (prefix: string | undefined, msg: string): string => {
351
+ const trimmed = prefix?.trim();
352
+ if (!trimmed) {
353
+ return msg;
354
+ }
355
+ return `${trimmed} ${msg}`;
356
+ };
357
+
358
+ /**
359
+ * Logger-shaped object returned by {@link createScopedLogger} and {@link createFeatureFlaggedLogger}.
360
+ * Method signatures match the global {@link logger} so the three are interchangeable at call sites.
361
+ */
362
+ export interface ScopedLogger {
363
+ /** Log a caught exception. Suitable as a promise handler: `promise.catch(log.catch)`. */
364
+ catch: (e: unknown) => void;
365
+ debug: (msg: string, ...args: unknown[]) => void;
366
+ error: (msg: string, ...args: unknown[]) => void;
367
+ info: (msg: string, ...args: unknown[]) => void;
368
+ warn: (msg: string, ...args: unknown[]) => void;
369
+ }
370
+
371
+ export interface CreateScopedLoggerOptions {
372
+ /** Short, stable token prepended to every message (for grep and log Explorer text search). */
373
+ prefix?: string;
374
+ /**
375
+ * Workflow-specific dimensions merged into Winston metadata as `terrenoLabels` (plain-text
376
+ * suffix and structured jsonPayload on cloud transports). Avoid keys that collide with
377
+ * request context or scoped metadata: requestId, jobId, sessionId, userId, traceId, spanId,
378
+ * terrenoLogPrefix, terrenoRequestLog, terrenoLabels.
379
+ */
380
+ labels?: Record<string, string | number | boolean | undefined>;
381
+ }
382
+
383
+ /** Winston child-logger metadata defaults applied to every line a scoped logger emits. */
384
+ interface TerrenoScopedLoggerDefaults {
385
+ terrenoLabels?: Record<string, string>;
386
+ terrenoLogPrefix?: string;
387
+ }
388
+
389
+ const buildScopedLoggerSentryExtras = (
390
+ labels: Record<string, string> | undefined,
391
+ logPrefix: string | undefined
392
+ ): Record<string, string> | undefined => {
393
+ const out: Record<string, string> = {};
394
+ if (logPrefix) {
395
+ out.terrenoLogPrefix = logPrefix;
396
+ }
397
+ if (labels) {
398
+ for (const [key, value] of Object.entries(labels)) {
399
+ out[key] = value;
400
+ }
401
+ }
402
+ return Object.keys(out).length > 0 ? out : undefined;
403
+ };
404
+
405
+ /**
406
+ * Creates a {@link ScopedLogger} that prefixes every message and/or attaches stable `labels` to
407
+ * every line, so multi-step workflows are easy to group and search.
408
+ *
409
+ * - `prefix` is prepended to the human-readable message (easy grep / Log Explorer text search) and
410
+ * also stored as the Winston metadata field `terrenoLogPrefix`.
411
+ * - `labels` are normalized to strings and stored as the Winston metadata field `terrenoLabels`.
412
+ * They appear in the plain-text ` key=value` suffix (see {@link formatLogContextSuffix}) and as
413
+ * discrete fields on structured transports such as `@google-cloud/logging-winston`.
414
+ *
415
+ * Both ride on a Winston **child logger**, so they merge with — and never overwrite — the
416
+ * request/job correlation fields that AsyncLocalStorage injects (`requestId`, `userId`,
417
+ * `terrenoRequestLog`, etc.). Avoid label keys that collide with those framework fields:
418
+ * `requestId`, `jobId`, `sessionId`, `userId`, `traceId`, `spanId`, `terrenoLogPrefix`,
419
+ * `terrenoRequestLog`, `terrenoLabels`.
420
+ *
421
+ * If both `prefix` and `labels` are empty, the global {@link logger} is returned unchanged.
422
+ *
423
+ * @param options - Optional `prefix` token and/or `labels` dimensions for this scope.
424
+ * @returns A scoped logger sharing the same methods as the global {@link logger}.
425
+ * @see {@link createFeatureFlaggedLogger} to gate a scoped logger behind a feature flag.
426
+ *
427
+ * @example Reuse one instance for a whole workflow so every line shares identifiers
428
+ * ```typescript
429
+ * import {createScopedLogger} from "@terreno/api";
430
+ *
431
+ * const log = createScopedLogger({
432
+ * prefix: "[InvoicePay]",
433
+ * labels: {invoiceId: invoice._id.toString(), attempt: String(attemptNumber)},
434
+ * });
435
+ *
436
+ * log.info("Starting capture"); // -> "[InvoicePay] Starting capture invoiceId=... attempt=1 requestId=..."
437
+ * log.warn("Stripe rate limited, backing off");
438
+ * await capture(invoice).catch(log.catch);
439
+ * ```
440
+ */
441
+ export const createScopedLogger = (options: CreateScopedLoggerOptions = {}): ScopedLogger => {
442
+ const trimmedPrefix = options.prefix?.trim() ? options.prefix.trim() : undefined;
443
+ const terrenoLabels = normalizeLogLabels(options.labels);
444
+
445
+ if (!trimmedPrefix && !terrenoLabels) {
446
+ return logger;
447
+ }
448
+
449
+ const childDefaults: TerrenoScopedLoggerDefaults = {};
450
+ if (terrenoLabels) {
451
+ childDefaults.terrenoLabels = terrenoLabels;
452
+ }
453
+ if (trimmedPrefix) {
454
+ childDefaults.terrenoLogPrefix = trimmedPrefix;
455
+ }
456
+
457
+ const base = winstonLogger.child(childDefaults);
458
+ const sentryExtras = (): Record<string, string> | undefined =>
459
+ buildScopedLoggerSentryExtras(terrenoLabels, trimmedPrefix);
460
+
461
+ return {
462
+ catch: (e: unknown) => {
463
+ const errorMsg = applyMessagePrefix(
464
+ trimmedPrefix,
465
+ `Caught: ${(e as Error)?.message} ${(e as Error)?.stack}`
466
+ );
467
+ base.error(errorMsg);
468
+ if (process.env.USE_SENTRY_LOGGING === "true") {
469
+ if (e instanceof Error) {
470
+ Sentry.captureException(e);
471
+ } else if (Sentry.logger) {
472
+ Sentry.logger.error(errorMsg, mergeSentryLogAttributes(sentryExtras()));
473
+ }
474
+ }
475
+ },
476
+ debug: (msg: string, ...args: unknown[]) => {
477
+ const line = applyMessagePrefix(trimmedPrefix, msg);
478
+ base.debug(line, ...args);
479
+ sendToSentry(line, "debug", sentryExtras());
480
+ },
481
+ error: (msg: string, ...args: unknown[]) => {
482
+ const line = applyMessagePrefix(trimmedPrefix, msg);
483
+ base.error(line, ...args);
484
+ sendToSentry(line, "error", sentryExtras());
485
+ },
486
+ info: (msg: string, ...args: unknown[]) => {
487
+ const line = applyMessagePrefix(trimmedPrefix, msg);
488
+ base.info(line, ...args);
489
+ sendToSentry(line, "info", sentryExtras());
490
+ },
491
+ warn: (msg: string, ...args: unknown[]) => {
492
+ const line = applyMessagePrefix(trimmedPrefix, msg);
493
+ base.warn(line, ...args);
494
+ sendToSentry(line, "warn", sentryExtras());
495
+ },
496
+ };
497
+ };
498
+
499
+ export interface CreateFeatureFlaggedLoggerOptions {
500
+ /**
501
+ * When this returns true, log calls are forwarded to `target`. Invoked on every call so flags
502
+ * can flip without process restart (env, database-backed flags, `@terreno/feature-flags`, etc.).
503
+ */
504
+ isEnabled: () => boolean;
505
+ /** Defaults to global `logger`; pass `createScopedLogger({...})` for gated diagnostic blocks. */
506
+ target?: ScopedLogger;
507
+ /**
508
+ * When false (default), `catch` always forwards to `target` so `promise.catch(log.catch)` still
509
+ * records errors when the flag is off. Set true to gate `catch` the same as other levels.
510
+ */
511
+ gateCatch?: boolean;
512
+ }
513
+
514
+ /**
515
+ * Wraps a {@link ScopedLogger} so all `debug` / `info` / `warn` / `error` traffic is dropped while
516
+ * `isEnabled()` returns false. Use it to keep verbose diagnostics in the code but silent until a
517
+ * flag turns them on — no redeploy required.
518
+ *
519
+ * `isEnabled` is evaluated on **every** call, so it can read any feature-flag source: an
520
+ * environment variable, a cached/remote flag map, or a call into `@terreno/feature-flags` from your
521
+ * app. (`@terreno/api` deliberately does not import `@terreno/feature-flags` to avoid a package
522
+ * cycle — you supply the predicate.)
523
+ *
524
+ * @param options - The `isEnabled` predicate plus an optional `target` logger and `gateCatch`.
525
+ * @returns A scoped logger that forwards to `target` only while the flag is enabled.
526
+ * @see {@link createScopedLogger} for the usual `target`.
527
+ *
528
+ * @example Gate a scoped logger behind an env var (flips live, no restart)
529
+ * ```typescript
530
+ * import {createFeatureFlaggedLogger, createScopedLogger} from "@terreno/api";
531
+ *
532
+ * const jobLog = createFeatureFlaggedLogger({
533
+ * isEnabled: () => process.env.JOB_TRACE_LOGS === "true",
534
+ * target: createScopedLogger({prefix: "[Job]", labels: {jobName: "nightly-sync"}}),
535
+ * });
536
+ *
537
+ * jobLog.info("step 1"); // silent unless JOB_TRACE_LOGS=true
538
+ * ```
539
+ *
540
+ * @example Drive it from `@terreno/feature-flags` in app code
541
+ * ```typescript
542
+ * const debugLog = createFeatureFlaggedLogger({
543
+ * isEnabled: () => flags.isEnabled("debug.billing"),
544
+ * target: createScopedLogger({prefix: "[Billing]"}),
545
+ * gateCatch: true, // also silence `catch` while the flag is off (default: false)
546
+ * });
547
+ * ```
548
+ */
549
+ export const createFeatureFlaggedLogger = (
550
+ options: CreateFeatureFlaggedLoggerOptions
551
+ ): ScopedLogger => {
552
+ const target = options.target ?? logger;
553
+ const gateCatch = options.gateCatch ?? false;
554
+
555
+ return {
556
+ catch: (e: unknown): void => {
557
+ if (gateCatch && !options.isEnabled()) {
558
+ return;
559
+ }
560
+ target.catch(e);
561
+ },
562
+ debug: (msg: string, ...args: unknown[]): void => {
563
+ if (!options.isEnabled()) {
564
+ return;
565
+ }
566
+ target.debug(msg, ...args);
567
+ },
568
+ error: (msg: string, ...args: unknown[]): void => {
569
+ if (!options.isEnabled()) {
570
+ return;
571
+ }
572
+ target.error(msg, ...args);
573
+ },
574
+ info: (msg: string, ...args: unknown[]): void => {
575
+ if (!options.isEnabled()) {
576
+ return;
577
+ }
578
+ target.info(msg, ...args);
579
+ },
580
+ warn: (msg: string, ...args: unknown[]): void => {
581
+ if (!options.isEnabled()) {
582
+ return;
583
+ }
584
+ target.warn(msg, ...args);
585
+ },
586
+ };
587
+ };
588
+
133
589
  export interface LoggingOptions {
134
590
  level?: "debug" | "info" | "warn" | "error";
135
591
  transports?: winston.transport[];
@@ -145,10 +601,13 @@ export interface LoggingOptions {
145
601
  logSlowRequestsReadMs?: number;
146
602
  // The threshold in ms for logging slow requests. Defaults to 500ms for write requests.
147
603
  logSlowRequestsWriteMs?: number;
604
+ /** When true, skips the dev JSONL file under `.terreno/logs/app.log`. */
605
+ disableTerrenoDevJsonlLog?: boolean;
148
606
  }
149
607
 
150
608
  export const setupLogging = (options?: LoggingOptions): void => {
151
609
  winstonLogger.clear();
610
+ terrenoDevJsonlAttached = false;
152
611
  if (!options?.disableConsoleLogging) {
153
612
  const formats: winston.Logform.Format[] = [addRequestContextFormat(), winston.format.simple()];
154
613
  if (!options?.disableConsoleColors) {
@@ -217,4 +676,8 @@ export const setupLogging = (options?: LoggingOptions): void => {
217
676
  winstonLogger.add(transport);
218
677
  }
219
678
  }
679
+
680
+ attachTerrenoDevJsonlTransportIfEnabled(winstonLogger, {
681
+ disable: options?.disableTerrenoDevJsonlLog === true,
682
+ });
220
683
  };
@@ -1,8 +1,10 @@
1
1
  import {beforeEach, describe, expect, it, type Mock, mock} from "bun:test";
2
2
  import * as Sentry from "@sentry/bun";
3
- import type {NextFunction, Request, Response} from "express";
3
+ import express, {type NextFunction, type Request, type Response} from "express";
4
+ import supertest from "supertest";
4
5
 
5
- import {sentryAppVersionMiddleware} from "./middleware";
6
+ import {jsonResponseRequestIdMiddleware, sentryAppVersionMiddleware} from "./middleware";
7
+ import {requestContextMiddleware} from "./requestContext";
6
8
 
7
9
  const buildReq = (headers: Record<string, string | undefined>): Request => {
8
10
  return {
@@ -69,3 +71,73 @@ describe("sentryAppVersionMiddleware", () => {
69
71
  expect(next.mock.calls[0]).toHaveLength(0);
70
72
  });
71
73
  });
74
+
75
+ describe("jsonResponseRequestIdMiddleware", () => {
76
+ const buildStackedApp = (): express.Application => {
77
+ const app = express();
78
+ app.use(requestContextMiddleware);
79
+ app.use(jsonResponseRequestIdMiddleware);
80
+ app.get("/object", (_req, res) => {
81
+ return res.json({hello: "world"});
82
+ });
83
+ app.get("/array", (_req, res) => {
84
+ return res.json([1, 2]);
85
+ });
86
+ app.get("/openapi.json", (_req, res) => {
87
+ return res.json({openapi: "3.0.0", paths: {}});
88
+ });
89
+ app.get("/openapi/components/schemas/Food.json", (_req, res) => {
90
+ return res.json({description: "A food", type: "object"});
91
+ });
92
+ app.get("/openapi/validate", (_req, res) => {
93
+ return res.json({document: {openapi: "3.0.0"}, valid: true});
94
+ });
95
+ return app;
96
+ };
97
+
98
+ it("adds requestId to object JSON bodies and matches X-Request-ID header", async () => {
99
+ const app = buildStackedApp();
100
+ const res = await supertest(app).get("/object").expect(200);
101
+ expect(res.body.hello).toBe("world");
102
+ expect(res.body.requestId).toBeDefined();
103
+ expect(res.body.requestId).toBe(res.headers["x-request-id"]);
104
+ });
105
+
106
+ it("does not wrap JSON array bodies", async () => {
107
+ const app = buildStackedApp();
108
+ const res = await supertest(app).get("/array").expect(200);
109
+ expect(res.body).toEqual([1, 2]);
110
+ expect(res.headers["x-request-id"]).toBeDefined();
111
+ });
112
+
113
+ it("does not inject requestId into GET /openapi.json bodies", async () => {
114
+ const app = buildStackedApp();
115
+ const res = await supertest(app).get("/openapi.json").expect(200);
116
+ expect(res.body).toEqual({openapi: "3.0.0", paths: {}});
117
+ expect(res.body.requestId).toBeUndefined();
118
+ });
119
+
120
+ it("does not inject requestId into GET /openapi/components/...json bodies", async () => {
121
+ const app = buildStackedApp();
122
+ const res = await supertest(app).get("/openapi/components/schemas/Food.json").expect(200);
123
+ expect(res.body).toEqual({description: "A food", type: "object"});
124
+ expect(res.body.requestId).toBeUndefined();
125
+ });
126
+
127
+ it("does not inject requestId into GET /openapi/validate bodies", async () => {
128
+ const app = buildStackedApp();
129
+ const res = await supertest(app).get("/openapi/validate").expect(200);
130
+ expect(res.body).toEqual({document: {openapi: "3.0.0"}, valid: true});
131
+ expect(res.body.requestId).toBeUndefined();
132
+ });
133
+
134
+ it("uses incoming X-Request-ID on wrapped object responses", async () => {
135
+ const app = buildStackedApp();
136
+ const res = await supertest(app)
137
+ .get("/object")
138
+ .set("X-Request-ID", "client-rid-99")
139
+ .expect(200);
140
+ expect(res.body.requestId).toBe("client-rid-99");
141
+ expect(res.headers["x-request-id"]).toBe("client-rid-99");
142
+ });
143
+ });