|
9 | 9 | * is the "two spellings of one thing" shape this repo pays for repeatedly. It |
10 | 10 | * is deliberately NOT re-exported from the package index: an internal shim, not |
11 | 11 | * a logging API. |
| 12 | + * |
| 13 | + * ── The declared level seam (#15484) ────────────────────────────────────── |
| 14 | + * |
| 15 | + * Every fault this package reports goes through `logError`, and `logError` |
| 16 | + * hands its varargs — an `Error` object among them — straight to |
| 17 | + * `console.error`, which Node formats with the error's full stack and its |
| 18 | + * `[cause]` chain. Measured on one green `packages/rest` run: 2,095 indented |
| 19 | + * `at ` frame lines, 36.7% of the captured output, and 100% of them arrive |
| 20 | + * through this file (1,197 from `error-response.ts`, 841 from |
| 21 | + * `rest-server.ts`, 57 from `cause` chains). |
| 22 | + * |
| 23 | + * ⛔ That volume is NOT a defect and the frames are NOT dead weight. They are |
| 24 | + * read, and the repo pins that they are read: at `logWithheldServerFault` the |
| 25 | + * client is told nothing and the log is the operator's only copy of the driver |
| 26 | + * text — which lives on `error.cause` and is printed only because a whole |
| 27 | + * `Error` is passed; at `logUnexpectedRouteError` the frames are the only |
| 28 | + * location diagnostic a bare `TypeError` has. Four assertions across |
| 29 | + * `rest-5xx-message-sanitization.test.ts` and `rest-expected-error-logging.test.ts` |
| 30 | + * hold that, by asserting the IDENTITY of the `Error` reaching `console.error` |
| 31 | + * (#5437 / #4886 / #5489). ⛔ Do not "quieten" this shim by formatting the |
| 32 | + * `Error` down to a string or a summary — that is the repair those pins exist |
| 33 | + * to stop, and it deletes from the LOG what was deliberately withheld from the |
| 34 | + * CLIENT. |
| 35 | + * |
| 36 | + * So the seam is a DECLARATION, not a quieter product: `OS_REST_LOG` names a |
| 37 | + * level, exactly as `OS_REGISTRY_LOG` does for `@objectstack/objectql`'s |
| 38 | + * `SchemaRegistry`, with the same five-level vocabulary and the same shipped |
| 39 | + * `'info'` default. ⛔ The shipped default is unchanged and must stay |
| 40 | + * unchanged: a reported fault keeps printing the full `Error` — message, |
| 41 | + * `cause` chain and frames — for every real caller. Suppression is only ever |
| 42 | + * something a HARNESS opts into, declared where a gate can read it |
| 43 | + * (`scripts/check-rest-log-declared.mjs`), never the product's default. |
| 44 | + * |
| 45 | + * An operator learns the variable from `packages/rest/README.md`, from this |
| 46 | + * block, and from `docs/audits/2026-09-test-log-volume-census.md`; an |
| 47 | + * unrecognised value falls back to the default rather than silently silencing |
| 48 | + * anything, which is the same failure direction `OS_REGISTRY_LOG` chose. |
| 49 | + */ |
| 50 | + |
| 51 | +/** |
| 52 | + * The levels `OS_REST_LOG` accepts — deliberately the same vocabulary as |
| 53 | + * `@objectstack/objectql`'s `REGISTRY_LOG_LEVELS`, so the two declarations are |
| 54 | + * one contract with two populations rather than two ad-hoc environment |
| 55 | + * variables. Ordered loudest-first; `scripts/check-rest-log-declared.mjs` |
| 56 | + * READS this array rather than copying it. |
| 57 | + */ |
| 58 | +export const REST_LOG_LEVELS = ['debug', 'info', 'warn', 'error', 'silent'] as const; |
| 59 | + |
| 60 | +/** One of {@link REST_LOG_LEVELS}. */ |
| 61 | +export type RestLogLevel = (typeof REST_LOG_LEVELS)[number]; |
| 62 | + |
| 63 | +/** |
| 64 | + * The SHIPPED default — what every real caller gets when `OS_REST_LOG` is |
| 65 | + * unset or unrecognised. ⛔ Never lower this to quieten a log: at any level |
| 66 | + * below `'warn'` this package stops reporting faults it is the only reporter |
| 67 | + * of. `rest-log-declared-level-seam.test.ts` pins it. |
| 68 | + */ |
| 69 | +export const REST_LOG_DEFAULT_LEVEL: RestLogLevel = 'info'; |
| 70 | + |
| 71 | +/** Emission threshold per level: a site emits when its own rank <= the level's. */ |
| 72 | +const LEVEL_RANK: Readonly<Record<RestLogLevel, number>> = { |
| 73 | + debug: 4, info: 3, warn: 2, error: 1, silent: 0, |
| 74 | +}; |
| 75 | + |
| 76 | +/** `logError`'s rank, and `logWarn`'s — a site speaks while the level reaches it. */ |
| 77 | +const ERROR_RANK = LEVEL_RANK.error; |
| 78 | +const WARN_RANK = LEVEL_RANK.warn; |
| 79 | + |
| 80 | +/** |
| 81 | + * The level in force right now. |
| 82 | + * |
| 83 | + * Read from the environment on every call, not memoised at module load: a |
| 84 | + * harness that declares the level through vitest's `env` block, and a test that |
| 85 | + * restores the shipped default around one assertion, both have to be observed |
| 86 | + * by a module that may already be imported. This is a fault path — it runs once |
| 87 | + * per reported fault, never per request. |
12 | 88 | */ |
| 89 | +export function restLogLevel(): RestLogLevel { |
| 90 | + const raw = String((globalThis as any).process?.env?.OS_REST_LOG ?? '').toLowerCase(); |
| 91 | + return (REST_LOG_LEVELS as readonly string[]).includes(raw) |
| 92 | + ? (raw as RestLogLevel) |
| 93 | + : REST_LOG_DEFAULT_LEVEL; |
| 94 | +} |
13 | 95 |
|
14 | 96 | // Node-safe logger — avoids importing 'console' which is absent from ES2020 lib typings. |
15 | | -export const logError = (...args: unknown[]) => (globalThis as any).console?.error(...args); |
16 | | -export const logWarn = (...args: unknown[]) => ((globalThis as any).console?.warn ?? (globalThis as any).console?.error)?.(...args); |
| 97 | +export const logError = (...args: unknown[]) => { |
| 98 | + if (LEVEL_RANK[restLogLevel()] < ERROR_RANK) return; |
| 99 | + (globalThis as any).console?.error(...args); |
| 100 | +}; |
| 101 | +export const logWarn = (...args: unknown[]) => { |
| 102 | + if (LEVEL_RANK[restLogLevel()] < WARN_RANK) return; |
| 103 | + ((globalThis as any).console?.warn ?? (globalThis as any).console?.error)?.(...args); |
| 104 | +}; |
0 commit comments