@ultimat3/core 1.1.0 → 2.0.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 (45) hide show
  1. package/CLAUDE.md +252 -0
  2. package/README.md +217 -10
  3. package/package.json +2 -1
  4. package/src/actor.ts +118 -4
  5. package/src/app-version.ts +32 -0
  6. package/src/assert.ts +5 -1
  7. package/src/config.ts +47 -12
  8. package/src/context.ts +30 -3
  9. package/src/cursor.ts +25 -4
  10. package/src/env-example.ts +2 -1
  11. package/src/env.ts +14 -3
  12. package/src/environment.ts +39 -13
  13. package/src/error-codes.ts +13 -0
  14. package/src/error-render.ts +249 -0
  15. package/src/error-reporter-sentry.ts +175 -0
  16. package/src/error-reporter.ts +212 -0
  17. package/src/error-retry.ts +100 -0
  18. package/src/errors.ts +55 -7
  19. package/src/exports/error-contract.ts +61 -0
  20. package/src/exports/observability.ts +161 -0
  21. package/src/exports/secrets.ts +71 -0
  22. package/src/ids.ts +49 -7
  23. package/src/impersonate.ts +62 -0
  24. package/src/index.ts +277 -113
  25. package/src/lifecycle-deadline.ts +73 -0
  26. package/src/lifecycle-errors.ts +33 -0
  27. package/src/lifecycle.ts +178 -16
  28. package/src/logger.ts +99 -9
  29. package/src/mcp-exposure.ts +32 -0
  30. package/src/metrics.ts +0 -0
  31. package/src/otlp-metric-exporter.ts +136 -0
  32. package/src/otlp-span-exporter.ts +170 -0
  33. package/src/otlp.ts +217 -0
  34. package/src/read-capped.ts +47 -0
  35. package/src/runtime-metrics.ts +15 -0
  36. package/src/safe-url.ts +50 -0
  37. package/src/sampler.ts +126 -0
  38. package/src/schema-error-codes.ts +28 -0
  39. package/src/secrets-errors.ts +143 -0
  40. package/src/secrets-store.ts +173 -0
  41. package/src/secrets.ts +292 -0
  42. package/src/telemetry.ts +43 -11
  43. package/src/timing-safe-equal.ts +18 -0
  44. package/src/type-pins.ts +93 -0
  45. package/src/version.ts +53 -4
@@ -0,0 +1,249 @@
1
+ // Single responsibility: turn a value the framework does not control into text for an error's
2
+ // `cause` or `fix`, without ever throwing while doing it. An error factory that dies formatting
3
+ // its own message replaces the refusal with a `TypeError`, and `error.code === 'X_…'` then
4
+ // matches nothing — the last message that may be lost to its own rendering.
5
+
6
+ /**
7
+ * The cap on a rendered cause, in characters. A cause is READ — one log line, one `--json` field,
8
+ * one terminal paragraph — and the value it describes is the app's, so it can be a megabyte of
9
+ * request body. `JSON.stringify` has neither an output limit nor a streaming mode, so without a
10
+ * bound the whole of that value became the error's `message`, its log line and its `--json` field,
11
+ * and stayed there for the life of the error. Past every real cause in this repo, short of
12
+ * anything that costs.
13
+ */
14
+ export const MAX_RENDERED_LENGTH = 512;
15
+
16
+ /** The last guard: whatever survived the bounded walk still ends at the cap, ellipsis included. */
17
+ const truncate = (text: string): string =>
18
+ text.length <= MAX_RENDERED_LENGTH ? text : `${text.slice(0, MAX_RENDERED_LENGTH - 1)}…`;
19
+
20
+ /**
21
+ * `JSON.stringify` with a budget. The replacer runs before each value is serialised, so a long
22
+ * string is cut and the entries past the budget are dropped BEFORE they are written — the point
23
+ * being that the bound is on what gets allocated, not on a full serialisation trimmed afterwards.
24
+ * Dropping degrades exactly as the language already does: an object key disappears, an array slot
25
+ * becomes `null`.
26
+ */
27
+ const boundedJson = (value: unknown): string | undefined => {
28
+ let spent = 0;
29
+ return JSON.stringify(value, (_key, entry: unknown) => {
30
+ if (spent > MAX_RENDERED_LENGTH) return undefined;
31
+ if (typeof entry !== 'string') {
32
+ spent += 1;
33
+ return entry;
34
+ }
35
+ spent += entry.length;
36
+ return entry.length > MAX_RENDERED_LENGTH ? `${entry.slice(0, MAX_RENDERED_LENGTH)}…` : entry;
37
+ });
38
+ };
39
+
40
+ /**
41
+ * A value from an app, rendered for a `cause`. `JSON.stringify` raises a `TypeError` on a bigint
42
+ * and on a cyclic structure, RUNS any `toJSON` the value carries and reads every enumerable
43
+ * getter — so building the message can raise INSTEAD of the refusal. The caller then catches a
44
+ * `TypeError` where a validation denial belongs, catching by code finds nothing, and an HTTP
45
+ * surface answers 500 rather than the mapped status.
46
+ *
47
+ * A cause DESCRIBES, so degrading to a type name — or to a bounded prefix — costs a reader nothing
48
+ * they needed. Template interpolation is avoided for the same reason: `` `${symbol}` `` throws
49
+ * where `String(symbol)` does not. Lifted from `@ultimat3/entity`'s `renderValue` — the spelling
50
+ * two independent fixes converged on — including its `a ${typeof value}` fallback for the values
51
+ * `JSON.stringify` answers `undefined` for: a function's source is neither bounded nor a thing a
52
+ * reader wants.
53
+ *
54
+ * Text for a `fix:` goes through `renderFixLiteral` instead, because a fix has to parse.
55
+ */
56
+ export function renderCauseValue(value: unknown): string {
57
+ if (value === undefined) return 'undefined';
58
+ if (typeof value === 'bigint') return `${value}n`;
59
+ if (typeof value === 'symbol') return String(value);
60
+ try {
61
+ return truncate(boundedJson(value) ?? `a ${typeof value}`);
62
+ } catch {
63
+ return `a ${typeof value} that cannot be rendered`;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * `value instanceof Error`, made total. The test itself can throw: a `Proxy`'s `getPrototypeOf`
69
+ * trap runs during `instanceof`, and the one place this question is asked is a `catch` block that
70
+ * has nothing left to answer with if it does.
71
+ */
72
+ export function isThrownError(value: unknown): value is Error {
73
+ try {
74
+ return value instanceof Error;
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * A caught value as text: an `Error`'s own words where it has them, `renderCauseValue` everywhere
82
+ * else. `.name` and `.message` are ordinary property reads, so a subclass with a getter — or a
83
+ * `Proxy` — makes the read throw exactly where the catch block cannot afford it.
84
+ *
85
+ * One helper because the framework spelled `error instanceof Error ? error.message : …` in seven
86
+ * places, each carrying the same hole: `toUltimateError`, `@ultimat3/http`'s `finalizeFailed`,
87
+ * `@ultimat3/auth`'s OAuth callback, three CLI reporters and `@ultimat3/realtime`'s wire error.
88
+ */
89
+ export function renderThrowable(value: unknown): string {
90
+ try {
91
+ if (value instanceof Error) {
92
+ const name = typeof value.name === 'string' ? value.name : 'Error';
93
+ const message = value.message;
94
+ return truncate(
95
+ `${name}: ${typeof message === 'string' ? message : renderCauseValue(message)}`,
96
+ );
97
+ }
98
+ } catch {
99
+ // The value fought being read, which is the case this function exists for: render it whole.
100
+ }
101
+ return renderCauseValue(value);
102
+ }
103
+
104
+ /**
105
+ * One string field off a value that may fight being read. An `UltimateError` crossing a worker,
106
+ * a subprocess or a WebSocket arrives as a plain object, so every surface that re-renders one asks
107
+ * structurally — `typeof value.code === 'string'` — and that read is a getter call, or a `Proxy`'s
108
+ * `get` trap, on a value the framework did not build. It throws in the one place with nothing left
109
+ * to answer with: the catch block deciding what the caller sees. The renderers above are total and
110
+ * were still reached past three of these reads.
111
+ *
112
+ * `undefined` covers absent, wrong type and threw, because each one means the same thing to every
113
+ * caller: this value did not supply the field, so use the default.
114
+ */
115
+ export function stringField(value: unknown, key: string): string | undefined {
116
+ if (typeof value !== 'object' || value === null) return undefined;
117
+ try {
118
+ const held = (value as Record<string, unknown>)[key];
119
+ return typeof held === 'string' ? held : undefined;
120
+ } catch {
121
+ return undefined;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * The same value where the text has to PARSE — a `fix:` is pasted and run, so a degraded type
127
+ * name in it produces a command that does not work. A string becomes its quoted literal; anything
128
+ * else becomes the placeholder, which is a parameter because what is missing differs by call
129
+ * site: an org id in one fix line, a flag key in the next, and a fix that names the wrong thing
130
+ * is not a fix. `JSON.stringify` cannot throw on a string primitive.
131
+ *
132
+ * Lifted from `@ultimat3/entity`'s `asLiteral`.
133
+ */
134
+ export function renderFixLiteral(value: unknown, placeholder: string): string {
135
+ return typeof value === 'string' ? JSON.stringify(value) : placeholder;
136
+ }
137
+
138
+ /** `JSON.stringify` with its throw removed: did the value survive being serialised at all? */
139
+ const canRender = (value: unknown): boolean => {
140
+ try {
141
+ JSON.stringify(value);
142
+ return true;
143
+ } catch {
144
+ return false;
145
+ }
146
+ };
147
+
148
+ /** A record's own keys, or none — a `Proxy` may refuse to be enumerated, and `ownKeys` throws. */
149
+ const metaKeys = (meta: Readonly<Record<string, unknown>>): readonly string[] => {
150
+ try {
151
+ return Object.keys(meta);
152
+ } catch {
153
+ return [];
154
+ }
155
+ };
156
+
157
+ /** One entry, kept as it is when it renders — reading it is its own throw, past a getter. */
158
+ const metaEntry = (meta: Readonly<Record<string, unknown>>, key: string): unknown => {
159
+ try {
160
+ const value = meta[key];
161
+ // A function passes `canRender` — `JSON.stringify(fn)` answers `undefined` rather than
162
+ // throwing — but copying one into the record moves the throw one layer out instead of removing
163
+ // it: a `meta` carrying an enumerable `toJSON` is INVOKED when `--json` serialises the error
164
+ // around it, which is the render this whole file exists to keep alive. Rendered, never copied.
165
+ return canRender(value) && typeof value !== 'function' ? value : renderCauseValue(value);
166
+ } catch {
167
+ return 'a value that cannot be read';
168
+ }
169
+ };
170
+
171
+ /**
172
+ * The third surface, one layer past the two above: `UltimateError.toJSON()` hands `meta` straight
173
+ * to `JSON.stringify`, so a bigint, a cycle or a hostile `toJSON` in it throws at `--json` RENDER
174
+ * time — after a constructor the renderers already made safe. `parseId` puts the rejected value in
175
+ * `meta` itself, so core feeds it uncontrolled values on its own.
176
+ *
177
+ * `meta` is MACHINE-READ, which decides the shape: a record that serialises is returned unchanged,
178
+ * value identity included, because describing a value a caller parses today would be a worse bug
179
+ * than the throw. Only what cannot be rendered degrades, and it degrades one key at a time — a
180
+ * single broken value must not cost the reader the keys beside it. The cost of that pass-through
181
+ * is one extra `JSON.stringify` of a small record on an error path, and any `toJSON` in it running
182
+ * twice; a `toJSON` with side effects is already outside what an error's `meta` may carry.
183
+ */
184
+ export function renderMetaRecord(
185
+ meta: Readonly<Record<string, unknown>> | undefined,
186
+ ): Readonly<Record<string, unknown>> | undefined {
187
+ if (meta === undefined || canRender(meta)) return meta;
188
+ const out: Record<string, unknown> = {};
189
+ for (const key of metaKeys(meta)) out[key] = metaEntry(meta, key);
190
+ return out;
191
+ }
192
+
193
+ /**
194
+ * The shape of a rejected value — **never its content**.
195
+ *
196
+ * WHY: `renderCauseValue` above is safe against THROWING, not against LEAKING. It is the right
197
+ * renderer for a value the framework itself built; it is the wrong one for a value a caller
198
+ * supplied, because a `cause` is not a private diagnostic — it is folded into an HTTP problem
199
+ * document AND written into the log line, and the logger redacts `fields` by key, so a value
200
+ * baked into a message string has no key left to redact. That is not hypothetical: it was
201
+ * reproduced this cycle that a validator echoing a rejected value wrote mistyped passwords to
202
+ * both the log index and the user's own network tab.
203
+ *
204
+ * So: type and length, which is what a format or type violation actually needs, and nothing else.
205
+ * There is no dev-only escape hatch on purpose — a flag is one misconfigured environment away
206
+ * from being the same breach.
207
+ *
208
+ * A deliberate, character-for-character duplicate of `describeValue` in
209
+ * `packages/schema/src/describe-value.ts`, for the reason `SCHEMA_ERROR_CODE_TITLES` is one:
210
+ * `@ultimat3/schema` is tier 0 alongside this package, so neither may import the other. Keep the
211
+ * two identical; changing one alone is the bug.
212
+ */
213
+ export function describeValue(value: unknown): string {
214
+ if (value === undefined) return 'undefined';
215
+ if (value === null) return 'null';
216
+ switch (typeof value) {
217
+ case 'string':
218
+ return countOf(value.length, 'string', 'character');
219
+ case 'number':
220
+ return describeNumber(value);
221
+ case 'boolean':
222
+ return 'a boolean';
223
+ case 'bigint':
224
+ return 'a bigint';
225
+ case 'symbol':
226
+ return 'a symbol';
227
+ case 'function':
228
+ return 'a function';
229
+ default:
230
+ break;
231
+ }
232
+ if (Array.isArray(value)) return countOf(value.length, 'array', 'item');
233
+ // `getTime()` rather than a value: an invalid Date is the one Date fact a caller can act on.
234
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? 'an invalid Date' : 'a Date';
235
+ return 'an object';
236
+ }
237
+
238
+ function describeNumber(value: number): string {
239
+ if (Number.isNaN(value)) return 'NaN';
240
+ if (value === Number.POSITIVE_INFINITY) return 'Infinity';
241
+ if (value === Number.NEGATIVE_INFINITY) return '-Infinity';
242
+ return 'a number';
243
+ }
244
+
245
+ function countOf(size: number, noun: string, unit: string): string {
246
+ if (size === 0) return `an empty ${noun}`;
247
+ const article = noun === 'array' ? 'an' : 'a';
248
+ return `${article} ${noun} of ${size} ${unit}${size === 1 ? '' : 's'}`;
249
+ }
@@ -0,0 +1,175 @@
1
+ // Single responsibility: one `ErrorReporter` that speaks the Sentry ENVELOPE wire format. A
2
+ // serialisation, not a vendor (axiom 7, exactly as `metrics-text.ts` is to Prometheus): the format
3
+ // is documented and several self-hostable monitors ingest it. Nothing here names a host, a project
4
+ // or an organisation — the DSN is the app's own typed env, passed in at wiring time.
5
+
6
+ import { renderThrowable } from './error-render';
7
+ import type { ErrorReport, ErrorReporter, ErrorSeverity } from './error-reporter';
8
+ import { type CodedErrorInit, UltimateError } from './errors';
9
+ import { traceId } from './ids';
10
+ import { logger } from './logger';
11
+
12
+ export class ErrorReporterDsnInvalidError extends UltimateError {
13
+ static readonly code = 'X_ERROR_REPORTER_DSN_INVALID';
14
+ override readonly name = 'ErrorReporterDsnInvalidError';
15
+ constructor(init: CodedErrorInit) {
16
+ super({ ...init, code: ErrorReporterDsnInvalidError.code });
17
+ }
18
+ }
19
+
20
+ export interface SentryDsn {
21
+ readonly publicKey: string;
22
+ readonly projectId: string;
23
+ /** Where an envelope is POSTed. Derived from the DSN, never configured separately. */
24
+ readonly envelopeUrl: string;
25
+ }
26
+
27
+ const DSN_FIX =
28
+ 'set the monitor DSN in .env to https://<publicKey>@<host>/<projectId>, then run: x env check';
29
+
30
+ /**
31
+ * `https://<publicKey>@<host>[:<port>][/<path>]/<projectId>`. Parsed at wiring time rather than at
32
+ * the first error: a typo discovered by the first outage is a monitor that was never connected.
33
+ */
34
+ export function parseSentryDsn(dsn: string): SentryDsn {
35
+ let url: URL;
36
+ try {
37
+ url = new URL(dsn);
38
+ } catch {
39
+ throw new ErrorReporterDsnInvalidError({
40
+ cause: `"${dsn}" is not a URL`,
41
+ fix: DSN_FIX,
42
+ meta: { dsn },
43
+ });
44
+ }
45
+ const segments = url.pathname.split('/').filter((part) => part.length > 0);
46
+ const projectId = segments.pop();
47
+ if (
48
+ (url.protocol !== 'https:' && url.protocol !== 'http:') ||
49
+ url.username === '' ||
50
+ projectId === undefined
51
+ ) {
52
+ throw new ErrorReporterDsnInvalidError({
53
+ cause: `"${url.protocol}//${url.host}${url.pathname}" has no publicKey, no projectId or a non-HTTP scheme`,
54
+ fix: DSN_FIX,
55
+ meta: { dsn },
56
+ });
57
+ }
58
+ const prefix = segments.length === 0 ? '' : `/${segments.join('/')}`;
59
+ return {
60
+ publicKey: url.username,
61
+ projectId,
62
+ envelopeUrl: `${url.protocol}//${url.host}${prefix}/api/${projectId}/envelope/`,
63
+ };
64
+ }
65
+
66
+ /** The protocol's own level names. `warning`/`error`/`fatal` happen to be the same three words. */
67
+ const LEVELS: Readonly<Record<ErrorSeverity, string>> = Object.freeze({
68
+ warning: 'warning',
69
+ error: 'error',
70
+ fatal: 'fatal',
71
+ });
72
+
73
+ export interface SentryEnvelopeOptions {
74
+ readonly dsn: string;
75
+ /** 32 lowercase hex, no dashes. `traceId()` already produces exactly that shape. */
76
+ readonly eventId: string;
77
+ }
78
+
79
+ function payloadOf(report: ErrorReport, eventId: string): Record<string, unknown> {
80
+ return {
81
+ event_id: eventId,
82
+ // Seconds, as the protocol spells a timestamp. `new Date` here converts a number the caller
83
+ // supplied; it never reads a clock, which stays `clock.ts`'s job.
84
+ timestamp: report.at / 1000,
85
+ platform: 'javascript',
86
+ level: LEVELS[report.severity],
87
+ logger: report.source,
88
+ environment: report.environment,
89
+ server_name: report.resource.serviceName,
90
+ ...(report.release === null ? {} : { release: report.release }),
91
+ ...(report.scope.operation === undefined ? {} : { transaction: report.scope.operation }),
92
+ // Tags are the monitor's facets, so only bounded values go here — the same rule metric labels
93
+ // follow. `requestId` and `actorId` are unbounded and live in `extra`.
94
+ tags: {
95
+ code: report.code,
96
+ source: report.source,
97
+ service_version: report.resource.serviceVersion,
98
+ ...(report.scope.role === undefined ? {} : { role: report.scope.role }),
99
+ },
100
+ ...(report.scope.traceId === undefined
101
+ ? {}
102
+ : {
103
+ contexts: {
104
+ trace: {
105
+ trace_id: report.scope.traceId,
106
+ ...(report.scope.spanId === undefined ? {} : { span_id: report.scope.spanId }),
107
+ },
108
+ },
109
+ }),
110
+ extra: {
111
+ // The whole point of reporting the framework's contract instead of a message: whoever is
112
+ // paged reads the runnable fix next to the failure.
113
+ fix: report.fix,
114
+ docs: report.docs,
115
+ ...(report.scope.requestId === undefined ? {} : { requestId: report.scope.requestId }),
116
+ ...(report.scope.actorId === undefined ? {} : { actorId: report.scope.actorId }),
117
+ ...(report.stack === undefined ? {} : { stack: report.stack }),
118
+ ...(report.meta ?? {}),
119
+ ...(report.scope.extra ?? {}),
120
+ },
121
+ exception: { values: [{ type: report.code, value: `${report.title} — ${report.cause}` }] },
122
+ };
123
+ }
124
+
125
+ /** Pure, so the wire format is a unit test rather than a thing discovered in production. */
126
+ export function sentryEnvelope(report: ErrorReport, options: SentryEnvelopeOptions): string {
127
+ const payload = JSON.stringify(payloadOf(report, options.eventId));
128
+ const header = JSON.stringify({
129
+ event_id: options.eventId,
130
+ sent_at: new Date(report.at).toISOString(),
131
+ dsn: options.dsn,
132
+ });
133
+ const item = JSON.stringify({
134
+ type: 'event',
135
+ content_type: 'application/json',
136
+ length: new TextEncoder().encode(payload).length,
137
+ });
138
+ return `${header}\n${item}\n${payload}\n`;
139
+ }
140
+
141
+ export interface SentryReporterOptions {
142
+ /** From the app's typed env. The framework declares no default and ships no constant. */
143
+ readonly dsn: string;
144
+ /** Injected by tests; the preload seals the real one. */
145
+ readonly fetch?: typeof globalThis.fetch | undefined;
146
+ readonly clientName?: string | undefined;
147
+ }
148
+
149
+ /**
150
+ * Fire-and-forget on purpose. A report is not the request, and awaiting the monitor would add its
151
+ * latency — and its outages — to every failure the app already knows how to answer.
152
+ */
153
+ export function sentryErrorReporter(options: SentryReporterOptions): ErrorReporter {
154
+ const dsn = parseSentryDsn(options.dsn);
155
+ const send = options.fetch ?? globalThis.fetch;
156
+ const client = options.clientName ?? 'ultimate';
157
+ const auth = `Sentry sentry_version=7, sentry_client=${client}, sentry_key=${dsn.publicKey}`;
158
+ return {
159
+ report(report: ErrorReport): void {
160
+ const body = sentryEnvelope(report, { dsn: options.dsn, eventId: traceId() });
161
+ void send(dsn.envelopeUrl, {
162
+ method: 'POST',
163
+ headers: { 'content-type': 'application/x-sentry-envelope', 'x-sentry-auth': auth },
164
+ body,
165
+ }).catch((failure: unknown) => {
166
+ logger.warn('error reporter delivery failed', {
167
+ url: dsn.envelopeUrl,
168
+ // `renderThrowable`: this is the `.catch` that keeps a monitor outage from becoming a
169
+ // second failure, so rendering the rejection may not raise one of its own.
170
+ error: renderThrowable(failure),
171
+ });
172
+ });
173
+ },
174
+ };
175
+ }
@@ -0,0 +1,212 @@
1
+ // Single responsibility: the error-monitoring seam — every surface reports a caught error through
2
+ // ONE `ErrorReporter`. Shaped exactly like `telemetry.ts` and `metrics.ts`: always on, a no-op
3
+ // driver by default, and the wire format supplied by a transport, never here. The framework names
4
+ // no vendor (axiom 7); a transport takes its endpoint from the app's typed env.
5
+
6
+ import { type Clock, systemClock } from './clock';
7
+ import { tryUseContext } from './context';
8
+ import { DEFAULT_ENVIRONMENT, type Environment, tryResolveEnvironment } from './environment';
9
+ import { renderThrowable, stringField } from './error-render';
10
+ import { toUltimateError } from './errors';
11
+ import { logger } from './logger';
12
+ import type { Role } from './roles';
13
+ import { currentSpanContext, type SpanResource, serviceResource } from './telemetry';
14
+
15
+ export type ErrorSeverity = 'warning' | 'error' | 'fatal';
16
+
17
+ /**
18
+ * Which surface caught it. A closed list on purpose: this becomes a facet in the monitor, and a
19
+ * free-form string here is the same unbounded-cardinality mistake that a user id in a metric
20
+ * label is. A new surface adds a member; it never passes a string of its own.
21
+ */
22
+ export const ERROR_SOURCES = ['http', 'job', 'realtime', 'cli', 'process'] as const;
23
+
24
+ export type ErrorSource = (typeof ERROR_SOURCES)[number];
25
+
26
+ export interface ErrorScope {
27
+ readonly requestId?: string | undefined;
28
+ readonly traceId?: string | undefined;
29
+ readonly spanId?: string | undefined;
30
+ readonly role?: Role | undefined;
31
+ /** The route PATTERN, the job name, the frame type — never a concrete path or a row id. */
32
+ readonly operation?: string | undefined;
33
+ readonly actorId?: string | undefined;
34
+ /** Anything else worth reading during triage. Not a facet: never indexed, never grouped on. */
35
+ readonly extra?: Readonly<Record<string, unknown>> | undefined;
36
+ }
37
+
38
+ /**
39
+ * What a reporter receives. The framework's error contract verbatim — a monitor groups on `code`
40
+ * and shows `fix` to whoever is paged, which is the whole reason a report carries more than a
41
+ * message string.
42
+ */
43
+ export interface ErrorReport {
44
+ /** Epoch milliseconds, from the configured clock. */
45
+ readonly at: number;
46
+ readonly severity: ErrorSeverity;
47
+ readonly source: ErrorSource;
48
+ readonly code: string;
49
+ readonly title: string;
50
+ readonly cause: string;
51
+ readonly fix: string;
52
+ readonly docs: string;
53
+ readonly meta: Readonly<Record<string, unknown>> | undefined;
54
+ readonly stack: string | undefined;
55
+ readonly resource: SpanResource;
56
+ readonly environment: Environment;
57
+ /** The deploy's own id — `BUILD_ID`, the same value `x-ultimate-build` carries. */
58
+ readonly release: string | null;
59
+ readonly scope: ErrorScope;
60
+ /** The value that was actually thrown. A transport may read it; nothing else should. */
61
+ readonly error: unknown;
62
+ }
63
+
64
+ /** The driver seam. A self-hosted monitor, an OTLP logs exporter or a file all arrive as one. */
65
+ export interface ErrorReporter {
66
+ report(event: ErrorReport): void;
67
+ }
68
+
69
+ export const noopErrorReporter: ErrorReporter = Object.freeze({
70
+ report(): void {
71
+ // Intentionally empty: reporting is always on, and free until a transport is configured.
72
+ },
73
+ });
74
+
75
+ export interface MemoryErrorReporter extends ErrorReporter {
76
+ readonly events: readonly ErrorReport[];
77
+ reset(): void;
78
+ }
79
+
80
+ /** For tests, and for a `x dev` process that shows its own failures without leaving the box. */
81
+ export function memoryErrorReporter(): MemoryErrorReporter {
82
+ const events: ErrorReport[] = [];
83
+ return {
84
+ events,
85
+ report(event: ErrorReport): void {
86
+ events.push(event);
87
+ },
88
+ reset(): void {
89
+ events.length = 0;
90
+ },
91
+ };
92
+ }
93
+
94
+ export interface ErrorReportingOptions {
95
+ readonly reporter?: ErrorReporter | undefined;
96
+ readonly clock?: Clock | undefined;
97
+ /** The deploy's build id. `serve.ts` passes the one it already computed; never a second one. */
98
+ readonly release?: string | null | undefined;
99
+ readonly environment?: Environment | undefined;
100
+ readonly enabled?: boolean | undefined;
101
+ }
102
+
103
+ let reporter: ErrorReporter = noopErrorReporter;
104
+ let clock: Clock = systemClock;
105
+ let release: string | null = null;
106
+ let environment: Environment | undefined;
107
+ let enabled = true;
108
+
109
+ export function configureErrorReporting(options: ErrorReportingOptions): void {
110
+ if (options.reporter !== undefined) reporter = options.reporter;
111
+ if (options.clock !== undefined) clock = options.clock;
112
+ if (options.release !== undefined) release = options.release;
113
+ if (options.environment !== undefined) environment = options.environment;
114
+ if (options.enabled !== undefined) enabled = options.enabled;
115
+ }
116
+
117
+ export function resetErrorReporting(): void {
118
+ reporter = noopErrorReporter;
119
+ clock = systemClock;
120
+ release = null;
121
+ environment = undefined;
122
+ enabled = true;
123
+ }
124
+
125
+ function environmentNow(): Environment {
126
+ // A malformed `ULTIMATE_ENV` is its own error with its own code and its own fix. Failing to tag
127
+ // a report with an environment must never replace the error being reported — which is why the
128
+ // non-throwing resolver is core's, not a `try` around the throwing one here: two call sites
129
+ // catching the same throw is two places the policy can drift.
130
+ return environment ?? tryResolveEnvironment() ?? DEFAULT_ENVIRONMENT;
131
+ }
132
+
133
+ export interface ReportErrorOptions {
134
+ readonly source: ErrorSource;
135
+ /** Default `error`. `warning` is for a failure the framework already recovered from. */
136
+ readonly severity?: ErrorSeverity | undefined;
137
+ readonly scope?: ErrorScope | undefined;
138
+ }
139
+
140
+ /**
141
+ * Normalise a throwable into an `ErrorReport`. Exported so a transport can be tested against a
142
+ * report built the same way the runtime builds one, and so a surface can enrich before sending.
143
+ * The ambient context and the active span fill in whatever the caller did not name.
144
+ */
145
+ export function errorReport(error: unknown, options: ReportErrorOptions): ErrorReport {
146
+ const normalized = toUltimateError(error);
147
+ const ctx = tryUseContext();
148
+ const span = currentSpanContext();
149
+ /**
150
+ * Trace and span resolve as a PAIR, from one source, and never field by field. Falling back
151
+ * per-field let a caller-supplied `traceId` pick up the *ambient* `spanId`, so a report claimed
152
+ * a span that belongs to a different trace — whoever is paged then opens the wrong span inside
153
+ * the right trace, which is worse than no span at all because it looks authoritative. A caller
154
+ * naming a trace is making a statement; the ambient span only fills a silence.
155
+ */
156
+ const trace: { traceId: string | undefined; spanId: string | undefined } =
157
+ options.scope?.traceId === undefined
158
+ ? {
159
+ traceId: span?.traceId ?? ctx?.traceId,
160
+ spanId: span?.spanId === '' ? undefined : span?.spanId,
161
+ }
162
+ : { traceId: options.scope.traceId, spanId: options.scope.spanId };
163
+ const scope: ErrorScope = {
164
+ requestId: options.scope?.requestId ?? ctx?.requestId,
165
+ traceId: trace.traceId,
166
+ spanId: trace.spanId,
167
+ role: options.scope?.role ?? ctx?.role,
168
+ operation: options.scope?.operation,
169
+ actorId: options.scope?.actorId ?? ctx?.actor.id,
170
+ extra: options.scope?.extra,
171
+ };
172
+ return {
173
+ at: clock.now().getTime(),
174
+ severity: options.severity ?? 'error',
175
+ source: options.source,
176
+ code: normalized.code,
177
+ title: normalized.title,
178
+ cause: normalized.cause,
179
+ fix: normalized.fix,
180
+ docs: normalized.docs,
181
+ meta: normalized.meta,
182
+ // The thrown value's own stack, not the wrapper's: `toUltimateError` builds its `InternalError`
183
+ // at this line, so the wrapper's stack points here rather than at the throw. Read through
184
+ // `stringField`, because `error instanceof Error` and `.stack` are both property operations
185
+ // on a caught value — and this function's own contract is that reporting never throws.
186
+ stack: stringField(error, 'stack') ?? normalized.stack,
187
+ resource: serviceResource(),
188
+ environment: environmentNow(),
189
+ release: release ?? ctx?.buildId ?? null,
190
+ scope,
191
+ error,
192
+ };
193
+ }
194
+
195
+ /**
196
+ * The one call every surface makes. It never throws and never rejects: a monitor that is down
197
+ * must not turn one failure into two, and the surface that caught this has already logged it.
198
+ */
199
+ export function reportError(error: unknown, options: ReportErrorOptions): void {
200
+ if (!enabled) return;
201
+ try {
202
+ reporter.report(errorReport(error, options));
203
+ } catch (failure) {
204
+ logger.warn('error reporter failed', {
205
+ source: options.source,
206
+ // `renderThrowable`: `failure instanceof Error ? failure.message : String(failure)` is
207
+ // itself a throw on a hostile value, and it sat inside the catch that makes this function's
208
+ // documented "never throws" true.
209
+ error: renderThrowable(failure),
210
+ });
211
+ }
212
+ }