@ultimat3/core 3.0.0 → 4.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.
- package/CLAUDE.md +26 -0
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/actor.ts +27 -1
- package/src/canonical-json.ts +102 -0
- package/src/config.ts +33 -10
- package/src/decimal-order.ts +76 -0
- package/src/env-example.ts +9 -1
- package/src/error-retry.ts +25 -11
- package/src/image/errors.ts +1 -1
- package/src/index.ts +8 -12
- package/src/logger.ts +21 -2
- package/src/metrics-types.ts +102 -0
- package/src/metrics.ts +73 -100
- package/src/otlp-metric-exporter.ts +26 -3
- package/src/otlp-span-exporter.ts +28 -3
- package/src/type-pins.ts +10 -3
package/CLAUDE.md
CHANGED
|
@@ -101,6 +101,32 @@ the pin (`schema-error-codes-pin.test.ts`) lives in `@ultimat3/cli`, which may l
|
|
|
101
101
|
`@ultimat3/storage` both need — core is the lowest tier both can reach, so the shared code lives
|
|
102
102
|
here rather than in either package copying the other's file.
|
|
103
103
|
|
|
104
|
+
`canonical-json.ts` is the same shape for the hash every SHARING key in the framework is taken
|
|
105
|
+
over, `As of 2026-08`. `canonicalJson` is an INJECTIVE canonical form and `fingerprint` is
|
|
106
|
+
SHA-256/16 of it, and three tier-3 packages needed exactly this while none may import another:
|
|
107
|
+
`@ultimat3/action`'s `requestHash` and job dedupe key, `@ultimat3/query`'s `queryHash` (a
|
|
108
|
+
read-cache entry, a cursor scope, a live query id) and `@ultimat3/realtime`'s `qid`. Each kept its
|
|
109
|
+
own copy and the copies had **diverged in a way that leaked**: query's had no `Date` branch, so
|
|
110
|
+
`Object.keys(date)` was `[]`, every date rendered `{}`, and one cache key, one cursor scope and one
|
|
111
|
+
live window answered for every date window of a read — reachable straight off a query string, since
|
|
112
|
+
`coerceQuery` turns a `t.date` member into a real `Date`. Injective is the whole requirement, not a
|
|
113
|
+
formatting preference: every one of those keys decides which of two callers is served the other's
|
|
114
|
+
answer. So `NaN`, `±Infinity` and `-0` are bare tokens the quoting `string` branch cannot spell,
|
|
115
|
+
and a `Date`, a `Map` and a `Set` — the three values with no own enumerable key — are TAGGED. Never
|
|
116
|
+
add a fourth copy, and never make it parseable: `@ultimat3/action`'s `stableStringify` is the
|
|
117
|
+
DOCUMENT form for that (it publishes `openapi.json`), and it is a different function on purpose.
|
|
118
|
+
|
|
119
|
+
`decimal-order.ts` is the third instance of the same rule, over a value rather than a shape.
|
|
120
|
+
`compareDecimalText` is the exact ordering of two decimals however long the digits run — the order
|
|
121
|
+
Postgres gives a `numeric` or an `int8` over the TEXT `@ultimat3/entity`'s `bigint()` and
|
|
122
|
+
`decimal()` hand back, where `String(left) < String(right)` answers `["10","100","2","9"]` for
|
|
123
|
+
`["2","9","10","100"]` and cuts a keyset page where the database does not. It answers **`undefined`**
|
|
124
|
+
when either side is not a plain decimal, and that is the contract, not a convenience: a caller that
|
|
125
|
+
knows the column's declared kind asks (`@ultimat3/entity`'s `compareByKind`), and a caller that does
|
|
126
|
+
NOT — `@ultimat3/query`, whose `OrderKey` is a name and a direction — must never, because Postgres
|
|
127
|
+
orders a `text` column of digits lexically and a comparator guessing would trade one disagreement
|
|
128
|
+
with the SQL it printed for another.
|
|
129
|
+
|
|
104
130
|
`mcp-exposure.ts` is the same shape for a declaration rather than an algorithm: `isMcpExposed` is
|
|
105
131
|
the ONE answer to "did this primitive opt into being an MCP tool?", asked by `action`, `query`
|
|
106
132
|
(t3), `mcp`, `ai`, `manifest` (t4) — five packages that cannot import each other, so core is the
|
package/README.md
CHANGED
|
@@ -343,6 +343,7 @@ collectMetrics(); // the same numbers as data, for a MetricExporter
|
|
|
343
343
|
| Names | lowercase `snake_case`, the intersection every exposition format accepts. Dotted OTel names survive OTLP and die at a Prometheus scrape |
|
|
344
344
|
| Attributes | `string \| number \| boolean` only — each distinct set is a stored series, so a user id here is an outage |
|
|
345
345
|
| Cardinality | enforced, not advised: `maxSeries` per instrument (default `DEFAULT_MAX_SERIES`), and past it every new label set folds into one `otel_metric_overflow="true"` series with `X_METRIC_CARDINALITY` logged once, naming the instrument |
|
|
346
|
+
| Async gauges | an `observe()` that throws, or answers a non-finite number, costs that instrument its point and **nothing beside it** — `X_METRIC_VALUE_INVALID` logged once, naming the instrument. Unguarded it took the whole `/metrics` body down with it, and `startMetricExport`'s timer callback raised where nothing can catch it |
|
|
346
347
|
| Driver seam | `MetricExporter`, defaulting to a no-op. `memoryMetricExporter()` for tests, `startMetricExport(ms)` for a periodic push, `otlpMetricExporter()` for a collector |
|
|
347
348
|
|
|
348
349
|
`runtime-metrics.ts` holds the series every process emits, and `SCALING_METRICS` maps each
|
package/package.json
CHANGED
package/src/actor.ts
CHANGED
|
@@ -76,8 +76,27 @@ export interface Actor {
|
|
|
76
76
|
readonly orgId?: string | undefined;
|
|
77
77
|
/** Application roles (`admin`, `editor`). Unrelated to the runtime `Role`. */
|
|
78
78
|
readonly roles: readonly string[];
|
|
79
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* FRAMEWORK capabilities, checked with `hasScope()`. One reader in the whole framework —
|
|
81
|
+
* `@ultimat3/entity`'s `crossTenant()`, gating `tenancy:cross` — and that narrowness is the
|
|
82
|
+
* point: a scope is an escape hatch the framework itself honours, never the app's authz
|
|
83
|
+
* vocabulary. That is `roles` and `permissions`, which `@ultimat3/policy` reads.
|
|
84
|
+
*/
|
|
80
85
|
readonly scopes: readonly string[];
|
|
86
|
+
/**
|
|
87
|
+
* DIRECT grants, bypassing roles: what a service token or a break-glass account holds
|
|
88
|
+
* (`post:publish`). `@ultimat3/policy` flattens these together with every grant `roles` expands
|
|
89
|
+
* to, so the two are one set by the time a predicate runs, and neither is `scopes`.
|
|
90
|
+
*
|
|
91
|
+
* Declared here rather than on policy's own `Actor` (`As of 2026-08-19`). It was policy's, which
|
|
92
|
+
* made it unreachable from the one place actors are built: core is tier 0 and cannot import
|
|
93
|
+
* policy, so `build()` below had no field to carry and `userActor({ permissions })` compiled and
|
|
94
|
+
* silently discarded the argument. Every caller worked around it with
|
|
95
|
+
* `{ ...userActor({ id }), permissions: [...] }` — a spread over a frozen actor, producing an
|
|
96
|
+
* UNFROZEN one — so the fixtures proving authz had a shape no request ever mints. `@ultimat3/auth`
|
|
97
|
+
* carried a second, hand-synced copy of the declaration for the same tier reason.
|
|
98
|
+
*/
|
|
99
|
+
readonly permissions: readonly string[];
|
|
81
100
|
/** App-declared facts. Read it through `actorFact()`; never logged — `actorLabel` is id-only. */
|
|
82
101
|
readonly facts?: ActorFactMap | undefined;
|
|
83
102
|
/**
|
|
@@ -93,6 +112,8 @@ export interface ActorInit {
|
|
|
93
112
|
readonly orgId?: string | undefined;
|
|
94
113
|
readonly roles?: readonly string[] | undefined;
|
|
95
114
|
readonly scopes?: readonly string[] | undefined;
|
|
115
|
+
/** Direct grants. Absent means none — never "inherit some"; there is nothing to inherit from. */
|
|
116
|
+
readonly permissions?: readonly string[] | undefined;
|
|
96
117
|
readonly facts?: ActorFactMap | undefined;
|
|
97
118
|
/** For a session that already recorded an impersonation; `impersonate()` sets it otherwise. */
|
|
98
119
|
readonly onBehalfOf?: ActorOrigin | undefined;
|
|
@@ -105,6 +126,7 @@ const ANONYMOUS: Actor = Object.freeze({
|
|
|
105
126
|
id: 'anonymous',
|
|
106
127
|
roles: Object.freeze([]),
|
|
107
128
|
scopes: Object.freeze([]),
|
|
129
|
+
permissions: Object.freeze([]),
|
|
108
130
|
facts: NO_FACTS,
|
|
109
131
|
});
|
|
110
132
|
|
|
@@ -115,6 +137,10 @@ function build(kind: ActorKind, init: ActorInit): Actor {
|
|
|
115
137
|
orgId: init.orgId,
|
|
116
138
|
roles: Object.freeze([...(init.roles ?? [])]),
|
|
117
139
|
scopes: Object.freeze([...(init.scopes ?? [])]),
|
|
140
|
+
// Copied and frozen like the two above, and for the sharper reason: this list IS the actor's
|
|
141
|
+
// authz. Handing back the caller's array would let whoever still holds it `push` a grant into
|
|
142
|
+
// a decision already made about a frozen actor.
|
|
143
|
+
permissions: Object.freeze([...(init.permissions ?? [])]),
|
|
118
144
|
facts: Object.freeze({ ...init.facts }),
|
|
119
145
|
onBehalfOf: init.onBehalfOf === undefined ? undefined : Object.freeze({ ...init.onBehalfOf }),
|
|
120
146
|
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The INJECTIVE canonical form of a value, and the sharing key taken over it.
|
|
3
|
+
*
|
|
4
|
+
* Tier 0 because three tier-3 packages need exactly this and none of them may import another:
|
|
5
|
+
* `@ultimat3/action`'s `requestHash` and job dedupe key, `@ultimat3/query`'s `queryHash` (the
|
|
6
|
+
* read-cache entry, the cursor scope, the live query id) and `@ultimat3/realtime`'s `qid`. Each
|
|
7
|
+
* kept its own copy, and the copies had already diverged — query's rendered every `Date` as `{}`,
|
|
8
|
+
* so one key answered for every date window a read ever served.
|
|
9
|
+
*
|
|
10
|
+
* Injective is the whole requirement: every one of those keys decides which of two callers is
|
|
11
|
+
* served the other's answer, so two distinct inputs sharing one string is a leak and not a
|
|
12
|
+
* collision. Nothing here is ever parsed back — `@ultimat3/action`'s `stableStringify` is the
|
|
13
|
+
* DOCUMENT form for that, and it is a different function on purpose.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Bare tokens, never quoted and never `'null'`. This output is only ever hashed, so an unquoted
|
|
18
|
+
* word cannot collide with the `string` branch (which always quotes), while `'null'` collided with
|
|
19
|
+
* JSON `null` itself — `{ n: NaN }`, `{ n: Infinity }`, `{ n: -Infinity }` and `{ n: null }` were
|
|
20
|
+
* one key and therefore one idempotency record, one cache entry and one cursor scope. `-0` is
|
|
21
|
+
* spelled out for the same reason: `String(-0)` is `"0"`.
|
|
22
|
+
*/
|
|
23
|
+
function hashNumber(value: number): string {
|
|
24
|
+
if (Number.isNaN(value)) return 'NaN';
|
|
25
|
+
if (!Number.isFinite(value)) return value > 0 ? 'Infinity' : '-Infinity';
|
|
26
|
+
return Object.is(value, -0) ? '-0' : String(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The epoch, TAGGED — the same per-type tagging `hashNumber` uses for `NaN` and `-0`, for the same
|
|
31
|
+
* reason. Untagged, a `t.date` field and a `t.number` field holding that field's epoch would be one
|
|
32
|
+
* key, which is the collision this form exists to refuse; an Invalid Date would be the bare `NaN`
|
|
33
|
+
* token a `t.number` field already owns.
|
|
34
|
+
*/
|
|
35
|
+
function hashDate(value: Date): string {
|
|
36
|
+
return `Date(${hashNumber(value.getTime())})`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Tagged for the reason a `Date` is: a `Map`, a `Set` and `{}` all have no own enumerable key, so
|
|
41
|
+
* the object branch rendered all three `{}` and one hash answered for three payloads sharing
|
|
42
|
+
* nothing. Entries are SORTED, as an object's keys are: insertion order is not part of what a Map
|
|
43
|
+
* or a Set holds, so two spellings of one payload stay one key.
|
|
44
|
+
*/
|
|
45
|
+
function hashCollection(value: Map<unknown, unknown> | Set<unknown>): string {
|
|
46
|
+
const entries =
|
|
47
|
+
value instanceof Map
|
|
48
|
+
? [...value].map(([key, item]) => `${canonicalJson(key)}:${canonicalJson(item)}`)
|
|
49
|
+
: [...value].map((item) => canonicalJson(item));
|
|
50
|
+
return `${value instanceof Map ? 'Map' : 'Set'}(${entries.sort().join(',')})`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* JSON with object keys sorted at every depth, and every value a JSON document would fold onto
|
|
55
|
+
* `null` or `{}` given a token of its own. No timestamps, no insertion-order leaks.
|
|
56
|
+
*/
|
|
57
|
+
export function canonicalJson(value: unknown): string {
|
|
58
|
+
if (value === null) return 'null';
|
|
59
|
+
switch (typeof value) {
|
|
60
|
+
case 'string':
|
|
61
|
+
return JSON.stringify(value);
|
|
62
|
+
case 'number':
|
|
63
|
+
return hashNumber(value);
|
|
64
|
+
case 'boolean':
|
|
65
|
+
return String(value);
|
|
66
|
+
case 'bigint':
|
|
67
|
+
return JSON.stringify(`${value}n`);
|
|
68
|
+
case 'undefined':
|
|
69
|
+
case 'function':
|
|
70
|
+
case 'symbol':
|
|
71
|
+
return 'null';
|
|
72
|
+
default:
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
// Ahead of the object branch, because none of the three has an own enumerable key: `Object.keys`
|
|
76
|
+
// is empty for all of them and the branch below would answer `{}` for every date, every map and
|
|
77
|
+
// every set alike.
|
|
78
|
+
if (value instanceof Date) return hashDate(value);
|
|
79
|
+
if (value instanceof Map || value instanceof Set) return hashCollection(value);
|
|
80
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
81
|
+
const record = value as Record<string, unknown>;
|
|
82
|
+
const keys = Object.keys(record)
|
|
83
|
+
.filter((key) => record[key] !== undefined)
|
|
84
|
+
.sort();
|
|
85
|
+
const entries = keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`);
|
|
86
|
+
return `{${entries.join(',')}}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* SHA-256, first 16 hex characters — the same primitive and width `@ultimat3/entity`'s `planScope`
|
|
91
|
+
* already chose, and for the same reason.
|
|
92
|
+
*
|
|
93
|
+
* This is a SHARING key over input a client chooses, not a checksum. It decides "same request,
|
|
94
|
+
* replay the stored response", which read-cache entry two callers are served from, which scope a
|
|
95
|
+
* cursor is bound to and which subscribers are served out of one live window — so a collision
|
|
96
|
+
* hands one caller another's rows. FNV-1a/32, which two of the three copies started as, is
|
|
97
|
+
* 4x10^9 values and brute-forceable offline in seconds: an input landing on another read's key was
|
|
98
|
+
* something an attacker could mint rather than something they had to wait for.
|
|
99
|
+
*/
|
|
100
|
+
export function fingerprint(value: unknown): string {
|
|
101
|
+
return new Bun.CryptoHasher('sha256').update(canonicalJson(value)).digest('hex').slice(0, 16);
|
|
102
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -28,13 +28,28 @@ export interface ThemeConfig {
|
|
|
28
28
|
*/
|
|
29
29
|
export interface AuthConfig {
|
|
30
30
|
readonly signInPath: string | null;
|
|
31
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* Where sign-in lands when there is nowhere to return to, or `?next=` is not same-origin.
|
|
33
|
+
*
|
|
34
|
+
* **Consulted by nothing, `As of 2026-08.`** Accepted, defaulted and merged here and read by no
|
|
35
|
+
* file in the repo — `dummy/social-media-clone/app.config.ts` sets `/dashboard` and gets
|
|
36
|
+
* whatever the sign-in route does on its own. Same shape `urlEnv`, `poolSize` and `schema` were
|
|
37
|
+
* deleted for below; this one is not deleted yet only because its writer is a tracked app's
|
|
38
|
+
* config, so removing the key and the line that sets it is one commit across two file sets.
|
|
39
|
+
*/
|
|
32
40
|
readonly afterSignInPath: string;
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
export interface PwaConfig {
|
|
36
44
|
readonly enabled: boolean;
|
|
37
45
|
readonly offline: OfflineStrategy;
|
|
46
|
+
/**
|
|
47
|
+
* **Consulted by nothing, `As of 2026-08.`** `wiki/Configuration.md` describes it as "render
|
|
48
|
+
* your own install affordance from the deferred event", both tracked apps set it, and
|
|
49
|
+
* `x new`'s scaffold writes it into every generated app — and no file reads it.
|
|
50
|
+
* `@ultimat3/pwa`'s `install.ts` is real and complete; nothing threads this flag into it.
|
|
51
|
+
* Delete the key or thread it; leaving it is a switch with no wire.
|
|
52
|
+
*/
|
|
38
53
|
readonly installPrompt: boolean;
|
|
39
54
|
readonly backgroundSync: boolean;
|
|
40
55
|
readonly push: boolean;
|
|
@@ -76,12 +91,18 @@ export interface JobsConfig {
|
|
|
76
91
|
readonly visibilityTimeoutMs: number;
|
|
77
92
|
}
|
|
78
93
|
|
|
94
|
+
/**
|
|
95
|
+
* No `heartbeatMs`. It was declared here, defaulted to 15_000, and read by NOTHING — deleted
|
|
96
|
+
* 2026-08-19. The socket beat is `new LiveClient({ heartbeatMs })`, browser code that cannot read
|
|
97
|
+
* server config, and the presence beat is DERIVED (`PresenceRegistry.heartbeatMs` is
|
|
98
|
+
* `max(1000, floor(ttlMs / 3))`). A second knob is a second number that can disagree with the one
|
|
99
|
+
* it is a fraction of, and a knob nothing reads is a knob nothing enforces — axioms 1 and 3.
|
|
100
|
+
*/
|
|
79
101
|
export interface RealtimeConfig {
|
|
80
102
|
readonly enabled: boolean;
|
|
81
103
|
readonly tier: RealtimeTier;
|
|
82
104
|
readonly transport: RealtimeTransport;
|
|
83
105
|
readonly urlEnv: string | undefined;
|
|
84
|
-
readonly heartbeatMs: number;
|
|
85
106
|
}
|
|
86
107
|
|
|
87
108
|
export interface McpConfig {
|
|
@@ -91,7 +112,15 @@ export interface McpConfig {
|
|
|
91
112
|
|
|
92
113
|
export interface AiConfig {
|
|
93
114
|
readonly mcp: McpConfig;
|
|
94
|
-
/**
|
|
115
|
+
/**
|
|
116
|
+
* Env key for the model id, so no model string is baked into the image — **an intention, not a
|
|
117
|
+
* behaviour, `As of 2026-08`.** The only read of it in the repo is the merge two hundred lines
|
|
118
|
+
* below, which copies it from input to output; nothing consumes the merged value, so
|
|
119
|
+
* `examples/dummy`'s `modelEnv: 'ANTHROPIC_MODEL'` selects no model. `@ultimat3/ai` reads env
|
|
120
|
+
* for API KEYS only (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`); the model is
|
|
121
|
+
* `request.model ?? DEFAULT_MODEL`, a compile-time constant in `models.ts`. So the exact thing
|
|
122
|
+
* this key exists to prevent — a model string baked into the image — is what actually happens.
|
|
123
|
+
*/
|
|
95
124
|
readonly modelEnv: string | undefined;
|
|
96
125
|
}
|
|
97
126
|
|
|
@@ -210,13 +239,7 @@ function defaults(name: string): Omit<AppConfig, 'name'> {
|
|
|
210
239
|
backoff: 'exponential',
|
|
211
240
|
visibilityTimeoutMs: 30_000,
|
|
212
241
|
},
|
|
213
|
-
realtime: {
|
|
214
|
-
enabled: false,
|
|
215
|
-
tier: 'channels',
|
|
216
|
-
transport: 'memory',
|
|
217
|
-
urlEnv: undefined,
|
|
218
|
-
heartbeatMs: 15_000,
|
|
219
|
-
},
|
|
242
|
+
realtime: { enabled: false, tier: 'channels', transport: 'memory', urlEnv: undefined },
|
|
220
243
|
ai: { mcp: { expose: true, path: '/mcp' }, modelEnv: undefined },
|
|
221
244
|
};
|
|
222
245
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two decimals compared EXACTLY, however long the digits run — the ordering Postgres gives a
|
|
3
|
+
* `numeric` or an `int8`, over the TEXT those columns' row values are.
|
|
4
|
+
*
|
|
5
|
+
* Tier 0 because the values are text and the fix has to be available wherever they arrive.
|
|
6
|
+
* `@ultimat3/entity`'s `bigint()` and `decimal()` both hand digits back as a string on purpose — a
|
|
7
|
+
* JS `bigint` is what `JSON.stringify` throws on and a `number` loses digits past 2^53, exactly
|
|
8
|
+
* where a legacy `int8` key lives — so no `typeof` branch catches them: `String(left) <
|
|
9
|
+
* String(right)` answered `["10","100","2","9"]` where the database answers `["2","9","10","100"]`,
|
|
10
|
+
* and a keyset page boundary was cut where the database never cuts one.
|
|
11
|
+
*
|
|
12
|
+
* It answers `undefined` rather than guessing, and that is the whole of its contract: a caller
|
|
13
|
+
* that knows the column's declared kind (`@ultimat3/entity`'s `compareByKind`) asks; a caller that
|
|
14
|
+
* does NOT know it — `@ultimat3/query`, whose `OrderKey` is a name and a direction — must not,
|
|
15
|
+
* because Postgres orders a `text` column holding `"10"` and `"9"` lexically and a comparator
|
|
16
|
+
* guessing "both sides look like decimals" would disagree with the SQL it printed.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** A decimal, split so two of them can be compared exactly however long the digits run. */
|
|
20
|
+
interface Decimal {
|
|
21
|
+
readonly negative: boolean;
|
|
22
|
+
readonly whole: string;
|
|
23
|
+
readonly fraction: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DECIMAL_SHAPE = /^([+-]?)(\d+)(?:\.(\d*))?$/;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The digits, or `undefined` for anything that is not a plain decimal — an exponent
|
|
30
|
+
* (`String(1e21)` is `"1e+21"`), a `NaN`, an empty string. Those are not values a `numeric` column
|
|
31
|
+
* can hold, so they are not values Postgres would be ordering either.
|
|
32
|
+
*/
|
|
33
|
+
function decimalOf(value: unknown): Decimal | undefined {
|
|
34
|
+
const text =
|
|
35
|
+
typeof value === 'bigint' || typeof value === 'number'
|
|
36
|
+
? String(value)
|
|
37
|
+
: typeof value === 'string'
|
|
38
|
+
? value.trim()
|
|
39
|
+
: undefined;
|
|
40
|
+
const parts = text === undefined ? null : DECIMAL_SHAPE.exec(text);
|
|
41
|
+
const whole = parts?.[2];
|
|
42
|
+
if (parts === null || whole === undefined) return undefined;
|
|
43
|
+
return { negative: parts[1] === '-', whole, fraction: parts[3] ?? '' };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Exact at any width: the fractions are padded to one length and both sides become one integer, so
|
|
48
|
+
* a 38-digit `numeric` orders by its digits rather than by whatever a `Number` rounded it to.
|
|
49
|
+
*/
|
|
50
|
+
function compare(left: Decimal, right: Decimal): number {
|
|
51
|
+
if (left.negative !== right.negative) return left.negative ? -1 : 1;
|
|
52
|
+
const width = Math.max(left.fraction.length, right.fraction.length);
|
|
53
|
+
const scaled = (value: Decimal): bigint =>
|
|
54
|
+
BigInt(`${value.whole}${value.fraction.padEnd(width, '0')}`);
|
|
55
|
+
const first = scaled(left);
|
|
56
|
+
const second = scaled(right);
|
|
57
|
+
// Never a subtraction: the difference between two `bigint`s is exact and the return type is a
|
|
58
|
+
// `number`, which cannot hold it.
|
|
59
|
+
const order = first < second ? -1 : first > second ? 1 : 0;
|
|
60
|
+
// Guarded rather than negated: `-0` is a different value from `0` to `Object.is` and to a caller
|
|
61
|
+
// writing `=== 0`, and two equal negatives are a tie.
|
|
62
|
+
return left.negative && order !== 0 ? -order : order;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* `-1`, `0` or `1` for two plain decimals; `undefined` when EITHER side is not one.
|
|
67
|
+
*
|
|
68
|
+
* Both, or neither: one decimal against a value that is not one is not a numeric comparison, and
|
|
69
|
+
* answering for that pair would order a mixed column by a rule the database does not use.
|
|
70
|
+
*/
|
|
71
|
+
export function compareDecimalText(left: unknown, right: unknown): number | undefined {
|
|
72
|
+
const first = decimalOf(left);
|
|
73
|
+
if (first === undefined) return undefined;
|
|
74
|
+
const second = decimalOf(right);
|
|
75
|
+
return second === undefined ? undefined : compare(first, second);
|
|
76
|
+
}
|
package/src/env-example.ts
CHANGED
|
@@ -96,7 +96,15 @@ export interface EnvExampleReport {
|
|
|
96
96
|
readonly ok: boolean;
|
|
97
97
|
/** Declared in the schema, absent from the file. Always a defect. */
|
|
98
98
|
readonly missing: readonly string[];
|
|
99
|
-
/**
|
|
99
|
+
/**
|
|
100
|
+
* In the file, not in the schema — never fatal, because apps set keys nothing declares.
|
|
101
|
+
*
|
|
102
|
+
* NOT reported on its own, and the comment here said it was. `ok` is `missing.length === 0`, so
|
|
103
|
+
* an example carrying only extra keys returns `ok: true` and `assertEnvExample` never builds an
|
|
104
|
+
* error: the list reaches a surface only as `meta` on a drift some MISSING key already raised.
|
|
105
|
+
* A caller that wants it reads `checkEnvExample(...).extra` itself, which is why this stays
|
|
106
|
+
* public. `env-example.test.ts` pins both halves.
|
|
107
|
+
*/
|
|
100
108
|
readonly extra: readonly string[];
|
|
101
109
|
}
|
|
102
110
|
|
package/src/error-retry.ts
CHANGED
|
@@ -27,15 +27,29 @@ export const DEFAULT_ERROR_RETRY: ErrorRetry = 'terminal';
|
|
|
27
27
|
* app whose clients stop retrying a rolling restart, which is the one case retrying always wins.
|
|
28
28
|
* Only the exceptions are listed — everything else is `terminal` by the default above.
|
|
29
29
|
*/
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
30
|
+
// A `Map`, not a frozen object: `code` is a caller's string on every read below, and
|
|
31
|
+
// `CORE_ERROR_RETRY['constructor']` on an object literal answers the `Object` FUNCTION — which
|
|
32
|
+
// `retryFor` then returned as an `ErrorRetry`, into every `UltimateError.retry` and `toJSON()`.
|
|
33
|
+
const CORE_ERROR_RETRY: ReadonlyMap<string, ErrorRetry> = new Map(
|
|
34
|
+
Object.entries({
|
|
35
|
+
X_DRAINING: 'retryable',
|
|
36
|
+
// A deadline that expired is the canonical back-off-and-try-again case: nothing about the
|
|
37
|
+
// request was wrong, the budget ran out. Deliberately NOT `retry-after` — that spelling means
|
|
38
|
+
// the responder named a time, and a timeout by definition produced no such answer. Its twin
|
|
39
|
+
// `X_ABORTED` (the caller went away) is left to the `terminal` DEFAULT rather than listed here:
|
|
40
|
+
// the answer is the same, and listing it would close a door nobody has asked to open.
|
|
41
|
+
X_TIMEOUT: 'retryable',
|
|
42
|
+
// Listed even though `terminal` is the default, and that is the whole point: `classifyThrown`
|
|
43
|
+
// reads an UNREGISTERED code carrying `terminal` as unclassified, because a per-instance
|
|
44
|
+
// `terminal` is indistinguishable from the default and honouring it would dead-letter the
|
|
45
|
+
// first attempt of every job in every app whose codes nobody has classified. So a stub that
|
|
46
|
+
// says "this build does not have the feature" fell through to the attempt count and burned a
|
|
47
|
+
// job's whole retry policy on a fact that cannot change between attempt 1 and attempt 5.
|
|
48
|
+
// It is core's code — every `notImplemented()` stub in the framework raises it — so it is
|
|
49
|
+
// classified once here rather than by each package that happens to throw it.
|
|
50
|
+
X_NOT_IMPLEMENTED: 'terminal',
|
|
51
|
+
} as const),
|
|
52
|
+
);
|
|
39
53
|
|
|
40
54
|
const REGISTERED = new Map<string, ErrorRetry>();
|
|
41
55
|
|
|
@@ -70,7 +84,7 @@ export function registerErrorRetry(retries: Readonly<Record<string, ErrorRetry>>
|
|
|
70
84
|
if (!isErrorRetry(retry)) {
|
|
71
85
|
throw retryInvalid(code, `"${String(retry)}" is not ${ERROR_RETRY_KINDS.join(' | ')}`);
|
|
72
86
|
}
|
|
73
|
-
const core = CORE_ERROR_RETRY
|
|
87
|
+
const core = CORE_ERROR_RETRY.get(code);
|
|
74
88
|
if (core !== undefined) {
|
|
75
89
|
throw retryInvalid(code, `the framework already classifies it as ${core}`);
|
|
76
90
|
}
|
|
@@ -99,7 +113,7 @@ export function resetErrorRetry(): void {
|
|
|
99
113
|
* Core table first, for the same belt-and-braces reason `retryFor` had it first.
|
|
100
114
|
*/
|
|
101
115
|
export function declaredErrorRetry(code: string): ErrorRetry | undefined {
|
|
102
|
-
return CORE_ERROR_RETRY
|
|
116
|
+
return CORE_ERROR_RETRY.get(code) ?? REGISTERED.get(code);
|
|
103
117
|
}
|
|
104
118
|
|
|
105
119
|
export function retryFor(code: string): ErrorRetry {
|
package/src/image/errors.ts
CHANGED
|
@@ -42,7 +42,7 @@ export const imageDecodeFailed = (
|
|
|
42
42
|
): ImageDecodeFailedError =>
|
|
43
43
|
new ImageDecodeFailedError(
|
|
44
44
|
cause,
|
|
45
|
-
'
|
|
45
|
+
're-export the image from its source: `file <path>` reports what these bytes actually are',
|
|
46
46
|
meta,
|
|
47
47
|
);
|
|
48
48
|
|
package/src/index.ts
CHANGED
|
@@ -30,10 +30,9 @@ export {
|
|
|
30
30
|
withFacts,
|
|
31
31
|
} from './actor';
|
|
32
32
|
export { APP_VERSION_KEY, appVersion, DEFAULT_APP_VERSION } from './app-version';
|
|
33
|
-
export type
|
|
34
|
-
export {
|
|
35
|
-
export type
|
|
36
|
-
export { frozenClock, systemClock } from './clock';
|
|
33
|
+
export { assert, assertNever, type InvariantOptions, invariant } from './assert';
|
|
34
|
+
export { canonicalJson, fingerprint } from './canonical-json';
|
|
35
|
+
export { type Clock, type FrozenClock, frozenClock, systemClock } from './clock';
|
|
37
36
|
export type {
|
|
38
37
|
AiConfig,
|
|
39
38
|
AiConfigInput,
|
|
@@ -78,6 +77,7 @@ export {
|
|
|
78
77
|
resetCursorSigning,
|
|
79
78
|
usesDevCursorSecret,
|
|
80
79
|
} from './cursor';
|
|
80
|
+
export { compareDecimalText } from './decimal-order';
|
|
81
81
|
export type {
|
|
82
82
|
Env,
|
|
83
83
|
EnvBooleanVar,
|
|
@@ -425,8 +425,7 @@ export {
|
|
|
425
425
|
MAX_IMAGE_PIXELS,
|
|
426
426
|
rasterFrom,
|
|
427
427
|
} from './image/raster';
|
|
428
|
-
export type
|
|
429
|
-
export { fitBox, resizeRaster, scaledToFit } from './image/resize';
|
|
428
|
+
export { fitBox, type ImageFit, type ResizeSpec, resizeRaster, scaledToFit } from './image/resize';
|
|
430
429
|
export { impersonate, impersonationReason, isImpersonating } from './impersonate';
|
|
431
430
|
export { cachedFormatter, canonicalLocale, MAX_CACHED_FORMATTERS } from './intl-cache';
|
|
432
431
|
export type {
|
|
@@ -471,10 +470,8 @@ export {
|
|
|
471
470
|
markListening,
|
|
472
471
|
resetListeners,
|
|
473
472
|
} from './listeners';
|
|
474
|
-
export type
|
|
475
|
-
export {
|
|
476
|
-
export type { CappedBody } from './read-capped';
|
|
477
|
-
export { readWithinLimit } from './read-capped';
|
|
473
|
+
export { isMcpExposed, type McpExposureDeclaration } from './mcp-exposure';
|
|
474
|
+
export { type CappedBody, readWithinLimit } from './read-capped';
|
|
478
475
|
export type { ModuleRegistrar, PrimitiveKind, RegisteredPrimitive } from './registrar';
|
|
479
476
|
export {
|
|
480
477
|
hasPrimitiveRegistrar,
|
|
@@ -488,8 +485,7 @@ export { err, isErr, isOk, map, mapErr, ok, tryCatch, unwrap, unwrapOr } from '.
|
|
|
488
485
|
export type { ResolveRoleOptions, Role, RoleInfo, ScalingSignal } from './roles';
|
|
489
486
|
export { DEFAULT_ROLE, isRole, ROLE_INFO, ROLES, resolveRole } from './roles';
|
|
490
487
|
export { safeUrl, URL_ATTRIBUTES } from './safe-url';
|
|
491
|
-
export type
|
|
492
|
-
export { defineService, resetServices } from './service';
|
|
488
|
+
export { defineService, resetServices, type ServiceFactory } from './service';
|
|
493
489
|
export { timingSafeEqual } from './timing-safe-equal';
|
|
494
490
|
export {
|
|
495
491
|
frameworkVersion,
|
package/src/logger.ts
CHANGED
|
@@ -10,6 +10,9 @@ import { isSecret, REDACTED } from './secret';
|
|
|
10
10
|
// it without importing the logger, and two constants spelled the same is one rename from a leak.
|
|
11
11
|
export { REDACTED } from './secret';
|
|
12
12
|
|
|
13
|
+
/** What a `Date` this file cannot render says instead — the line survives, the value is named. */
|
|
14
|
+
const INVALID_DATE = 'an invalid Date';
|
|
15
|
+
|
|
13
16
|
export const LOG_LEVELS = ['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'silent'] as const;
|
|
14
17
|
|
|
15
18
|
export type LogLevel = (typeof LOG_LEVELS)[number];
|
|
@@ -133,7 +136,7 @@ function serialise(value: unknown, depth: number): unknown {
|
|
|
133
136
|
// `toISOString()` THROWS on an invalid Date, and an invalid Date is exactly the value worth
|
|
134
137
|
// logging when a schedule went wrong.
|
|
135
138
|
if (value instanceof Date) {
|
|
136
|
-
return Number.isNaN(value.getTime()) ?
|
|
139
|
+
return Number.isNaN(value.getTime()) ? INVALID_DATE : value.toISOString();
|
|
137
140
|
}
|
|
138
141
|
if (isUltimateError(value)) return value.toJSON();
|
|
139
142
|
if (value instanceof Error) return { name: value.name, message: value.message };
|
|
@@ -198,6 +201,22 @@ function renderLine(
|
|
|
198
201
|
}
|
|
199
202
|
}
|
|
200
203
|
|
|
204
|
+
/**
|
|
205
|
+
* The one value in a line that is not the caller's, and it was the one read left unguarded:
|
|
206
|
+
* `toISOString()` raises `RangeError` on an invalid `Date`, and a `Clock` is injected — a frozen
|
|
207
|
+
* clock set from a bad string, or a clock whose `now()` throws, took the whole line with it. The
|
|
208
|
+
* same marker `serialise` gives an invalid `Date` in a FIELD, so one vocabulary covers both.
|
|
209
|
+
*/
|
|
210
|
+
function timestamp(clock: Clock): string {
|
|
211
|
+
try {
|
|
212
|
+
const at = clock.now();
|
|
213
|
+
if (at instanceof Date && !Number.isNaN(at.getTime())) return at.toISOString();
|
|
214
|
+
} catch {
|
|
215
|
+
// A clock that fights being read is exactly the moment a line is worth keeping.
|
|
216
|
+
}
|
|
217
|
+
return INVALID_DATE;
|
|
218
|
+
}
|
|
219
|
+
|
|
201
220
|
function envLevel(): LogLevel {
|
|
202
221
|
const raw = process.env['LOG_LEVEL'];
|
|
203
222
|
return raw !== undefined && (LOG_LEVELS as readonly string[]).includes(raw)
|
|
@@ -215,7 +234,7 @@ export function createLogger(options?: LoggerOptions): Logger {
|
|
|
215
234
|
function emit(lineLevel: LogLevel, message: string, fields?: LogFields): void {
|
|
216
235
|
if (LEVEL_WEIGHT[lineLevel] < threshold) return;
|
|
217
236
|
const line = {
|
|
218
|
-
ts: clock
|
|
237
|
+
ts: timestamp(clock),
|
|
219
238
|
level: lineLevel,
|
|
220
239
|
msg: message,
|
|
221
240
|
...redactFields(bound),
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Single responsibility: the OpenTelemetry-shaped metric DATA MODEL — what a point, a
|
|
2
|
+
// descriptor, a collection and an instrument's options are. No registry and no state: `metrics.ts`
|
|
3
|
+
// owns those, and a reader (`metrics-text.ts`, an exporter) needs the shapes without them.
|
|
4
|
+
|
|
5
|
+
import type { SpanResource } from './telemetry';
|
|
6
|
+
|
|
7
|
+
export type MetricKind = 'counter' | 'gauge' | 'histogram';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Narrower than a span attribute on purpose: metric attributes become time-series labels, every
|
|
11
|
+
* distinct combination is a stored series, and an array label has no meaning in any exposition
|
|
12
|
+
* format. Keep the cardinality low — a user id here is an outage, and `maxSeries` is the ceiling
|
|
13
|
+
* that makes "keep it low" a mechanism instead of this sentence.
|
|
14
|
+
*/
|
|
15
|
+
export type MetricAttributeValue = string | number | boolean;
|
|
16
|
+
|
|
17
|
+
export interface MetricAttributes {
|
|
18
|
+
readonly [key: string]: MetricAttributeValue;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface MetricDescriptor {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
readonly kind: MetricKind;
|
|
24
|
+
/** UCUM, as OTel spells it: `1`, `s`, `By`, `{request}`. */
|
|
25
|
+
readonly unit: string;
|
|
26
|
+
readonly description: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface MetricPoint {
|
|
30
|
+
readonly attributes: MetricAttributes;
|
|
31
|
+
/** Counter: cumulative sum since process start. Gauge: last value. Histogram: sum. */
|
|
32
|
+
readonly value: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface HistogramPoint extends MetricPoint {
|
|
36
|
+
readonly count: number;
|
|
37
|
+
readonly min: number;
|
|
38
|
+
readonly max: number;
|
|
39
|
+
/** Explicit upper bounds; `buckets` is one longer, the last being the `+Inf` overflow. */
|
|
40
|
+
readonly bounds: readonly number[];
|
|
41
|
+
readonly buckets: readonly number[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ReadableMetric {
|
|
45
|
+
readonly descriptor: MetricDescriptor;
|
|
46
|
+
readonly points: readonly MetricPoint[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface MetricCollection {
|
|
50
|
+
/** Epoch milliseconds, from the configured clock. */
|
|
51
|
+
readonly at: number;
|
|
52
|
+
readonly resource: SpanResource;
|
|
53
|
+
readonly metrics: readonly ReadableMetric[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The driver seam. OTLP, Prometheus remote-write or a vendor SDK all arrive as one of these. */
|
|
57
|
+
export interface MetricExporter {
|
|
58
|
+
export(collection: MetricCollection): void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface Counter {
|
|
62
|
+
add(value?: number, attributes?: MetricAttributes): void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface Gauge {
|
|
66
|
+
/** Set the current value. */
|
|
67
|
+
record(value: number, attributes?: MetricAttributes): void;
|
|
68
|
+
/** Move the current value — `+1` on connect, `-1` on disconnect. */
|
|
69
|
+
add(delta: number, attributes?: MetricAttributes): void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface Histogram {
|
|
73
|
+
record(value: number, attributes?: MetricAttributes): void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface InstrumentOptions {
|
|
77
|
+
readonly unit?: string | undefined;
|
|
78
|
+
readonly description?: string | undefined;
|
|
79
|
+
/**
|
|
80
|
+
* Distinct label sets this instrument may store. Past it every new set folds into one overflow
|
|
81
|
+
* series. Defaults to `DEFAULT_MAX_SERIES`; the first declaration of a name wins.
|
|
82
|
+
*/
|
|
83
|
+
readonly maxSeries?: number | undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface GaugeOptions extends InstrumentOptions {
|
|
87
|
+
/**
|
|
88
|
+
* Async instrument: read at collection time instead of being pushed. Never stale.
|
|
89
|
+
* Stated twice for one name with two different callbacks is `X_METRIC_NAME_INVALID`, not a
|
|
90
|
+
* silent win for the first — see `assertSameDeclaration`.
|
|
91
|
+
*/
|
|
92
|
+
readonly observe?: (() => number) | undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface HistogramOptions extends InstrumentOptions {
|
|
96
|
+
/**
|
|
97
|
+
* Explicit bucket boundaries, ascending. Defaults to the OTel latency-in-seconds set.
|
|
98
|
+
* Stated twice for one name with two different sets is `X_METRIC_NAME_INVALID`, not a silent
|
|
99
|
+
* win for the first — see `assertSameDeclaration`.
|
|
100
|
+
*/
|
|
101
|
+
readonly bounds?: readonly number[] | undefined;
|
|
102
|
+
}
|
package/src/metrics.ts
CHANGED
|
@@ -3,9 +3,44 @@
|
|
|
3
3
|
// always on, a no-op exporter by default, and the wire format supplied by a driver, never here.
|
|
4
4
|
|
|
5
5
|
import { type Clock, systemClock } from './clock';
|
|
6
|
+
import { renderThrowable } from './error-render';
|
|
6
7
|
import { type CodedErrorInit, UltimateError } from './errors';
|
|
7
8
|
import { logger } from './logger';
|
|
8
|
-
import
|
|
9
|
+
import type {
|
|
10
|
+
Counter,
|
|
11
|
+
Gauge,
|
|
12
|
+
GaugeOptions,
|
|
13
|
+
Histogram,
|
|
14
|
+
HistogramOptions,
|
|
15
|
+
InstrumentOptions,
|
|
16
|
+
MetricAttributes,
|
|
17
|
+
MetricCollection,
|
|
18
|
+
MetricDescriptor,
|
|
19
|
+
MetricExporter,
|
|
20
|
+
MetricKind,
|
|
21
|
+
MetricPoint,
|
|
22
|
+
} from './metrics-types';
|
|
23
|
+
import { serviceResource } from './telemetry';
|
|
24
|
+
|
|
25
|
+
// The data model is a module of its own; the public surface is unchanged, so nothing that imports
|
|
26
|
+
// a metric type from here has to learn a second path.
|
|
27
|
+
export type {
|
|
28
|
+
Counter,
|
|
29
|
+
Gauge,
|
|
30
|
+
GaugeOptions,
|
|
31
|
+
Histogram,
|
|
32
|
+
HistogramOptions,
|
|
33
|
+
HistogramPoint,
|
|
34
|
+
InstrumentOptions,
|
|
35
|
+
MetricAttributes,
|
|
36
|
+
MetricAttributeValue,
|
|
37
|
+
MetricCollection,
|
|
38
|
+
MetricDescriptor,
|
|
39
|
+
MetricExporter,
|
|
40
|
+
MetricKind,
|
|
41
|
+
MetricPoint,
|
|
42
|
+
ReadableMetric,
|
|
43
|
+
} from './metrics-types';
|
|
9
44
|
|
|
10
45
|
export class MetricNameInvalidError extends UltimateError {
|
|
11
46
|
static readonly code = 'X_METRIC_NAME_INVALID';
|
|
@@ -31,103 +66,6 @@ export class MetricCardinalityError extends UltimateError {
|
|
|
31
66
|
}
|
|
32
67
|
}
|
|
33
68
|
|
|
34
|
-
export type MetricKind = 'counter' | 'gauge' | 'histogram';
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Narrower than a span attribute on purpose: metric attributes become time-series labels, every
|
|
38
|
-
* distinct combination is a stored series, and an array label has no meaning in any exposition
|
|
39
|
-
* format. Keep the cardinality low — a user id here is an outage, and `maxSeries` is the ceiling
|
|
40
|
-
* that makes "keep it low" a mechanism instead of this sentence.
|
|
41
|
-
*/
|
|
42
|
-
export type MetricAttributeValue = string | number | boolean;
|
|
43
|
-
|
|
44
|
-
export interface MetricAttributes {
|
|
45
|
-
readonly [key: string]: MetricAttributeValue;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export interface MetricDescriptor {
|
|
49
|
-
readonly name: string;
|
|
50
|
-
readonly kind: MetricKind;
|
|
51
|
-
/** UCUM, as OTel spells it: `1`, `s`, `By`, `{request}`. */
|
|
52
|
-
readonly unit: string;
|
|
53
|
-
readonly description: string;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export interface MetricPoint {
|
|
57
|
-
readonly attributes: MetricAttributes;
|
|
58
|
-
/** Counter: cumulative sum since process start. Gauge: last value. Histogram: sum. */
|
|
59
|
-
readonly value: number;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export interface HistogramPoint extends MetricPoint {
|
|
63
|
-
readonly count: number;
|
|
64
|
-
readonly min: number;
|
|
65
|
-
readonly max: number;
|
|
66
|
-
/** Explicit upper bounds; `buckets` is one longer, the last being the `+Inf` overflow. */
|
|
67
|
-
readonly bounds: readonly number[];
|
|
68
|
-
readonly buckets: readonly number[];
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export interface ReadableMetric {
|
|
72
|
-
readonly descriptor: MetricDescriptor;
|
|
73
|
-
readonly points: readonly MetricPoint[];
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export interface MetricCollection {
|
|
77
|
-
/** Epoch milliseconds, from the configured clock. */
|
|
78
|
-
readonly at: number;
|
|
79
|
-
readonly resource: SpanResource;
|
|
80
|
-
readonly metrics: readonly ReadableMetric[];
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** The driver seam. OTLP, Prometheus remote-write or a vendor SDK all arrive as one of these. */
|
|
84
|
-
export interface MetricExporter {
|
|
85
|
-
export(collection: MetricCollection): void;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export interface Counter {
|
|
89
|
-
add(value?: number, attributes?: MetricAttributes): void;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export interface Gauge {
|
|
93
|
-
/** Set the current value. */
|
|
94
|
-
record(value: number, attributes?: MetricAttributes): void;
|
|
95
|
-
/** Move the current value — `+1` on connect, `-1` on disconnect. */
|
|
96
|
-
add(delta: number, attributes?: MetricAttributes): void;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export interface Histogram {
|
|
100
|
-
record(value: number, attributes?: MetricAttributes): void;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export interface InstrumentOptions {
|
|
104
|
-
readonly unit?: string | undefined;
|
|
105
|
-
readonly description?: string | undefined;
|
|
106
|
-
/**
|
|
107
|
-
* Distinct label sets this instrument may store. Past it every new set folds into one overflow
|
|
108
|
-
* series. Defaults to `DEFAULT_MAX_SERIES`; the first declaration of a name wins.
|
|
109
|
-
*/
|
|
110
|
-
readonly maxSeries?: number | undefined;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export interface GaugeOptions extends InstrumentOptions {
|
|
114
|
-
/**
|
|
115
|
-
* Async instrument: read at collection time instead of being pushed. Never stale.
|
|
116
|
-
* Stated twice for one name with two different callbacks is `X_METRIC_NAME_INVALID`, not a
|
|
117
|
-
* silent win for the first — see `assertSameDeclaration`.
|
|
118
|
-
*/
|
|
119
|
-
readonly observe?: (() => number) | undefined;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export interface HistogramOptions extends InstrumentOptions {
|
|
123
|
-
/**
|
|
124
|
-
* Explicit bucket boundaries, ascending. Defaults to the OTel latency-in-seconds set.
|
|
125
|
-
* Stated twice for one name with two different sets is `X_METRIC_NAME_INVALID`, not a silent
|
|
126
|
-
* win for the first — see `assertSameDeclaration`.
|
|
127
|
-
*/
|
|
128
|
-
readonly bounds?: readonly number[] | undefined;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
69
|
/**
|
|
132
70
|
* The per-instrument series ceiling. 2000 is roomy for a bounded label set — every route pattern
|
|
133
71
|
* times every status class times every method — and small enough that the process notices an
|
|
@@ -204,6 +142,8 @@ interface Instrument {
|
|
|
204
142
|
readonly maxSeries: number;
|
|
205
143
|
/** Reported once. A cardinality blow-up is one bug, not one log line per call. */
|
|
206
144
|
overflowed: boolean;
|
|
145
|
+
/** Reported once, for the same reason: a scrape every 15s must not become a log every 15s. */
|
|
146
|
+
observeFailed: boolean;
|
|
207
147
|
}
|
|
208
148
|
|
|
209
149
|
const instruments = new Map<string, Instrument>();
|
|
@@ -230,6 +170,7 @@ export function resetMetrics(): void {
|
|
|
230
170
|
for (const instrument of instruments.values()) {
|
|
231
171
|
instrument.series.clear();
|
|
232
172
|
instrument.overflowed = false;
|
|
173
|
+
instrument.observeFailed = false;
|
|
233
174
|
}
|
|
234
175
|
}
|
|
235
176
|
|
|
@@ -294,6 +235,7 @@ function declare(name: string, kind: MetricKind, options: GaugeOptions & Histogr
|
|
|
294
235
|
observe: options.observe,
|
|
295
236
|
maxSeries,
|
|
296
237
|
overflowed: false,
|
|
238
|
+
observeFailed: false,
|
|
297
239
|
};
|
|
298
240
|
instruments.set(name, instrument);
|
|
299
241
|
return instrument;
|
|
@@ -350,6 +292,26 @@ function reportOverflow(instrument: Instrument): void {
|
|
|
350
292
|
logger.error(error.format(), { code: error.code, metric: name });
|
|
351
293
|
}
|
|
352
294
|
|
|
295
|
+
/**
|
|
296
|
+
* Reported through the logger for `reportOverflow`'s reason and once for the same one — a scrape
|
|
297
|
+
* runs on a timer, so a permanently broken observer would otherwise write a log line every
|
|
298
|
+
* interval forever. A recurrence after the first is therefore silent by design; the missing series
|
|
299
|
+
* is the signal that outlives the line.
|
|
300
|
+
*/
|
|
301
|
+
function reportObserveFailure(instrument: Instrument, thrown: unknown): void {
|
|
302
|
+
if (instrument.observeFailed) return;
|
|
303
|
+
instrument.observeFailed = true;
|
|
304
|
+
const { name, kind } = instrument.descriptor;
|
|
305
|
+
const error = new MetricValueInvalidError({
|
|
306
|
+
// `renderThrowable`, never `${thrown}`: the value is whatever the app's callback threw, and a
|
|
307
|
+
// `.message` read on it is the one that throws where there is nothing left to answer with.
|
|
308
|
+
cause: `the observe() callback of ${name} did not produce a value: ${renderThrowable(thrown)}; this instrument contributes no point until it does`,
|
|
309
|
+
fix: `make the observe() callback of ${name} total — return a finite number when the resource it reads is gone, e.g. ${kind}('${name}', { observe: () => pool?.size ?? 0 })`,
|
|
310
|
+
meta: { metric: name },
|
|
311
|
+
});
|
|
312
|
+
logger.error(error.format(), { code: error.code, metric: name });
|
|
313
|
+
}
|
|
314
|
+
|
|
353
315
|
function createSeries(instrument: Instrument, key: string, attributes: MetricAttributes): Series {
|
|
354
316
|
const created: Series = {
|
|
355
317
|
attributes,
|
|
@@ -433,8 +395,19 @@ export function histogram(name: string, options?: HistogramOptions): Histogram {
|
|
|
433
395
|
}
|
|
434
396
|
|
|
435
397
|
function pointsOf(instrument: Instrument): readonly MetricPoint[] {
|
|
436
|
-
|
|
437
|
-
|
|
398
|
+
const observe = instrument.observe;
|
|
399
|
+
if (observe !== undefined) {
|
|
400
|
+
try {
|
|
401
|
+
return [{ attributes: {}, value: finite(instrument.descriptor.name, observe()) }];
|
|
402
|
+
} catch (thrown) {
|
|
403
|
+
// The callback is the app's, run at SCRAPE time with no call site to blame: `() => pool.size`
|
|
404
|
+
// after a drain throws, and an unguarded read here took every other instrument down with it
|
|
405
|
+
// — /metrics 500s, `http_requests_total` goes invisible, and `startMetricExport`'s timer
|
|
406
|
+
// callback raises where nothing can catch it. One hostile observer costs its own point only,
|
|
407
|
+
// the same degradation `readinessChecks()` and the logger's per-key walk already make.
|
|
408
|
+
reportObserveFailure(instrument, thrown);
|
|
409
|
+
return [];
|
|
410
|
+
}
|
|
438
411
|
}
|
|
439
412
|
return [...instrument.series.values()].map((series) =>
|
|
440
413
|
instrument.descriptor.kind === 'histogram'
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Single responsibility: a `MetricExporter` that POSTs OTLP/HTTP JSON to a collector. No batching
|
|
2
2
|
// — `collectMetrics()` already produces one whole snapshot per tick, so a tick is a request.
|
|
3
3
|
|
|
4
|
+
import { renderThrowable } from './error-render';
|
|
5
|
+
import { logger } from './logger';
|
|
4
6
|
import type {
|
|
5
7
|
HistogramPoint,
|
|
6
8
|
MetricCollection,
|
|
@@ -124,10 +126,31 @@ export function otlpMetricExporter(options: OtlpMetricExporterOptions = {}): Otl
|
|
|
124
126
|
return {
|
|
125
127
|
export(collection: MetricCollection): void {
|
|
126
128
|
startedAtMs ??= collection.at;
|
|
127
|
-
|
|
129
|
+
let body: string;
|
|
130
|
+
try {
|
|
131
|
+
// `export` is called from a timer, not awaited by anyone, so this throw had nowhere to go
|
|
132
|
+
// but into the metric loop that called it: `MetricAttributeValue` is a compile-time claim,
|
|
133
|
+
// and an attribute the app spelled as an object or a bigint reaches `otlpAttributes` as a
|
|
134
|
+
// TypeError. Dropped with a line, the same degradation `postOtlp` already applies to a
|
|
135
|
+
// collector that is down — telemetry is best-effort and must never end the process.
|
|
136
|
+
body = JSON.stringify(otlpMetricsRequest(collection, startedAtMs));
|
|
137
|
+
} catch (failure) {
|
|
138
|
+
logger.warn('otlp metric snapshot dropped', {
|
|
139
|
+
url,
|
|
140
|
+
metrics: collection.metrics.length,
|
|
141
|
+
error: renderThrowable(failure),
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
128
145
|
// Chained, so a slow collector cannot make two snapshots arrive out of order and turn a
|
|
129
|
-
// cumulative counter into an apparent reset.
|
|
130
|
-
|
|
146
|
+
// cumulative counter into an apparent reset. Chained on a SETTLED shadow, for the reason
|
|
147
|
+
// `otlp-span-exporter.ts` spells out: a chain that carries a rejection forward stops calling
|
|
148
|
+
// `postOtlp` for the life of the process, in silence.
|
|
149
|
+
const settled = inflight.then(
|
|
150
|
+
() => undefined,
|
|
151
|
+
() => undefined,
|
|
152
|
+
);
|
|
153
|
+
inflight = settled.then(() => postOtlp({ url, headers, body, timeoutMs, fetch: send }));
|
|
131
154
|
},
|
|
132
155
|
flush(): Promise<void> {
|
|
133
156
|
return inflight;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Single responsibility: a `SpanExporter` that POSTs OTLP/HTTP JSON to a collector. Batched,
|
|
2
2
|
// because `SpanExporter.export` is one span and a request per span is a second load generator.
|
|
3
3
|
|
|
4
|
+
import { renderThrowable } from './error-render';
|
|
5
|
+
import { logger } from './logger';
|
|
4
6
|
import {
|
|
5
7
|
OTLP_SCOPE,
|
|
6
8
|
type OtlpKeyValue,
|
|
@@ -134,15 +136,38 @@ export function otlpSpanExporter(options: OtlpSpanExporterOptions = {}): OtlpSpa
|
|
|
134
136
|
const post = (batch: readonly ReadableSpan[]): Promise<void> => {
|
|
135
137
|
const first = batch[0];
|
|
136
138
|
if (first === undefined) return Promise.resolve();
|
|
137
|
-
|
|
139
|
+
let body: string;
|
|
140
|
+
try {
|
|
141
|
+
// The one synchronous throw on this path, and the only way `inflight` can reject at all:
|
|
142
|
+
// `AttributeValue` is a compile-time claim, so an attribute the app spelled as an object, a
|
|
143
|
+
// bigint or a cycle reaches `anyValue`'s `value.map(...)` as a TypeError. Dropped with a
|
|
144
|
+
// line, the same degradation `postOtlp` already applies to a collector that is down —
|
|
145
|
+
// telemetry is best-effort and must never become the process's exit code.
|
|
146
|
+
body = JSON.stringify(otlpTraceRequest(batch, first.resource));
|
|
147
|
+
} catch (failure) {
|
|
148
|
+
logger.warn('otlp span batch dropped', {
|
|
149
|
+
url,
|
|
150
|
+
spans: batch.length,
|
|
151
|
+
error: renderThrowable(failure),
|
|
152
|
+
});
|
|
153
|
+
return Promise.resolve();
|
|
154
|
+
}
|
|
138
155
|
return postOtlp({ url, headers, body, timeoutMs, fetch: send });
|
|
139
156
|
};
|
|
140
157
|
|
|
141
158
|
const drainQueue = (): Promise<void> => {
|
|
142
159
|
const batch = queue.splice(0, queue.length);
|
|
143
160
|
// 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
|
-
|
|
161
|
+
// span arriving after its child into a broken trace on the read side. Chained on a SETTLED
|
|
162
|
+
// shadow, because a chain that carries a rejection forward is poisoned for the life of the
|
|
163
|
+
// process: `post` is never called again while the queue keeps emptying, so every later span is
|
|
164
|
+
// dropped in silence and every timer tick mints a fresh unhandled rejection — which Bun ends
|
|
165
|
+
// the process on. Same shape as `offline-queue.ts`'s drain chain.
|
|
166
|
+
const settled = inflight.then(
|
|
167
|
+
() => undefined,
|
|
168
|
+
() => undefined,
|
|
169
|
+
);
|
|
170
|
+
inflight = settled.then(() => post(batch));
|
|
146
171
|
return inflight;
|
|
147
172
|
};
|
|
148
173
|
|
package/src/type-pins.ts
CHANGED
|
@@ -52,9 +52,15 @@ type _ActorFactMapAcceptsNothing = Assert<
|
|
|
52
52
|
>;
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
|
-
* The seam is additive: an actor literal
|
|
56
|
-
* optional for that reason and not only for the denial rule —
|
|
57
|
-
* a breaking change to a tier-0 type every package depends on.
|
|
55
|
+
* The seam is additive: an actor literal carrying the required members but no `facts` still is an
|
|
56
|
+
* `Actor`. `facts` is optional for that reason and not only for the denial rule — making it
|
|
57
|
+
* required would be a breaking change to a tier-0 type every package depends on.
|
|
58
|
+
*
|
|
59
|
+
* `permissions` joined the required set in 4.0.0 and is spelled here deliberately. That WAS such a
|
|
60
|
+
* breaking change, made knowingly and with a migration: policy's `PolicyActorFields` held it, so
|
|
61
|
+
* `userActor({ permissions })` silently dropped it and no builder could spell a direct grant. This
|
|
62
|
+
* pin is what makes the next one impossible to add by accident — a new required member fails here
|
|
63
|
+
* before it fails in an app.
|
|
58
64
|
*/
|
|
59
65
|
type _ActorWithoutFactsIsStillAnActor = Assert<
|
|
60
66
|
[
|
|
@@ -63,6 +69,7 @@ type _ActorWithoutFactsIsStillAnActor = Assert<
|
|
|
63
69
|
readonly id: string;
|
|
64
70
|
readonly roles: readonly string[];
|
|
65
71
|
readonly scopes: readonly string[];
|
|
72
|
+
readonly permissions: readonly string[];
|
|
66
73
|
},
|
|
67
74
|
] extends [Actor]
|
|
68
75
|
? true
|