@ultimat3/core 1.2.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 +210 -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,170 @@
1
+ // Single responsibility: a `SpanExporter` that POSTs OTLP/HTTP JSON to a collector. Batched,
2
+ // because `SpanExporter.export` is one span and a request per span is a second load generator.
3
+
4
+ import {
5
+ OTLP_SCOPE,
6
+ type OtlpKeyValue,
7
+ otlpAttributes,
8
+ otlpEndpoint,
9
+ otlpHeaders,
10
+ otlpResource,
11
+ postOtlp,
12
+ unixNano,
13
+ } from './otlp';
14
+ import type {
15
+ ReadableSpan,
16
+ SpanContext,
17
+ SpanExporter,
18
+ SpanKind,
19
+ SpanResource,
20
+ SpanStatusCode,
21
+ } from './telemetry';
22
+
23
+ /** OTLP's `SpanKind` enum; `UNSPECIFIED` is 0 and Ultimate never emits it. */
24
+ const SPAN_KIND: Readonly<Record<SpanKind, number>> = Object.freeze({
25
+ internal: 1,
26
+ server: 2,
27
+ client: 3,
28
+ producer: 4,
29
+ consumer: 5,
30
+ });
31
+
32
+ const STATUS_CODE: Readonly<Record<SpanStatusCode, number>> = Object.freeze({
33
+ unset: 0,
34
+ ok: 1,
35
+ error: 2,
36
+ });
37
+
38
+ interface OtlpSpanJson {
39
+ readonly traceId: string;
40
+ readonly spanId: string;
41
+ readonly parentSpanId?: string;
42
+ readonly name: string;
43
+ readonly kind: number;
44
+ readonly startTimeUnixNano: string;
45
+ readonly endTimeUnixNano: string;
46
+ readonly attributes: readonly OtlpKeyValue[];
47
+ readonly events: readonly unknown[];
48
+ readonly links: readonly unknown[];
49
+ readonly status: { readonly code: number; readonly message?: string };
50
+ }
51
+
52
+ function link(context: SpanContext): unknown {
53
+ return { traceId: context.traceId, spanId: context.spanId };
54
+ }
55
+
56
+ function spanJson(span: ReadableSpan): OtlpSpanJson {
57
+ return {
58
+ traceId: span.context.traceId,
59
+ spanId: span.context.spanId,
60
+ ...(span.parentSpanId === undefined ? {} : { parentSpanId: span.parentSpanId }),
61
+ name: span.name,
62
+ kind: SPAN_KIND[span.kind],
63
+ startTimeUnixNano: unixNano(span.startedAt),
64
+ endTimeUnixNano: unixNano(span.endedAt),
65
+ attributes: otlpAttributes(span.attributes),
66
+ events: span.events.map((event) => ({
67
+ timeUnixNano: unixNano(event.at),
68
+ name: event.name,
69
+ attributes: otlpAttributes(event.attributes),
70
+ })),
71
+ links: span.links.map(link),
72
+ status: {
73
+ code: STATUS_CODE[span.status.code],
74
+ ...(span.status.message === undefined ? {} : { message: span.status.message }),
75
+ },
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Pure, so the wire format is a unit test rather than something discovered against a collector.
81
+ * Spans are grouped by resource identity — in one process there is exactly one, but grouping here
82
+ * keeps the shape correct if a future caller replays spans from elsewhere.
83
+ */
84
+ export function otlpTraceRequest(spans: readonly ReadableSpan[], resource: SpanResource): unknown {
85
+ return {
86
+ resourceSpans: [
87
+ {
88
+ resource: otlpResource(resource),
89
+ scopeSpans: [{ scope: OTLP_SCOPE, spans: spans.map(spanJson) }],
90
+ },
91
+ ],
92
+ };
93
+ }
94
+
95
+ export interface OtlpSpanExporterOptions {
96
+ /** Overrides `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` / `OTEL_EXPORTER_OTLP_ENDPOINT`. */
97
+ readonly endpoint?: string | undefined;
98
+ /** Merged over `OTEL_EXPORTER_OTLP_HEADERS`. */
99
+ readonly headers?: Readonly<Record<string, string>> | undefined;
100
+ /** Spans per POST. Default 512, the OTel batch processor's own default. */
101
+ readonly maxBatchSize?: number | undefined;
102
+ /** Default 5000ms. */
103
+ readonly flushIntervalMs?: number | undefined;
104
+ /** Spans held before the oldest are dropped. Default 2048 — a bound, not a promise. */
105
+ readonly maxQueueSize?: number | undefined;
106
+ /** Default 10000ms. */
107
+ readonly timeoutMs?: number | undefined;
108
+ /** Injected by tests; the preload seals the real one. */
109
+ readonly fetch?: typeof globalThis.fetch | undefined;
110
+ }
111
+
112
+ export interface OtlpSpanExporter extends SpanExporter {
113
+ /** Send whatever is queued now. Resolves once the POST settles. */
114
+ flush(): Promise<void>;
115
+ /** Stop the timer and flush. Wire this into `onShutdown('otlp', …, { phase: 'close' })`. */
116
+ shutdown(): Promise<void>;
117
+ }
118
+
119
+ /**
120
+ * Throws `X_OTLP_ENDPOINT_INVALID` at construction when nothing configured an endpoint — a
121
+ * telemetry exporter that silently sends nowhere is the failure this whole seam exists to end.
122
+ * Ask `tryOtlpEndpoint('traces')` first when the exporter is optional.
123
+ */
124
+ export function otlpSpanExporter(options: OtlpSpanExporterOptions = {}): OtlpSpanExporter {
125
+ const url = otlpEndpoint('traces', options.endpoint);
126
+ const headers = otlpHeaders(options.headers);
127
+ const maxBatchSize = options.maxBatchSize ?? 512;
128
+ const maxQueueSize = options.maxQueueSize ?? 2048;
129
+ const timeoutMs = options.timeoutMs ?? 10_000;
130
+ const send = options.fetch ?? globalThis.fetch;
131
+ const queue: ReadableSpan[] = [];
132
+ let inflight: Promise<void> = Promise.resolve();
133
+
134
+ const post = (batch: readonly ReadableSpan[]): Promise<void> => {
135
+ const first = batch[0];
136
+ if (first === undefined) return Promise.resolve();
137
+ const body = JSON.stringify(otlpTraceRequest(batch, first.resource));
138
+ return postOtlp({ url, headers, body, timeoutMs, fetch: send });
139
+ };
140
+
141
+ const drainQueue = (): Promise<void> => {
142
+ const batch = queue.splice(0, queue.length);
143
+ // Chained, not concurrent: a collector reordering batches from one process turns a parent's
144
+ // span arriving after its child into a broken trace on the read side.
145
+ inflight = inflight.then(() => post(batch));
146
+ return inflight;
147
+ };
148
+
149
+ // Unref'd, so a pending flush never holds a draining process open — `shutdown()` is what
150
+ // decides the last batch leaves, exactly as `startMetricExport` defers to the drain hook.
151
+ const timer = setInterval(() => void drainQueue(), options.flushIntervalMs ?? 5_000);
152
+ timer.unref();
153
+
154
+ return {
155
+ export(span: ReadableSpan): void {
156
+ // Drop the OLDEST: a bounded queue that drops the newest keeps a stale window forever, and
157
+ // the spans an operator wants during an incident are the ones happening now.
158
+ if (queue.length >= maxQueueSize) queue.shift();
159
+ queue.push(span);
160
+ if (queue.length >= maxBatchSize) void drainQueue();
161
+ },
162
+ flush(): Promise<void> {
163
+ return drainQueue();
164
+ },
165
+ async shutdown(): Promise<void> {
166
+ clearInterval(timer);
167
+ await drainQueue();
168
+ },
169
+ };
170
+ }
package/src/otlp.ts ADDED
@@ -0,0 +1,217 @@
1
+ // Single responsibility: the pieces both OTLP exporters share — endpoint resolution from the env
2
+ // an operator already sets, header parsing, the OTLP/JSON value encoding, and one POST that never
3
+ // throws. A serialisation, not a vendor (axiom 7), exactly as `metrics-text.ts` is to Prometheus.
4
+
5
+ import { renderThrowable } from './error-render';
6
+ import { type CodedErrorInit, UltimateError } from './errors';
7
+ import { logger } from './logger';
8
+ import type { AttributeValue, SpanResource } from './telemetry';
9
+
10
+ export class OtlpEndpointInvalidError extends UltimateError {
11
+ static readonly code = 'X_OTLP_ENDPOINT_INVALID';
12
+ override readonly name = 'OtlpEndpointInvalidError';
13
+ constructor(init: CodedErrorInit) {
14
+ super({ ...init, code: OtlpEndpointInvalidError.code });
15
+ }
16
+ }
17
+
18
+ export class OtlpProtocolUnsupportedError extends UltimateError {
19
+ static readonly code = 'X_OTLP_PROTOCOL_UNSUPPORTED';
20
+ override readonly name = 'OtlpProtocolUnsupportedError';
21
+ constructor(init: CodedErrorInit) {
22
+ super({ ...init, code: OtlpProtocolUnsupportedError.code });
23
+ }
24
+ }
25
+
26
+ export type OtlpSignal = 'traces' | 'metrics';
27
+
28
+ export const OTLP_ENDPOINT_KEY = 'OTEL_EXPORTER_OTLP_ENDPOINT';
29
+ export const OTLP_HEADERS_KEY = 'OTEL_EXPORTER_OTLP_HEADERS';
30
+ export const OTLP_PROTOCOL_KEY = 'OTEL_EXPORTER_OTLP_PROTOCOL';
31
+
32
+ /** The gRPC receiver port. Named so the error can say which one the operator reached for. */
33
+ const GRPC_PORT = '4317';
34
+
35
+ export type OtlpEnv = Readonly<Record<string, string | undefined>>;
36
+
37
+ const signalKey = (signal: OtlpSignal, suffix: string): string =>
38
+ `OTEL_EXPORTER_OTLP_${signal.toUpperCase()}_${suffix}`;
39
+
40
+ /**
41
+ * OTLP/HTTP JSON only. gRPC needs HTTP/2 plus protobuf, which is a dependency and a second wire
42
+ * format for one signal — `docs/ops/03-observability.md` already points operators at `:4318`.
43
+ */
44
+ function assertHttpJson(signal: OtlpSignal, url: URL, env: OtlpEnv): void {
45
+ const protocol = (env[signalKey(signal, 'PROTOCOL')] ?? env[OTLP_PROTOCOL_KEY] ?? '').trim();
46
+ if (protocol !== '' && protocol !== 'http/json') {
47
+ throw new OtlpProtocolUnsupportedError({
48
+ cause: `${OTLP_PROTOCOL_KEY}="${protocol}" — this exporter speaks OTLP/HTTP JSON and nothing else`,
49
+ fix: `unset ${OTLP_PROTOCOL_KEY} (or set it to http/json) and point ${OTLP_ENDPOINT_KEY} at the collector's HTTP receiver, e.g. http://otel-collector:4318`,
50
+ meta: { protocol, signal },
51
+ });
52
+ }
53
+ if (url.port === GRPC_PORT) {
54
+ throw new OtlpProtocolUnsupportedError({
55
+ cause: `${url.origin} is the collector's gRPC receiver (:${GRPC_PORT}); OTLP/HTTP JSON is served on :4318`,
56
+ fix: `set ${OTLP_ENDPOINT_KEY}=${url.protocol}//${url.hostname}:4318`,
57
+ meta: { endpoint: url.origin, signal },
58
+ });
59
+ }
60
+ }
61
+
62
+ function parseEndpoint(signal: OtlpSignal, raw: string, perSignal: boolean, env: OtlpEnv): string {
63
+ let url: URL;
64
+ try {
65
+ url = new URL(raw);
66
+ } catch {
67
+ throw new OtlpEndpointInvalidError({
68
+ cause: `"${raw}" is not a URL`,
69
+ fix: `set ${OTLP_ENDPOINT_KEY}=http://otel-collector:4318`,
70
+ meta: { endpoint: raw, signal },
71
+ });
72
+ }
73
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
74
+ throw new OtlpEndpointInvalidError({
75
+ cause: `"${raw}" is ${url.protocol}, and OTLP/HTTP needs http or https`,
76
+ fix: `set ${OTLP_ENDPOINT_KEY}=http://otel-collector:4318`,
77
+ meta: { endpoint: raw, signal },
78
+ });
79
+ }
80
+ assertHttpJson(signal, url, env);
81
+ // The spec's own asymmetry, not ours: a per-signal endpoint is the full URL an operator chose,
82
+ // while the generic one is a base the signal path is appended to.
83
+ if (perSignal) return url.toString();
84
+ return `${url.toString().replace(/\/+$/, '')}/v1/${signal}`;
85
+ }
86
+
87
+ /** The endpoint an operator configured, or `undefined` when they configured none. */
88
+ export function tryOtlpEndpoint(
89
+ signal: OtlpSignal,
90
+ env: OtlpEnv = process.env,
91
+ ): string | undefined {
92
+ const specific = env[signalKey(signal, 'ENDPOINT')]?.trim();
93
+ if (specific !== undefined && specific !== '') return parseEndpoint(signal, specific, true, env);
94
+ const generic = env[OTLP_ENDPOINT_KEY]?.trim();
95
+ if (generic === undefined || generic === '') return undefined;
96
+ return parseEndpoint(signal, generic, false, env);
97
+ }
98
+
99
+ /** The endpoint, or a coded error naming the variable to set. */
100
+ export function otlpEndpoint(
101
+ signal: OtlpSignal,
102
+ explicit?: string | undefined,
103
+ env: OtlpEnv = process.env,
104
+ ): string {
105
+ if (explicit !== undefined && explicit !== '') return parseEndpoint(signal, explicit, true, env);
106
+ const resolved = tryOtlpEndpoint(signal, env);
107
+ if (resolved !== undefined) return resolved;
108
+ throw new OtlpEndpointInvalidError({
109
+ cause: `no OTLP endpoint: neither ${signalKey(signal, 'ENDPOINT')} nor ${OTLP_ENDPOINT_KEY} is set, and none was passed`,
110
+ fix: `set ${OTLP_ENDPOINT_KEY}=http://otel-collector:4318, or skip the exporter when tryOtlpEndpoint('${signal}') is undefined`,
111
+ meta: { signal },
112
+ });
113
+ }
114
+
115
+ /** `key=value,key2=value2`, percent-decoded — the spec's format for collector auth headers. */
116
+ export function otlpHeaders(
117
+ explicit?: Readonly<Record<string, string>> | undefined,
118
+ env: OtlpEnv = process.env,
119
+ ): Record<string, string> {
120
+ const headers: Record<string, string> = { 'content-type': 'application/json' };
121
+ const raw = env[OTLP_HEADERS_KEY];
122
+ if (raw !== undefined) {
123
+ for (const pair of raw.split(',')) {
124
+ const index = pair.indexOf('=');
125
+ if (index <= 0) continue;
126
+ const key = pair.slice(0, index).trim().toLowerCase();
127
+ if (key === '') continue;
128
+ headers[key] = decodeURIComponent(pair.slice(index + 1).trim());
129
+ }
130
+ }
131
+ for (const [key, value] of Object.entries(explicit ?? {})) headers[key.toLowerCase()] = value;
132
+ return headers;
133
+ }
134
+
135
+ export interface OtlpAnyValue {
136
+ readonly stringValue?: string;
137
+ readonly boolValue?: boolean;
138
+ readonly intValue?: string;
139
+ readonly doubleValue?: number;
140
+ readonly arrayValue?: { readonly values: readonly OtlpAnyValue[] };
141
+ }
142
+
143
+ export interface OtlpKeyValue {
144
+ readonly key: string;
145
+ readonly value: OtlpAnyValue;
146
+ }
147
+
148
+ function anyValue(value: AttributeValue): OtlpAnyValue {
149
+ if (typeof value === 'string') return { stringValue: value };
150
+ if (typeof value === 'boolean') return { boolValue: value };
151
+ if (typeof value === 'number') {
152
+ // `intValue` is a 64-bit field, so the JSON encoding spells it as a string. A float that
153
+ // happens to be integral is still a double to whoever queries it; `Number.isInteger` is the
154
+ // only signal available and matches what every other OTLP/JSON encoder does.
155
+ return Number.isInteger(value) ? { intValue: String(value) } : { doubleValue: value };
156
+ }
157
+ return { arrayValue: { values: value.map((item) => anyValue(item)) } };
158
+ }
159
+
160
+ export function otlpAttributes(
161
+ attributes: Readonly<Record<string, AttributeValue>>,
162
+ ): readonly OtlpKeyValue[] {
163
+ return Object.entries(attributes).map(([key, value]) => ({ key, value: anyValue(value) }));
164
+ }
165
+
166
+ /** Epoch ms -> the string of nanoseconds OTLP/JSON wants, without losing precision to a float. */
167
+ export function unixNano(epochMs: number): string {
168
+ return `${Math.round(epochMs)}000000`;
169
+ }
170
+
171
+ export function otlpResource(resource: SpanResource): {
172
+ readonly attributes: readonly OtlpKeyValue[];
173
+ } {
174
+ return {
175
+ attributes: otlpAttributes({
176
+ 'service.name': resource.serviceName,
177
+ 'service.version': resource.serviceVersion,
178
+ }),
179
+ };
180
+ }
181
+
182
+ /** The instrumentation scope every signal this package emits belongs to. */
183
+ export const OTLP_SCOPE = Object.freeze({ name: '@ultimat3/core' });
184
+
185
+ export interface OtlpPostOptions {
186
+ readonly url: string;
187
+ readonly headers: Readonly<Record<string, string>>;
188
+ readonly body: string;
189
+ readonly timeoutMs: number;
190
+ readonly fetch: typeof globalThis.fetch;
191
+ }
192
+
193
+ /**
194
+ * Never throws and never rejects. Telemetry delivery is not the request: a collector that is down
195
+ * must not become the app's outage, and the alternative — an unhandled rejection from a `void`ed
196
+ * promise — takes the process with it on Bun.
197
+ */
198
+ export async function postOtlp(options: OtlpPostOptions): Promise<void> {
199
+ try {
200
+ const response = await options.fetch(options.url, {
201
+ method: 'POST',
202
+ headers: { ...options.headers },
203
+ body: options.body,
204
+ signal: AbortSignal.timeout(options.timeoutMs),
205
+ });
206
+ if (!response.ok) {
207
+ logger.warn('otlp export rejected', { url: options.url, status: response.status });
208
+ }
209
+ } catch (failure) {
210
+ logger.warn('otlp export failed', {
211
+ url: options.url,
212
+ // `renderThrowable`: the read that renders a caught value must not itself throw, or the
213
+ // export failure this catch exists to swallow escapes as an unhandled rejection.
214
+ error: renderThrowable(failure),
215
+ });
216
+ }
217
+ }
@@ -0,0 +1,47 @@
1
+ // Single responsibility: reading a request body through a COUNTING reader, so a payload past the
2
+ // cap is never held in full. It lives in core because two transports need the identical guarantee
3
+ // and cannot share it any other way: `@ultimat3/http` (tier 2) owns `bodyLimitBytes`, and
4
+ // `@ultimat3/mcp` (tier 4) serves a bare `Request` that never passes through http's pipeline —
5
+ // `await request.json()` there was governed only by Bun's 128 MiB default.
6
+
7
+ /** What a body read produced: the bytes, or the running total at the moment it went over. */
8
+ export type CappedBody = { readonly bytes: Uint8Array } | { readonly over: number };
9
+
10
+ /**
11
+ * The body, read through the stream and abandoned the instant the running total passes `limit`.
12
+ * `arrayBuffer()`/`json()` materialise first and check after, so a `transfer-encoding: chunked`
13
+ * request — one with no `content-length` for a pre-check to read — allocated its whole payload
14
+ * before the 413 it was going to get anyway. A declared length is a courtesy, not a guard.
15
+ */
16
+ export const readWithinLimit = async (
17
+ body: ReadableStream<Uint8Array> | null,
18
+ limit: number,
19
+ ): Promise<CappedBody> => {
20
+ if (body === null) return { bytes: new Uint8Array(0) };
21
+ const reader = body.getReader();
22
+ const chunks: Uint8Array[] = [];
23
+ let total = 0;
24
+ try {
25
+ while (true) {
26
+ const { done, value } = await reader.read();
27
+ if (done) break;
28
+ total += value.byteLength;
29
+ if (total > limit) {
30
+ // Cancelled rather than drained: the peer is told to stop sending, and nothing past the
31
+ // cap is ever held. Draining is how a rejected request still costs its full transfer.
32
+ await reader.cancel();
33
+ return { over: total };
34
+ }
35
+ chunks.push(value);
36
+ }
37
+ } finally {
38
+ reader.releaseLock();
39
+ }
40
+ const bytes = new Uint8Array(total);
41
+ let offset = 0;
42
+ for (const chunk of chunks) {
43
+ bytes.set(chunk, offset);
44
+ offset += chunk.byteLength;
45
+ }
46
+ return { bytes };
47
+ };
@@ -48,6 +48,11 @@ export const jobs: Counter = counter('jobs_total', {
48
48
  description: 'Background jobs finished, by queue and outcome',
49
49
  });
50
50
 
51
+ export const leasesLost: Counter = counter('job_leases_lost_total', {
52
+ unit: '{job}',
53
+ description: 'Job leases that lapsed while the job was still running, by queue',
54
+ });
55
+
51
56
  export interface RequestSample {
52
57
  readonly method: string;
53
58
  /** The route PATTERN (`/posts/:id`), never the concrete path — one series per pattern. */
@@ -84,3 +89,13 @@ export function recordQueueDepth(queue: string, depth: number): void {
84
89
  export function recordJob(queue: string, outcome: 'ok' | 'failed' | 'dead'): void {
85
90
  jobs.add(1, { queue, outcome });
86
91
  }
92
+
93
+ /**
94
+ * One per job whose lease the holding process could not renew inside the visibility timeout.
95
+ * Deliberately not an outcome on `jobs_total`: nothing failed and nothing finished — the queue
96
+ * simply handed the job to somebody else while this process was still running it. Every point on
97
+ * this series is one job that ran twice, which is the only reason it is worth a series at all.
98
+ */
99
+ export function recordLeaseLost(queue: string): void {
100
+ leasesLost.add(1, { queue });
101
+ }
@@ -0,0 +1,50 @@
1
+ // Single responsibility: deciding whether a URL is safe to put in a URL-bearing HTML attribute.
2
+ // It lives in core because the packages that need it — `@ultimat3/render` (the SSR attribute
3
+ // writer) and `@ultimat3/ui` (an anchor whose href comes off a row) — are tiers 4 and 5 and one
4
+ // cannot import the other; core is the lowest tier both reach. Same reason as `timing-safe-equal`.
5
+
6
+ /** The attributes a browser will FOLLOW. A scheme in any of them is executable. */
7
+ export const URL_ATTRIBUTES: readonly string[] = ['href', 'src', 'action', 'formaction'];
8
+
9
+ /**
10
+ * `javascript:` and `vbscript:` are the two that execute; everything not on this list is refused
11
+ * rather than judged, because "which exotic scheme is harmless" is a question that gets a wrong
12
+ * answer once and then ships. The five here are the ones an anchor in an app actually carries —
13
+ * a refusal is SILENT (no attribute at all), so a scheme missing from this list is a dead link,
14
+ * which is why the list is the realistic set rather than the minimal one.
15
+ */
16
+ const SAFE_SCHEMES: readonly string[] = ['http:', 'https:', 'mailto:', 'tel:', 'sms:'];
17
+
18
+ const SCHEME = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
19
+
20
+ /**
21
+ * The value to emit, or `null` when the attribute must not be emitted at all. An anchor with no
22
+ * `href` is inert and still renders its text, which is strictly better than a live one nobody
23
+ * checked.
24
+ *
25
+ * `attribute` is compared lowercased by the caller's own table, so pass the HTML spelling.
26
+ */
27
+ export function safeUrl(value: string, attribute: string): string | null {
28
+ // A browser deletes TAB, CR and LF anywhere in a URL and trims leading C0 controls and spaces
29
+ // before parsing it, so `java\tscript:alert(1)` is `javascript:` by the time it is followed —
30
+ // the scheme has to be read off the stripped form, never off the raw one.
31
+ // Built by code point rather than a regex character class because Biome refuses a control
32
+ // character in a pattern - and it is right to: this is the one place that wants them named.
33
+ const stripped = [...value]
34
+ .filter((char) => {
35
+ const code = char.codePointAt(0) ?? 0;
36
+ return code > 0x20 && code !== 0x7f;
37
+ })
38
+ .join('');
39
+ const matched = SCHEME.exec(stripped);
40
+ // No scheme at all: relative, absolute-path, protocol-relative, query or fragment. Nothing here
41
+ // can execute, and refusing them would refuse most of the links an app writes.
42
+ if (matched === null) return value;
43
+ const scheme = matched[0].toLowerCase();
44
+ if (SAFE_SCHEMES.includes(scheme)) return value;
45
+ // The one data URL kept, and only where the bytes are RENDERED rather than navigated to: the
46
+ // framework's own blur placeholder is a `data:image/webp`. In an `href` a data URL is a document
47
+ // that runs on nothing but is still a phishing surface, so it is refused there.
48
+ if (attribute === 'src' && stripped.slice(0, 11).toLowerCase() === 'data:image/') return value;
49
+ return null;
50
+ }
package/src/sampler.ts ADDED
@@ -0,0 +1,126 @@
1
+ // Single responsibility: the sampling decision — the one lever between "tracing is on" and "the
2
+ // collector melts". Separate from `telemetry.ts` because the decision is a policy an app replaces,
3
+ // while span construction is not.
4
+
5
+ import { logger } from './logger';
6
+ import type { SpanAttributes, SpanContext } from './telemetry';
7
+
8
+ /**
9
+ * The seam. `parent` is the inbound span context when there is one — an upstream that decided
10
+ * "not sampled" has said so in `parent.traceFlags`, and a sampler that ignores it splits one
11
+ * distributed trace into a sampled half and an unsampled half, which is worse than either.
12
+ */
13
+ export interface Sampler {
14
+ shouldSample(name: string, parent: SpanContext | undefined, attributes: SpanAttributes): boolean;
15
+ }
16
+
17
+ export const OTEL_SAMPLER_KEY = 'OTEL_TRACES_SAMPLER';
18
+ export const OTEL_SAMPLER_ARG_KEY = 'OTEL_TRACES_SAMPLER_ARG';
19
+
20
+ /** Unset means on, exactly as OTel's own `parentbased_always_on` default does. */
21
+ export const DEFAULT_SAMPLE_RATIO = 1;
22
+
23
+ export const alwaysOnSampler: Sampler = Object.freeze({
24
+ shouldSample: (): boolean => true,
25
+ });
26
+
27
+ export const alwaysOffSampler: Sampler = Object.freeze({
28
+ shouldSample: (): boolean => false,
29
+ });
30
+
31
+ function parentSampled(parent: SpanContext | undefined): boolean | undefined {
32
+ return parent === undefined ? undefined : (parent.traceFlags & 1) === 1;
33
+ }
34
+
35
+ /**
36
+ * Honour the parent, else sample a `ratio` fraction of new traces.
37
+ *
38
+ * `random()` rather than a hash of the trace id: this sampler only ever decides for a ROOT span —
39
+ * a span with a parent takes the parent's bit verbatim — so there is no second service whose
40
+ * independent decision has to agree with ours, which is the only thing trace-id hashing buys.
41
+ * `random` is injectable so the ratio is a test and not a coin flip.
42
+ */
43
+ export function parentBasedRatioSampler(
44
+ ratio: number,
45
+ random: () => number = Math.random,
46
+ ): Sampler {
47
+ return {
48
+ shouldSample(_name, parent): boolean {
49
+ const inherited = parentSampled(parent);
50
+ if (inherited !== undefined) return inherited;
51
+ if (ratio >= 1) return true;
52
+ if (ratio <= 0) return false;
53
+ return random() < ratio;
54
+ },
55
+ };
56
+ }
57
+
58
+ /** `always_off` / `always_on` without the parent-based prefix ignore the inbound decision. */
59
+ export function ratioSampler(ratio: number, random: () => number = Math.random): Sampler {
60
+ return {
61
+ shouldSample(): boolean {
62
+ if (ratio >= 1) return true;
63
+ if (ratio <= 0) return false;
64
+ return random() < ratio;
65
+ },
66
+ };
67
+ }
68
+
69
+ function readRatio(raw: string | undefined, spelling: string): number {
70
+ if (raw === undefined || raw.trim() === '') return DEFAULT_SAMPLE_RATIO;
71
+ const parsed = Number.parseFloat(raw);
72
+ if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
73
+ // Warn rather than throw: this is read at the first span, not at boot, and a process that
74
+ // dies mid-request over a sampling typo has turned an observability misconfiguration into an
75
+ // outage. Falling back to 1 keeps the traces — losing them silently is the worse failure.
76
+ logger.warn('X_TELEMETRY_SAMPLER_ARG_INVALID', {
77
+ cause: `${spelling}="${raw}" is not a ratio between 0 and 1; sampling every trace instead`,
78
+ fix: `set ${spelling} to a value between 0 and 1, e.g. ${spelling}=0.05`,
79
+ });
80
+ return DEFAULT_SAMPLE_RATIO;
81
+ }
82
+ return parsed;
83
+ }
84
+
85
+ /**
86
+ * The sampler the env asks for. Recognises the OTel spellings an operator already knows; anything
87
+ * else falls through to parent-based ratio, which is the default behaviour either way.
88
+ */
89
+ export function samplerFromEnv(
90
+ env: Readonly<Record<string, string | undefined>> = process.env,
91
+ ): Sampler {
92
+ const name = (env[OTEL_SAMPLER_KEY] ?? '').trim().toLowerCase();
93
+ const ratio = readRatio(env[OTEL_SAMPLER_ARG_KEY], OTEL_SAMPLER_ARG_KEY);
94
+ switch (name) {
95
+ case 'always_off':
96
+ return alwaysOffSampler;
97
+ case 'always_on':
98
+ return alwaysOnSampler;
99
+ case 'traceidratio':
100
+ return ratioSampler(ratio);
101
+ case 'parentbased_always_off':
102
+ return parentBasedRatioSampler(0);
103
+ default:
104
+ // `parentbased_always_on`, `parentbased_traceidratio` and the unset case are one sampler:
105
+ // honour the parent, else the ratio — which is 1 when nothing set an arg.
106
+ return parentBasedRatioSampler(ratio);
107
+ }
108
+ }
109
+
110
+ let cached: Sampler | undefined;
111
+
112
+ /**
113
+ * Read at the first span, never at module scope: `installSecrets()` and `defineEnv()` both land
114
+ * values in `process.env` during boot, and a module-scope read would pin whatever was set before
115
+ * the app configured itself — the same defect `cursor.ts` fixed by moving its secret read into
116
+ * `sign()`.
117
+ */
118
+ export function defaultSampler(): Sampler {
119
+ if (cached === undefined) cached = samplerFromEnv();
120
+ return cached;
121
+ }
122
+
123
+ /** Test-only: forget the env-derived sampler so the next read sees the current environment. */
124
+ export function resetDefaultSampler(): void {
125
+ cached = undefined;
126
+ }
@@ -0,0 +1,28 @@
1
+ // Single responsibility: register `@ultimat3/schema`'s error codes so their titles render for any
2
+ // process that imports `@ultimat3/core` — not just the CLI. `@ultimat3/schema` is tier 0 alongside
3
+ // this package, so it cannot call `registerErrorCodes()` itself (that would mean importing core,
4
+ // a same-tier import) and this package cannot import schema to read its declarations back (same
5
+ // reason, the other direction). The codes below are a deliberate, tested duplicate of
6
+ // `SCHEMA_ERROR_CODES` in `packages/schema/src/errors.ts` — `schema-error-codes-pin.test.ts`, in a
7
+ // package that may legally import both (`@ultimat3/cli`), asserts them equal so a title edited in
8
+ // one place and not the other fails the build instead of quietly disagreeing at runtime.
9
+
10
+ import { registerErrorCodes } from './error-codes';
11
+
12
+ /** Mirrors `SCHEMA_ERROR_CODES` in `packages/schema/src/errors.ts`. Keep the titles identical. */
13
+ export const SCHEMA_ERROR_CODE_TITLES: Readonly<Record<string, string>> = Object.freeze({
14
+ X_VALIDATION_FAILED: 'value did not match its schema',
15
+ X_SCHEMA_UNSUPPORTED: 'the active schema provider cannot do this',
16
+ X_SCHEMA_DISCRIMINANT_INVALID: 'a discriminated union member can never be dispatched to',
17
+ X_SCHEMA_DEFAULT_UNSHAREABLE: 'a schema default cannot be copied per parse',
18
+ });
19
+
20
+ // Registered here rather than in `error-codes.ts`'s `CORE_CODE_TITLES` because core does not own
21
+ // these codes — `@ultimat3/schema` does — and `registerErrorCodes` is the one mechanism that
22
+ // raises `X_ERROR_CODE_DUPLICATE` if a package that DOES own one of them ever tries to register it
23
+ // too, which pins ownership even though the titles live in two files.
24
+ registerErrorCodes(
25
+ Object.fromEntries(
26
+ Object.entries(SCHEMA_ERROR_CODE_TITLES).map(([code, title]) => [code, { title }]),
27
+ ),
28
+ );