@ultimat3/core 29.4.0 → 30.1.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 (89) hide show
  1. package/CLAUDE.md +45 -81
  2. package/README.md +137 -10
  3. package/package.json +19 -3
  4. package/src/address-class.ts +53 -12
  5. package/src/aws-sigv4.ts +104 -8
  6. package/src/breaker.ts +177 -0
  7. package/src/client-dispatch.ts +76 -17
  8. package/src/client-flight.ts +4 -4
  9. package/src/client-paths-pins.ts +92 -0
  10. package/src/client-problem.ts +25 -1
  11. package/src/client-scope.ts +3 -1
  12. package/src/client-stream.ts +106 -0
  13. package/src/client-transport.ts +16 -2
  14. package/src/config-assets.ts +64 -0
  15. package/src/config-client-errors.ts +132 -0
  16. package/src/config-defaults.ts +18 -4
  17. package/src/config-failure-capture.ts +70 -0
  18. package/src/config-health.ts +8 -0
  19. package/src/config-islands.ts +1 -1
  20. package/src/config-keys.ts +2 -0
  21. package/src/config-site.ts +16 -0
  22. package/src/config-time.ts +40 -0
  23. package/src/config.ts +37 -26
  24. package/src/context.ts +92 -10
  25. package/src/core-error-codes.ts +19 -0
  26. package/src/cursor.ts +7 -3
  27. package/src/deadline-signal.ts +57 -0
  28. package/src/declared-errors-openapi.ts +70 -0
  29. package/src/declared-errors-scan.ts +79 -0
  30. package/src/declared-errors.ts +185 -0
  31. package/src/define-error.ts +200 -0
  32. package/src/deprecation.ts +14 -3
  33. package/src/drain-deadline.ts +22 -0
  34. package/src/env.ts +8 -3
  35. package/src/error-fingerprint.ts +95 -0
  36. package/src/error-reporter-chain.ts +45 -0
  37. package/src/error-reporter-sentry.ts +4 -0
  38. package/src/error-reporter.ts +14 -1
  39. package/src/error-status-declared.ts +82 -0
  40. package/src/exercise-errors.ts +41 -0
  41. package/src/exercise-run.ts +176 -0
  42. package/src/exports/error-contract.ts +7 -0
  43. package/src/exports/observability.ts +26 -0
  44. package/src/failure-capture-flush.ts +93 -0
  45. package/src/failure-capture-mask.ts +54 -0
  46. package/src/failure-capture.ts +205 -0
  47. package/src/fill-wire.ts +30 -0
  48. package/src/finite-option.ts +7 -0
  49. package/src/health-disclosure.ts +2 -2
  50. package/src/hmac.ts +47 -0
  51. package/src/index.ts +172 -9
  52. package/src/json-storable.ts +21 -0
  53. package/src/keyed-fingerprint.ts +3 -7
  54. package/src/lifecycle.ts +5 -1
  55. package/src/logger.ts +1 -1
  56. package/src/otlp-metric-exporter.ts +7 -1
  57. package/src/otlp-post.ts +147 -0
  58. package/src/otlp-span-exporter.ts +7 -1
  59. package/src/otlp.ts +4 -37
  60. package/src/outbound-attempt.ts +235 -0
  61. package/src/outbound-dial.ts +103 -0
  62. package/src/outbound-errors.ts +193 -0
  63. package/src/outbound-hops.ts +135 -0
  64. package/src/outbound-read.ts +103 -0
  65. package/src/outbound-subpath.ts +26 -0
  66. package/src/outbound-types.ts +108 -0
  67. package/src/outbound.ts +223 -0
  68. package/src/page-meta.ts +8 -0
  69. package/src/page.ts +9 -0
  70. package/src/record-envelope-openapi.ts +6 -0
  71. package/src/record-envelope.ts +33 -9
  72. package/src/record-sink.ts +13 -0
  73. package/src/redis-client.ts +78 -0
  74. package/src/registrar.ts +11 -0
  75. package/src/render-node.ts +13 -0
  76. package/src/render-nodes.ts +137 -0
  77. package/src/retry-loop.ts +197 -0
  78. package/src/retry.ts +47 -145
  79. package/src/roles.ts +2 -2
  80. package/src/route-path-merge.ts +55 -0
  81. package/src/route-path-template.ts +124 -0
  82. package/src/runtime-metrics.ts +16 -1
  83. package/src/same-origin.ts +18 -2
  84. package/src/session-lost.ts +115 -0
  85. package/src/single-flight.ts +75 -40
  86. package/src/source-mask.ts +36 -2
  87. package/src/tool-description.ts +29 -0
  88. package/src/visible-interval.ts +153 -0
  89. package/src/webhook-signature.ts +9 -11
@@ -9,6 +9,7 @@ import { DEFAULT_ENVIRONMENT, type Environment, tryResolveEnvironment } from './
9
9
  import { renderThrowable } from './error-render';
10
10
  import { failureStack } from './error-stack';
11
11
  import { toUltimateError } from './errors';
12
+ import { releaseHeldFailure } from './failure-capture-flush';
12
13
  import { logger } from './logger';
13
14
  import type { Role } from './roles';
14
15
  import { currentSpanContext, type SpanResource, serviceResource } from './telemetry';
@@ -20,7 +21,7 @@ export type ErrorSeverity = 'warning' | 'error' | 'fatal';
20
21
  * free-form string here is the same unbounded-cardinality mistake that a user id in a metric
21
22
  * label is. A new surface adds a member; it never passes a string of its own.
22
23
  */
23
- export const ERROR_SOURCES = ['http', 'job', 'realtime', 'cli', 'process'] as const;
24
+ export const ERROR_SOURCES = ['http', 'job', 'realtime', 'cli', 'process', 'browser'] as const;
24
25
 
25
26
  export type ErrorSource = (typeof ERROR_SOURCES)[number];
26
27
 
@@ -115,6 +116,15 @@ export function configureErrorReporting(options: ErrorReportingOptions): void {
115
116
  if (options.enabled !== undefined) enabled = options.enabled;
116
117
  }
117
118
 
119
+ /**
120
+ * The reporter installed now. Read by a boot that adds its own (`@ultimat3/cli`'s `SENTRY_DSN`
121
+ * transport) so it composes with an app's sink through `chainErrorReporters` instead of
122
+ * replacing it — the app's modules import, and install, before that boot line runs.
123
+ */
124
+ export function installedErrorReporter(): ErrorReporter {
125
+ return reporter;
126
+ }
127
+
118
128
  export function resetErrorReporting(): void {
119
129
  reporter = noopErrorReporter;
120
130
  clock = systemClock;
@@ -198,6 +208,9 @@ export function errorReport(error: unknown, options: ReportErrorOptions): ErrorR
198
208
  * must not turn one failure into two, and the surface that caught this has already logged it.
199
209
  */
200
210
  export function reportError(error: unknown, options: ReportErrorOptions): void {
211
+ // First, and whatever `enabled` says: a 5xx `invoke` held its bundle on this value, and this is
212
+ // where it learns its group (`failure-capture-flush.ts`). Nothing held costs one `WeakMap` read.
213
+ releaseHeldFailure(error, () => errorReport(error, options));
201
214
  if (!enabled) return;
202
215
  try {
203
216
  reporter.report(errorReport(error, options));
@@ -0,0 +1,82 @@
1
+ // Single responsibility: the APP's half of the HTTP status table — codes an app owns, mapped to the
2
+ // status a response answers. Here rather than in `@ultimat3/http` because `defineError` (core)
3
+ // writes it and core may not import upward; `@ultimat3/http`'s `registerErrorStatus` writes the same
4
+ // table, and its `declaredStatusFor` reads the framework's closed table FIRST, so no row here moves one.
5
+ //
6
+ // INTERNAL: reached through `@ultimat3/core/internal/error-status`, never the barrel — an app has one
7
+ // writer of its own (`defineError`) and one for a re-raised library code (`registerErrorStatus`).
8
+
9
+ import { renderCauseValue, renderFixShellArg } from './error-render';
10
+ import { UltimateError } from './errors';
11
+
12
+ /** A `Map`, never an object literal: the key is a caller's string, and `'toString'` is one. */
13
+ const APP_ERROR_STATUS = new Map<string, number>();
14
+
15
+ /**
16
+ * Every code `defineError` declared, with its status — `undefined` for an off-socket one. Beside the
17
+ * table, not in `define-error.ts`, so a reset and a snapshot cover both: a record that outlived its
18
+ * table refused a test's fresh declaration of the same code.
19
+ */
20
+ const DEFINED = new Map<string, number | undefined>();
21
+
22
+ /** Is `status` a value a `Response` can carry? */
23
+ export const isHttpStatus = (status: unknown): status is number =>
24
+ typeof status === 'number' && Number.isInteger(status) && status >= 100 && status <= 599;
25
+
26
+ /** The status this app declared for `code`, `undefined` when it declared none. */
27
+ export const appErrorStatus = (code: string): number | undefined => APP_ERROR_STATUS.get(code);
28
+
29
+ /**
30
+ * Write one row. The two writers — `defineError` and `registerErrorStatus` — each refuse first with
31
+ * their own code, so this is the last line, not the first: a status outside 100-599 or a second,
32
+ * different status for one code can never reach the table whoever forgot to check.
33
+ */
34
+ export function declareAppErrorStatus(code: string, status: number): void {
35
+ const existing = APP_ERROR_STATUS.get(code);
36
+ if (!isHttpStatus(status) || (existing !== undefined && existing !== status)) {
37
+ throw new UltimateError({
38
+ code: 'X_ERROR_DECLARATION_INVALID',
39
+ cause: `${code} cannot answer ${renderCauseValue(status)}: ${
40
+ existing === undefined
41
+ ? 'that is not an HTTP status (100-599)'
42
+ : `this app already declared it as ${existing}`
43
+ }`,
44
+ fix: `grep -rn ${renderFixShellArg(code, '<CODE>')} apps packages # keep one declaration, one status, then x verify --only errors`,
45
+ meta: { code },
46
+ });
47
+ }
48
+ APP_ERROR_STATUS.set(code, status);
49
+ }
50
+
51
+ /** Whether `defineError` declared `code`, and with which status (`undefined` = off-socket). */
52
+ export const definedErrorStatus = (
53
+ code: string,
54
+ ): { readonly status: number | undefined } | undefined =>
55
+ DEFINED.has(code) ? { status: DEFINED.get(code) } : undefined;
56
+
57
+ /** `defineError`'s record of a declaration it accepted. */
58
+ export const recordDefinedError = (code: string, status: number | undefined): void => {
59
+ DEFINED.set(code, status);
60
+ };
61
+
62
+ /** Test seam. Production declares once at boot and never undeclares. */
63
+ export const resetAppErrorStatus = (): void => {
64
+ APP_ERROR_STATUS.clear();
65
+ DEFINED.clear();
66
+ };
67
+
68
+ /**
69
+ * Test-only: capture the table and get the undo back — `errorCodeSnapshot`'s shape, for the same
70
+ * reason. Bun runs one process per suite, every app module declares at import, and a bare
71
+ * `resetAppErrorStatus()` strips rows the file never wrote.
72
+ */
73
+ export function appErrorStatusSnapshot(): () => void {
74
+ const saved = new Map(APP_ERROR_STATUS);
75
+ const defined = new Map(DEFINED);
76
+ return () => {
77
+ APP_ERROR_STATUS.clear();
78
+ for (const [code, status] of saved) APP_ERROR_STATUS.set(code, status);
79
+ DEFINED.clear();
80
+ for (const [code, status] of defined) DEFINED.set(code, status);
81
+ };
82
+ }
@@ -0,0 +1,41 @@
1
+ // Single responsibility: the two refusals the shared exercise loop raises BEFORE a run, so an
2
+ // action's and a query's exercise refuse the same way. Off the socket: each is raised in a test
3
+ // process or a script, never served. The verdict codes (`X_CONTRACT_DEFECT`, …) stay action's.
4
+
5
+ import { renderFixShellArg } from './error-render';
6
+ import { UltimateError } from './errors';
7
+
8
+ /**
9
+ * Not `development` or `test`: a deployed process. The exercise calls a handler with generated
10
+ * input, and mail, storage, outbound fetch and a non-outbox enqueue are not rolled back with the
11
+ * transaction — against a deploy those are real.
12
+ */
13
+ export class ExerciseEnvironmentError extends UltimateError {
14
+ static readonly code = 'X_CONTRACT_EXERCISE_ENVIRONMENT';
15
+ override readonly name = 'ExerciseEnvironmentError';
16
+ constructor(subject: string, environment: string) {
17
+ super({
18
+ code: ExerciseEnvironmentError.code,
19
+ cause: `${subject} was exercised in the ${environment} environment: generated input would run against a deploy, and mail, storage, outbound fetch and non-outbox jobs are never rolled back`,
20
+ fix: `bun test <the file calling ${renderFixShellArg(subject, '<name>')}.contract()> # under bun test (NODE_ENV=test) — never with ULTIMATE_ENV=${environment}`,
21
+ meta: { environment },
22
+ });
23
+ }
24
+ }
25
+
26
+ /**
27
+ * No transaction can be opened that the writes would join, outside `test`: the handler's writes
28
+ * would land on the process's lazy pool (`DATABASE_URL`) and be kept.
29
+ */
30
+ export class ExerciseUnscopedError extends UltimateError {
31
+ static readonly code = 'X_CONTRACT_EXERCISE_UNSCOPED';
32
+ override readonly name = 'ExerciseUnscopedError';
33
+ constructor(subject: string, environment: string) {
34
+ super({
35
+ code: ExerciseUnscopedError.code,
36
+ cause: `${subject} was exercised in ${environment} with no database client installed and no transaction open, so its writes could not be rolled back`,
37
+ fix: `setDbClient(<a test database client>) from @ultimat3/db before ${renderFixShellArg(subject, '<name>')}.contract() # or run it under bun test (NODE_ENV=test)`,
38
+ meta: { environment },
39
+ });
40
+ }
41
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * The contract step's exercise loop, once for `@ultimat3/action` and `@ultimat3/query` (both tier
3
+ * 3, neither may import the other): seeded draws, the verdict on each run, the replay command and
4
+ * the refusals before a run. Each adapter supplies the draw, the schema check and one attempt.
5
+ */
6
+
7
+ import { assert } from './assert';
8
+ import type { ErrorStatusOf } from './declared-errors';
9
+ import { type Environment, resolveEnvironment } from './environment';
10
+ import { isFixShellSafe, renderThrowable } from './error-render';
11
+ import { isUltimateError } from './errors';
12
+ import { ExerciseEnvironmentError, ExerciseUnscopedError } from './exercise-errors';
13
+ import { fnv1a } from './fnv1a';
14
+ import { isJsonObject } from './json-object';
15
+
16
+ /** Runs per exercise by default: sixteen invocations, four of them on the IR's bounds. */
17
+ export const EXERCISE_RUNS = 16;
18
+ /** The most a caller may raise `runs` to: every run is a real invocation. */
19
+ export const EXERCISE_RUNS_MAX = 64;
20
+
21
+ type Env = Readonly<Record<string, string | undefined>>;
22
+
23
+ /** One run's end. `reached` is the adapter's probe: did the handler (or `sql()`) start? */
24
+ export type ExerciseAttempt =
25
+ | { readonly ok: true; readonly reached: boolean }
26
+ | { readonly ok: false; readonly reached: boolean; readonly error: unknown };
27
+
28
+ /** What a passing exercise saw. */
29
+ export interface ExerciseReport {
30
+ readonly runs: number;
31
+ readonly reached: number;
32
+ readonly refused: number;
33
+ readonly outcomes: readonly string[];
34
+ }
35
+
36
+ /** What the adapter hands the loop. `env` exists for a test; a caller leaves it out. */
37
+ export interface ExerciseSubject {
38
+ readonly name: string;
39
+ readonly runs?: number | undefined;
40
+ /** How an outcome reads when the run stopped before the probe: `stopped before sql()`. */
41
+ readonly stoppedLabel: string;
42
+ draw(seed: number, run: number): unknown;
43
+ readonly bind?: ((drawn: unknown) => unknown) | undefined;
44
+ accepts(input: unknown): Promise<boolean>;
45
+ attempt(input: unknown): Promise<ExerciseAttempt>;
46
+ readonly statusOf: ErrorStatusOf;
47
+ readonly env?: Env | undefined;
48
+ /** A write: whether a transaction undoes each attempt. Omitted by a read, which writes nothing. */
49
+ readonly scoped?: boolean | undefined;
50
+ }
51
+
52
+ /** The loop's answer. Returned, never thrown: the codes are the adapter's to raise. */
53
+ export type ExerciseVerdict =
54
+ | { readonly kind: 'pass'; readonly report: ExerciseReport }
55
+ | {
56
+ readonly kind: 'defect';
57
+ readonly run: number;
58
+ readonly input: unknown;
59
+ readonly defect: string;
60
+ readonly error: unknown;
61
+ }
62
+ | { readonly kind: 'unreached'; readonly outcomes: readonly string[] }
63
+ | { readonly kind: 'drift' };
64
+
65
+ /** A thrown value's verdict: `null` is a pass (a coded refusal under 500), else what was wrong. */
66
+ export function exerciseDefect(error: unknown, statusOf: ErrorStatusOf): string | null {
67
+ if (!isUltimateError(error)) return `threw an uncoded ${renderThrowable(error)}`;
68
+ if (error.code === 'X_OUTPUT_INVALID') return 'returned a value its output schema rejects';
69
+ // An unmapped code answers 500 on the wire, so it is a 500 here.
70
+ const status = statusOf(error.code) ?? 500;
71
+ return status >= 500 ? `answered ${error.code}, a ${status}` : null;
72
+ }
73
+
74
+ /** The host a sealed-network refusal names, read off its cause (`GET https://host/... was ...`). */
75
+ export function sealedHost(cause: string): string {
76
+ const url = /^\S+ (\S+) was not mocked/.exec(cause)?.[1];
77
+ if (url === undefined || !URL.canParse(url)) return 'an unnamed host';
78
+ return new URL(url).host;
79
+ }
80
+
81
+ /**
82
+ * `bun test <this file> -t '<testName>'` when a test file is running — the seed is the name, so
83
+ * that replays the exact run — and the step otherwise. Single quotes are safe only around a name
84
+ * `isFixShellSafe` already carries verbatim; `testName` is that name plus fixed words.
85
+ */
86
+ export function exerciseReplay(name: string, testName: string): string {
87
+ // POSIX separators on every platform: a Windows `Bun.main` is `C:\…\x.test.ts`, which neither
88
+ // strips against the cwd nor travels through a shell as written.
89
+ const posix = (path: string): string => path.replaceAll('\\', '/');
90
+ const main = posix(Bun.main);
91
+ const file = main.replace(`${posix(process.cwd())}/`, '');
92
+ if (!main.includes('.test.') || !isFixShellSafe(file) || !isFixShellSafe(name)) {
93
+ return 'x verify --only contract';
94
+ }
95
+ return `bun test ${file} -t '${testName}'`;
96
+ }
97
+
98
+ /**
99
+ * Which deploy the exercise is in. Fails CLOSED: a process naming neither `ULTIMATE_ENV` nor
100
+ * `NODE_ENV` is read as `production`, where the shared default is `development` — an unnamed
101
+ * deploy must not run generated input. `bun test` names `test`, so a test run is unaffected.
102
+ */
103
+ const exerciseEnvironment = (env: Env | undefined): Environment =>
104
+ resolveEnvironment({ env, fallback: 'production' });
105
+
106
+ /**
107
+ * Refuse when no transaction would undo the writes — no client installed, none open — anywhere
108
+ * but `test`, where the store is memory. Outside it the handler's writes reach `DATABASE_URL`.
109
+ */
110
+ export function assertExerciseScoped(name: string, scoped: boolean, env?: Env): void {
111
+ const environment = exerciseEnvironment(env);
112
+ if (!scoped && environment !== 'test') throw new ExerciseUnscopedError(name, environment);
113
+ }
114
+
115
+ /** Exercise `subject`: refuse a deploy, then draw, check, attempt and judge each run. */
116
+ export async function runExercise(subject: ExerciseSubject): Promise<ExerciseVerdict> {
117
+ const { name } = subject;
118
+ const environment = exerciseEnvironment(subject.env);
119
+ if (environment !== 'development' && environment !== 'test') {
120
+ throw new ExerciseEnvironmentError(name, environment);
121
+ }
122
+ if (subject.scoped !== undefined) assertExerciseScoped(name, subject.scoped, subject.env);
123
+ const runs = subject.runs ?? EXERCISE_RUNS;
124
+ assert(
125
+ Number.isInteger(runs) && runs >= 1 && runs <= EXERCISE_RUNS_MAX,
126
+ `${name}: exercise runs must be a whole number from 1 to ${EXERCISE_RUNS_MAX}`,
127
+ `${name}.contract({ exercise: { actor, runs: ${EXERCISE_RUNS} } })`,
128
+ );
129
+ const seed = fnv1a(name);
130
+ const outcomes: string[] = [];
131
+ let reached = 0;
132
+ let refused = 0;
133
+ for (let run = 0; run < runs; run += 1) {
134
+ const drawn = subject.draw(seed, run);
135
+ const input = subject.bind === undefined ? drawn : await subject.bind(drawn);
136
+ if (!(await subject.accepts(input))) {
137
+ refused += 1;
138
+ outcomes.push(`run ${run}: input refused by its own schema`);
139
+ continue;
140
+ }
141
+ const attempt = await subject.attempt(input);
142
+ if (attempt.ok) {
143
+ reached += attempt.reached ? 1 : 0;
144
+ outcomes.push(`run ${run}: answered`);
145
+ continue;
146
+ }
147
+ // Before the verdict: the sealed network is the test's wall, not the handler's failure, and
148
+ // its code maps to no status, so `exerciseDefect` would read it as a 500.
149
+ const { error } = attempt;
150
+ if (isUltimateError(error) && error.code === 'X_TEST_NETWORK_SEALED') {
151
+ outcomes.push(`run ${run}: network sealed (${sealedHost(error.cause)})`);
152
+ continue;
153
+ }
154
+ const defect = exerciseDefect(error, subject.statusOf);
155
+ if (defect !== null) return { kind: 'defect', run, input, defect, error };
156
+ const code = isUltimateError(error) ? error.code : 'uncoded';
157
+ reached += attempt.reached ? 1 : 0;
158
+ outcomes.push(`run ${run}: ${attempt.reached ? 'refused' : subject.stoppedLabel} with ${code}`);
159
+ }
160
+ if (refused === runs) return { kind: 'drift' };
161
+ if (reached === 0) return { kind: 'unreached', outcomes };
162
+ return { kind: 'pass', report: { runs, reached, refused, outcomes } };
163
+ }
164
+
165
+ /** A generated value an override binds into: an action's or a query's input is an object. */
166
+ export type DrawnInput = Readonly<Record<string, unknown>>;
167
+
168
+ /**
169
+ * An override's `input` as the loop's `bind`: typed over an object, so `(drawn) => ({ ...drawn,
170
+ * postId })` compiles as written. A value that drew no object is passed through, since nothing
171
+ * in it can be bound by name.
172
+ */
173
+ export const bindDrawn = (
174
+ input: ((drawn: DrawnInput) => unknown) | undefined,
175
+ ): ((drawn: unknown) => unknown) | undefined =>
176
+ input === undefined ? undefined : (drawn) => (isJsonObject(drawn) ? input(drawn) : drawn);
@@ -5,6 +5,13 @@
5
5
 
6
6
  export type { CoreErrorCode } from '../core-error-codes';
7
7
  export { CORE_ERROR_CODES } from '../core-error-codes';
8
+ export type {
9
+ DefinedError,
10
+ DefinedErrorExtra,
11
+ DefineErrorSpec,
12
+ ErrorText,
13
+ } from '../define-error';
14
+ export { defineError } from '../define-error';
8
15
  export type {
9
16
  ErrorCodeDeclaration,
10
17
  ErrorCodeDescriptor,
@@ -3,6 +3,7 @@
3
3
  // subject — a process's own account of what it did — and `index.ts` re-exports every name below
4
4
  // explicitly, so this file changes what a reader looks at and never what the package exports.
5
5
 
6
+ export { errorFingerprint } from '../error-fingerprint';
6
7
  export type {
7
8
  ErrorReport,
8
9
  ErrorReporter,
@@ -17,11 +18,13 @@ export {
17
18
  configureErrorReporting,
18
19
  ERROR_SOURCES,
19
20
  errorReport,
21
+ installedErrorReporter,
20
22
  memoryErrorReporter,
21
23
  noopErrorReporter,
22
24
  reportError,
23
25
  resetErrorReporting,
24
26
  } from '../error-reporter';
27
+ export { chainErrorReporters, errorReportersOf } from '../error-reporter-chain';
25
28
  export type {
26
29
  SentryDsn,
27
30
  SentryEnvelopeOptions,
@@ -33,6 +36,28 @@ export {
33
36
  sentryEnvelope,
34
37
  sentryErrorReporter,
35
38
  } from '../error-reporter-sentry';
39
+ export type {
40
+ FailureActor,
41
+ FailureBundle,
42
+ FailureCaptureLimits,
43
+ FailureFact,
44
+ FailurePrimitive,
45
+ FailureRead,
46
+ FailureStore,
47
+ HeldFailure,
48
+ } from '../failure-capture';
49
+ export {
50
+ failureCaptureActive,
51
+ failureCaptureLimits,
52
+ installedFailureStore,
53
+ recordFailureFact,
54
+ recordFailureInput,
55
+ setFailureStore,
56
+ withFailureCapture,
57
+ } from '../failure-capture';
58
+ export { resetFailureCaptureWindows, settleFailureCaptures } from '../failure-capture-flush';
59
+ export type { CapturedValue } from '../failure-capture-mask';
60
+ export { captureValue } from '../failure-capture-mask';
36
61
  export type { LogFields, Logger, LoggerOptions, LogLevel } from '../logger';
37
62
  export {
38
63
  isRedactedKey,
@@ -87,6 +112,7 @@ export {
87
112
  OTLP_ENDPOINT_KEY,
88
113
  OTLP_HEADERS_KEY,
89
114
  OTLP_PROTOCOL_KEY,
115
+ OTLP_RETRY_STATUSES,
90
116
  OTLP_SCOPE,
91
117
  OtlpEndpointInvalidError,
92
118
  OtlpHeadersInvalidError,
@@ -0,0 +1,93 @@
1
+ // Single responsibility: turning a held failure into a stored bundle, at `reportError` — the one
2
+ // moment the failure has its group (`errorFingerprint` over the very report the monitor gets), so
3
+ // `x_failures` and `x_error_groups` are keyed alike. Bounded per fingerprint per hour, capped in
4
+ // bytes, and never awaited by the response: a store that refuses is a log line.
5
+
6
+ import { errorFingerprint } from './error-fingerprint';
7
+ import type { ErrorReport } from './error-reporter';
8
+ import type { FailureBundle, HeldFailure } from './failure-capture';
9
+ import {
10
+ failureCaptureLimits,
11
+ failureStoreRefused,
12
+ installedFailureStore,
13
+ takeHeldFailure,
14
+ } from './failure-capture';
15
+ import { uuidV7 } from './ids';
16
+
17
+ const HOUR_MS = 60 * 60 * 1000;
18
+ /** Fingerprints remembered for the hourly bound; past it the oldest window is forgotten. */
19
+ const MAX_WINDOWS = 1000;
20
+
21
+ const windows = new Map<string, { start: number; count: number }>();
22
+ const pending = new Set<Promise<void>>();
23
+
24
+ /** Whether one more bundle of this group fits this hour. Per process: a fleet stores N× the bound. */
25
+ function admitted(fingerprint: string, at: number): boolean {
26
+ const window = windows.get(fingerprint);
27
+ if (window === undefined || at - window.start >= HOUR_MS) {
28
+ windows.delete(fingerprint);
29
+ if (windows.size >= MAX_WINDOWS) windows.delete(windows.keys().next().value ?? '');
30
+ windows.set(fingerprint, { start: at, count: 1 });
31
+ return true;
32
+ }
33
+ if (window.count >= failureCaptureLimits().perHour) return false;
34
+ window.count += 1;
35
+ return true;
36
+ }
37
+
38
+ const sizeOf = (bundle: FailureBundle): number =>
39
+ new TextEncoder().encode(JSON.stringify(bundle)).length;
40
+
41
+ /** Rows dropped from the end, then the flags, until the bundle fits `maxBytes`. Never the input. */
42
+ function capped(bundle: FailureBundle): FailureBundle {
43
+ const max = failureCaptureLimits().maxBytes;
44
+ if (sizeOf(bundle) <= max) return bundle;
45
+ const reads = [...bundle.reads];
46
+ let next: FailureBundle = bundle;
47
+ while (reads.length > 0 && sizeOf(next) > max) {
48
+ reads.pop();
49
+ next = { ...bundle, reads: [...reads], truncated: true };
50
+ }
51
+ return sizeOf(next) <= max ? next : { ...next, flags: {}, truncated: true };
52
+ }
53
+
54
+ function bundleOf(held: HeldFailure, report: ErrorReport): FailureBundle {
55
+ return capped({
56
+ ...held,
57
+ id: uuidV7(),
58
+ fingerprint: errorFingerprint(report),
59
+ code: report.code,
60
+ operation: report.scope.operation ?? null,
61
+ build: held.build ?? report.release,
62
+ at: held.at === 0 ? report.at : held.at,
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Called by `reportError` with every report, before the reporter sees it. A value nothing held
68
+ * costs one `WeakMap` read. Total: it never throws, and the save is never awaited here.
69
+ */
70
+ export function releaseHeldFailure(error: unknown, report: () => ErrorReport): void {
71
+ const store = installedFailureStore();
72
+ const held = takeHeldFailure(error);
73
+ if (store === null || held === undefined) return;
74
+ try {
75
+ const bundle = bundleOf(held, report());
76
+ if (!admitted(bundle.fingerprint, bundle.at)) return;
77
+ const saving = store.save(bundle).catch(failureStoreRefused);
78
+ pending.add(saving);
79
+ void saving.finally(() => pending.delete(saving));
80
+ } catch (failure) {
81
+ failureStoreRefused(failure);
82
+ }
83
+ }
84
+
85
+ /** Every save in flight, settled: a drain hook, and a test that reads the store next. */
86
+ export async function settleFailureCaptures(): Promise<void> {
87
+ while (pending.size > 0) await Promise.all([...pending]);
88
+ }
89
+
90
+ /** Test-only: forget the hourly windows. */
91
+ export function resetFailureCaptureWindows(): void {
92
+ windows.clear();
93
+ }
@@ -0,0 +1,54 @@
1
+ // Single responsibility: what a failure bundle may keep of a value — every key the app declared
2
+ // secret (`redactKeys`), every `Secret` and every field a schema already masked, replaced by
3
+ // `REDACTED` and LISTED by path, so `x repro` can say which fields it had to invent.
4
+
5
+ import { isRedactedKey } from './logger';
6
+ import { isSecret, REDACTED } from './secret';
7
+
8
+ /** Deep enough for any input that is a request and not a document; past it the subtree is cut. */
9
+ const MAX_DEPTH = 8;
10
+
11
+ export interface CapturedValue {
12
+ readonly value: unknown;
13
+ /** Dotted paths (`card.pan`, `items[]`) whose value is `REDACTED` in `value`. */
14
+ readonly masked: readonly string[];
15
+ }
16
+
17
+ const join = (at: string, key: string): string => (at === '' ? key : `${at}.${key}`);
18
+
19
+ function walk(value: unknown, at: string, depth: number, masked: string[]): unknown {
20
+ if (value === REDACTED || isSecret(value)) {
21
+ masked.push(at === '' ? '(the value)' : at);
22
+ return REDACTED;
23
+ }
24
+ if (value === null || typeof value !== 'object') return value;
25
+ if (value instanceof Date) return value.toISOString();
26
+ if (depth >= MAX_DEPTH) return '[depth-limit]';
27
+ if (Array.isArray(value)) return value.map((entry) => walk(entry, `${at}[]`, depth + 1, masked));
28
+ const out: Record<string, unknown> = {};
29
+ for (const [key, entry] of Object.entries(value)) {
30
+ const path = join(at, key);
31
+ const kept = isRedactedKey(key)
32
+ ? walk(REDACTED, path, depth + 1, masked)
33
+ : walk(entry, path, depth + 1, masked);
34
+ // `defineProperty`, never assignment: an input key named `__proto__` must stay a key.
35
+ Object.defineProperty(out, key, {
36
+ value: kept,
37
+ enumerable: true,
38
+ writable: true,
39
+ configurable: true,
40
+ });
41
+ }
42
+ return out;
43
+ }
44
+
45
+ /**
46
+ * A JSON-safe copy of `value` with every secret masked and listed. `value` may already carry
47
+ * `REDACTED` where a schema's `.sensitive()` masked it (`maskSensitive`); those are listed too.
48
+ * Paths are deduplicated: one masked field in every element of a list is one path, `items[].pan`.
49
+ */
50
+ export function captureValue(value: unknown): CapturedValue {
51
+ const masked: string[] = [];
52
+ const kept = walk(value, '', 0, masked);
53
+ return { value: kept, masked: [...new Set(masked)] };
54
+ }