@ultimat3/core 2.0.0 → 3.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 +20 -0
- package/README.md +38 -0
- package/package.json +1 -1
- package/src/error-codes.ts +2 -1
- package/src/error-retry.ts +16 -4
- package/src/exports/error-contract.ts +1 -0
- package/src/index.ts +2 -0
- package/src/intl-cache.ts +43 -0
- package/src/lifecycle.ts +65 -24
- package/src/metrics.ts +44 -2
package/CLAUDE.md
CHANGED
|
@@ -62,6 +62,7 @@ shape against a locally declared sample interface for exactly that reason.
|
|
|
62
62
|
| `.env.example` | `env-example.ts` | a projection of the schema, never hand-maintained |
|
|
63
63
|
| loading `.env` | **Bun**, not us | `envFileCandidates()` documents the measured order; there is no `.env.staging` |
|
|
64
64
|
| a value that must not be printed | `secret.ts` | redacted by VALUE; `revealSecret()` is the one way out, on purpose greppable |
|
|
65
|
+
| an `Intl` formatter cache | `intl-cache.ts` (`cachedFormatter`, `canonicalLocale`, `MAX_CACHED_FORMATTERS`) | a locale and a zone arrive from a request header, so the key must be canonical AND the cache bounded — never a second copy of either half |
|
|
65
66
|
| the committed encrypted values | `secrets.ts` (envelope) + `secrets-store.ts` (files, `installSecrets`) | plaintext is a flat map of ENV NAMES; there is no `secrets.get()` |
|
|
66
67
|
|
|
67
68
|
`installSecrets()` is the ONLY path from `secrets.enc.json` to an app value, and it lands in
|
|
@@ -70,6 +71,16 @@ shape against a locally declared sample interface for exactly that reason.
|
|
|
70
71
|
implementations. The real environment always wins, which is what lets one image run in Compose and
|
|
71
72
|
on K8s off one committed file.
|
|
72
73
|
|
|
74
|
+
`intl-cache.ts` is tier 0 because two tier-1 packages need it and tier 1 may not import sideways.
|
|
75
|
+
It was `@ultimat3/time`'s, internal, until 2.0.0, when `@ultimat3/money`'s `formatMoney` was found
|
|
76
|
+
keyed raw on the caller's locale into an unbounded `Map` — 20,000 valid `en-US-x-*` tags from one
|
|
77
|
+
`Accept-Language` header retained +55.1 MB of RSS (measured `As of 2026-08`). Copying the FIFO into
|
|
78
|
+
`money` would have been a second answer to one question (axiom 1); `money → time` is a sideways
|
|
79
|
+
import `bun run boundaries` refuses. The bound and the canonical key are **two halves of one rule**
|
|
80
|
+
and live in one file for that reason: a canonical key bounds nothing (an unknown `-u-` extension
|
|
81
|
+
value survives canonicalization as a distinct string) and the cap alone lets one locale evict
|
|
82
|
+
itself under three spellings. Never build an `Intl` formatter on a caller string without both.
|
|
83
|
+
|
|
73
84
|
`secrets-errors.ts` registers its seven codes through `registerErrorCodes()` rather than joining
|
|
74
85
|
`CORE_CODE_TITLES` — the codes and the module that throws them ship together, and `registerErrorCodes`
|
|
75
86
|
is the one mechanism that raises `X_ERROR_CODE_DUPLICATE` if anything else claims one. Consequence
|
|
@@ -214,6 +225,15 @@ Every `UltimateError` carries `retry` (`terminal | retryable | retry-after`), **
|
|
|
214
225
|
registration path and it refuses to reclassify a core code, the same way `registerErrorStatus`
|
|
215
226
|
refuses to remap one. A new code in any package should be classified beside its declaration.
|
|
216
227
|
|
|
228
|
+
Two readers of that table, and picking the wrong one is a live defect. `retryFor(code)` answers
|
|
229
|
+
*what to do* and fails closed; `declaredErrorRetry(code)` answers *what somebody declared* and is
|
|
230
|
+
`undefined` when nobody did. `retry` on the instance is `init.retry ?? retryFor(code)`, so every
|
|
231
|
+
unclassified error already reads `terminal` — a caller deciding whether to STOP work in flight
|
|
232
|
+
(`@ultimat3/jobs`' executor) must read `declaredErrorRetry`, or it dead-letters attempt 1 of every
|
|
233
|
+
job in every app whose codes nobody classified. An instance `retry: 'terminal'` on an **unregistered**
|
|
234
|
+
code is indistinguishable from the default and is therefore read as unclassified; registering the
|
|
235
|
+
code is the one way to have it honoured.
|
|
236
|
+
|
|
217
237
|
Gotchas:
|
|
218
238
|
- `exactOptionalPropertyTypes` is on — declare optional fields as `x?: T | undefined`.
|
|
219
239
|
- `noPropertyAccessFromIndexSignature` is on — `ctx.services['mail']`, not `.mail`.
|
package/README.md
CHANGED
|
@@ -73,6 +73,16 @@ registerErrorRetry({ X_OAUTH_EXCHANGE_FAILED: 'retryable', X_RATE_LIMITED: 'retr
|
|
|
73
73
|
Core's own classifications are closed, exactly as `registerErrorStatus`'s framework table is: a
|
|
74
74
|
second, different registration for one code throws `X_ERROR_RETRY_INVALID`.
|
|
75
75
|
|
|
76
|
+
**Two readers, and the difference is load-bearing.** `retryFor(code)` answers *what to do* and
|
|
77
|
+
fails closed — `terminal` for a code nobody classified. `declaredErrorRetry(code)` answers *what
|
|
78
|
+
somebody actually declared*, and is `undefined` when nobody did. `UltimateError.retry` is
|
|
79
|
+
`init.retry ?? retryFor(code)`, so every unclassified error already carries `terminal`: a caller
|
|
80
|
+
deciding whether to stop work already in flight reads `declaredErrorRetry`, and the job executor
|
|
81
|
+
that read `retryFor` instead would dead-letter attempt 1 of every job in every app whose codes
|
|
82
|
+
nobody has classified. An instance-level `retry: 'terminal'` on an **unregistered** code is
|
|
83
|
+
indistinguishable from that default and is read as unclassified — register the code
|
|
84
|
+
(`registerErrorRetry({ X_YOUR_CODE: 'terminal' })`), which is the one way.
|
|
85
|
+
|
|
76
86
|
| Code | Subclass |
|
|
77
87
|
|---|---|
|
|
78
88
|
| `X_CONFIG_INVALID` | `ConfigInvalidError` |
|
|
@@ -423,6 +433,34 @@ never a silently wrong page.
|
|
|
423
433
|
| `usesDevCursorSecret()` | true while the shipped dev key is in use |
|
|
424
434
|
| `resetCursorSigning()` | test seam: forget `configureCursorSigning` and fall back to the environment |
|
|
425
435
|
|
|
436
|
+
## One bounded cache for every `Intl` formatter
|
|
437
|
+
|
|
438
|
+
```ts
|
|
439
|
+
import { cachedFormatter, canonicalLocale } from '@ultimat3/core';
|
|
440
|
+
|
|
441
|
+
const cache = new Map<string, Intl.NumberFormat>();
|
|
442
|
+
|
|
443
|
+
export function euroFormatter(locale: string): Intl.NumberFormat {
|
|
444
|
+
// `EN-us` and `en-latn-us` collapse to one key, so one locale cannot evict itself.
|
|
445
|
+
const tag = canonicalLocale(locale) ?? locale;
|
|
446
|
+
return cachedFormatter(
|
|
447
|
+
cache,
|
|
448
|
+
`${tag}|EUR`,
|
|
449
|
+
() => new Intl.NumberFormat(tag, { style: 'currency', currency: 'EUR' }),
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
A locale arrives from `Accept-Language` and a zone from `x-timezone`, so an unbounded `Map` keyed
|
|
455
|
+
on that string is **memory the client chooses**. Measured `As of 2026-08`: 4,096 casings of one
|
|
456
|
+
zone name retained 31 MB, and 20,000 valid `en-US-x-*` tags through `formatMoney` retained 55.1 MB.
|
|
457
|
+
The bound
|
|
458
|
+
(`MAX_CACHED_FORMATTERS`, 512, FIFO) and the canonical key are two halves of one rule and neither
|
|
459
|
+
is sufficient alone — an unknown `-u-` extension value survives canonicalization as a distinct
|
|
460
|
+
string, and the cap alone lets one locale evict itself under three spellings. A miss costs one
|
|
461
|
+
`Intl` construction, never a wrong answer, which is what makes the bound safe. It lives here rather
|
|
462
|
+
than in `@ultimat3/time` because `@ultimat3/money` needs it too and tier 1 may not import sideways.
|
|
463
|
+
|
|
426
464
|
## One image pipeline, everywhere
|
|
427
465
|
|
|
428
466
|
```ts
|
package/package.json
CHANGED
package/src/error-codes.ts
CHANGED
|
@@ -46,7 +46,8 @@ const CORE_CODE_TITLES = {
|
|
|
46
46
|
X_INVARIANT: 'invariant violated',
|
|
47
47
|
X_METRIC_CARDINALITY:
|
|
48
48
|
'a metric exceeded its series ceiling and is folding into one overflow series',
|
|
49
|
-
X_METRIC_NAME_INVALID:
|
|
49
|
+
X_METRIC_NAME_INVALID:
|
|
50
|
+
'metric name is malformed, or redeclared with a different kind, bounds or observer',
|
|
50
51
|
X_METRIC_VALUE_INVALID: 'metric value is not recordable',
|
|
51
52
|
X_NO_CONTEXT: 'no request context is active',
|
|
52
53
|
X_NOT_IMPLEMENTED: 'this driver does not implement the requested feature',
|
package/src/error-retry.ts
CHANGED
|
@@ -87,11 +87,23 @@ export function resetErrorRetry(): void {
|
|
|
87
87
|
REGISTERED.clear();
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
/**
|
|
91
|
+
* The classification somebody actually DECLARED for this code, `undefined` when nobody did.
|
|
92
|
+
*
|
|
93
|
+
* `retryFor` answers a client's question — "may I send this again?" — and fails closed, so it
|
|
94
|
+
* cannot tell a code declared `terminal` from a code nobody classified. A caller deciding whether
|
|
95
|
+
* to STOP work already in flight has to tell them apart: the job executor reads this, because
|
|
96
|
+
* treating every unclassified code as `terminal` would end the retry policy of every job in every
|
|
97
|
+
* shipped app, which is a far larger fault than the one that reading brings.
|
|
98
|
+
*
|
|
99
|
+
* Core table first, for the same belt-and-braces reason `retryFor` had it first.
|
|
100
|
+
*/
|
|
101
|
+
export function declaredErrorRetry(code: string): ErrorRetry | undefined {
|
|
102
|
+
return CORE_ERROR_RETRY[code] ?? REGISTERED.get(code);
|
|
103
|
+
}
|
|
104
|
+
|
|
93
105
|
export function retryFor(code: string): ErrorRetry {
|
|
94
|
-
return
|
|
106
|
+
return declaredErrorRetry(code) ?? DEFAULT_ERROR_RETRY;
|
|
95
107
|
}
|
|
96
108
|
|
|
97
109
|
/** Every classification a package or app declared, for `x errors list` and the manifest. */
|
package/src/index.ts
CHANGED
|
@@ -130,6 +130,7 @@ export {
|
|
|
130
130
|
CORE_ERROR_CODES,
|
|
131
131
|
ConfigInvalidError,
|
|
132
132
|
DEFAULT_ERROR_RETRY,
|
|
133
|
+
declaredErrorRetry,
|
|
133
134
|
describeErrorCode,
|
|
134
135
|
describeValue,
|
|
135
136
|
EnvMissingError,
|
|
@@ -427,6 +428,7 @@ export {
|
|
|
427
428
|
export type { ImageFit, ResizeSpec } from './image/resize';
|
|
428
429
|
export { fitBox, resizeRaster, scaledToFit } from './image/resize';
|
|
429
430
|
export { impersonate, impersonationReason, isImpersonating } from './impersonate';
|
|
431
|
+
export { cachedFormatter, canonicalLocale, MAX_CACHED_FORMATTERS } from './intl-cache';
|
|
430
432
|
export type {
|
|
431
433
|
HealthPayload,
|
|
432
434
|
HealthReport,
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// One bounded cache, on one canonical key, for every `Intl` formatter the framework builds.
|
|
2
|
+
// A locale and a zone both arrive from a request header, so an unbounded `Map` keyed on the
|
|
3
|
+
// caller's spelling is memory the client chooses — 31 MB and 55.1 MB, measured `As of 2026-08` and
|
|
4
|
+
// written up in the README. The bound and the canonical key are two halves of ONE rule.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Above the full canonical IANA set (445 zones as of tzdata 2025) so a correct app never evicts,
|
|
8
|
+
* and small enough that the worst case is a few megabytes rather than a leak. A miss costs one
|
|
9
|
+
* `Intl` construction, never a wrong answer — which is what makes a bound safe here at all.
|
|
10
|
+
*/
|
|
11
|
+
export const MAX_CACHED_FORMATTERS = 512;
|
|
12
|
+
|
|
13
|
+
/** FIFO — a `Map` iterates in insertion order, so the first key inserted is the first evicted. */
|
|
14
|
+
export function cachedFormatter<T>(cache: Map<string, T>, key: string, build: () => T): T {
|
|
15
|
+
// Membership decides, never truthiness: `T` is the caller's, so a stored `undefined` is a hit.
|
|
16
|
+
// The cast is sound because `has` just proved the key is present, which `get`'s signature cannot.
|
|
17
|
+
if (cache.has(key)) return cache.get(key) as T;
|
|
18
|
+
const formatter = build();
|
|
19
|
+
if (cache.size >= MAX_CACHED_FORMATTERS) {
|
|
20
|
+
const oldest = cache.keys().next().value;
|
|
21
|
+
if (oldest !== undefined) cache.delete(oldest);
|
|
22
|
+
}
|
|
23
|
+
cache.set(key, formatter);
|
|
24
|
+
return formatter;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The canonical BCP 47 spelling, or `undefined` when the tag is not structurally valid at all
|
|
29
|
+
* (`en_US`, `''`, `not a locale`). Well-formed but unknown to ICU (`zz`) is a locale — `Intl`
|
|
30
|
+
* falls back for it, and refusing here would be stricter than the formatters this feeds.
|
|
31
|
+
*
|
|
32
|
+
* Deliberately **not** memoised: this is string work, and a `Map` keyed on a header value is the
|
|
33
|
+
* unbounded cache the bound above exists to prevent.
|
|
34
|
+
*/
|
|
35
|
+
export function canonicalLocale(locale: string): string | undefined {
|
|
36
|
+
try {
|
|
37
|
+
// `getCanonicalLocales` runs the same IsStructurallyValidLanguageTag check that
|
|
38
|
+
// `supportedLocalesOf` throws on, and unlike it, hands back the canonical spelling.
|
|
39
|
+
return Intl.getCanonicalLocales(locale)[0];
|
|
40
|
+
} catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/lifecycle.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { type Clock, systemClock } from './clock';
|
|
|
6
6
|
import { UltimateError } from './errors';
|
|
7
7
|
import { settleWithin } from './lifecycle-deadline';
|
|
8
8
|
import { lifecycleDrained } from './lifecycle-errors';
|
|
9
|
-
import { type Logger, logger as rootLogger } from './logger';
|
|
9
|
+
import { type LogFields, type Logger, logger as rootLogger } from './logger';
|
|
10
10
|
|
|
11
11
|
export type HealthState = 'starting' | 'ready' | 'draining' | 'stopped';
|
|
12
12
|
|
|
@@ -165,6 +165,33 @@ export function readinessCheckCount(): number {
|
|
|
165
165
|
return readiness.size;
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Every line this file emits, and the only way it emits one. `log` is an injection seam
|
|
170
|
+
* (`configureLifecycle({ logger })`), so an app's `Logger` decides whether a log call can throw —
|
|
171
|
+
* and a throw here does not lose a line, it replaces the event. Inside `drain()` it rejected
|
|
172
|
+
* `drainPromise`: `state` never reached 'stopped', the memo re-rejected for every later caller,
|
|
173
|
+
* and on Bun the unhandled rejection ended the process the drain was trying to end cleanly.
|
|
174
|
+
* Inside `readinessChecks()` it replaced the probe's answer with a throw.
|
|
175
|
+
*
|
|
176
|
+
* A lifecycle that cannot report is still a lifecycle: the line falls back to core's own
|
|
177
|
+
* `rootLogger`, which is total by construction (`logger.ts`), and failing that is dropped.
|
|
178
|
+
*/
|
|
179
|
+
function report(level: 'info' | 'warn' | 'error', message: string, fields: LogFields): void {
|
|
180
|
+
try {
|
|
181
|
+
log[level](message, fields);
|
|
182
|
+
return;
|
|
183
|
+
} catch {
|
|
184
|
+
// Fall through — the injected sink is gone, and the fallback below is the last one there is.
|
|
185
|
+
}
|
|
186
|
+
if (log === rootLogger) return;
|
|
187
|
+
try {
|
|
188
|
+
rootLogger[level](message, fields);
|
|
189
|
+
} catch {
|
|
190
|
+
// Both sinks are gone. Dropping the line is the only remaining option that still ends the
|
|
191
|
+
// process, which is the outcome every caller of this file depends on.
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
168
195
|
/** Every check, run now, by name. A check that throws is `failing` — never an unhandled error. */
|
|
169
196
|
export function readinessChecks(): Readonly<Record<string, ReadinessStatus>> {
|
|
170
197
|
const results: Record<string, ReadinessStatus> = {};
|
|
@@ -173,7 +200,7 @@ export function readinessChecks(): Readonly<Record<string, ReadinessStatus>> {
|
|
|
173
200
|
results[name] = check() ? 'ok' : 'failing';
|
|
174
201
|
} catch (thrown) {
|
|
175
202
|
results[name] = 'failing';
|
|
176
|
-
|
|
203
|
+
report('warn', 'readiness check threw', { check: name, error: thrown });
|
|
177
204
|
}
|
|
178
205
|
}
|
|
179
206
|
return results;
|
|
@@ -278,7 +305,7 @@ async function runPhase(phase: ShutdownPhase, reason: ShutdownReason): Promise<v
|
|
|
278
305
|
for (const registration of registrations.filter((entry) => entry.phase === phase)) {
|
|
279
306
|
const outcome = await settleWithin(() => registration.hook(reason), remainingBudget(reason));
|
|
280
307
|
if (outcome.kind === 'failed') {
|
|
281
|
-
|
|
308
|
+
report('error', 'shutdown hook failed', {
|
|
282
309
|
hook: registration.name,
|
|
283
310
|
phase,
|
|
284
311
|
error: outcome.error,
|
|
@@ -290,7 +317,7 @@ async function runPhase(phase: ShutdownPhase, reason: ShutdownReason): Promise<v
|
|
|
290
317
|
// SIGKILL this process — the every-deploy duplicate that draining exists to prevent — so
|
|
291
318
|
// the drain moves on and the hook is left running with nobody reading it. The cost of that
|
|
292
319
|
// choice is real and named in the cause: a write it had in flight may be half done.
|
|
293
|
-
|
|
320
|
+
report('warn', 'X_SHUTDOWN_TIMEOUT', {
|
|
294
321
|
code: 'X_SHUTDOWN_TIMEOUT',
|
|
295
322
|
cause: `the "${registration.name}" shutdown hook (phase: ${phase}) was still running at the ${deadlineMs}ms drain deadline and has been ABANDONED — the process exits without it, so anything it had in flight may be incomplete`,
|
|
296
323
|
fix: `raise the budget past the work this hook does — configureLifecycle({ deadlineMs: 600_000 }) for a 10-minute job — and set terminationGracePeriodSeconds to at least as many seconds, or make the "${registration.name}" hook return once it has stopped accepting work rather than once it has finished`,
|
|
@@ -308,25 +335,34 @@ export function drain(signal = 'manual'): Promise<void> {
|
|
|
308
335
|
const reason: ShutdownReason = { signal, deadlineAt: systemClock.monotonic() + deadlineMs };
|
|
309
336
|
|
|
310
337
|
drainPromise = (async () => {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
338
|
+
try {
|
|
339
|
+
report('info', 'draining', { signal, deadlineMs, inflight });
|
|
340
|
+
await runPhase('accept', reason);
|
|
341
|
+
|
|
342
|
+
// Real monotonic, like `deadlineAt` itself: `waitForIdle` sleeps on a real `setTimeout`, and
|
|
343
|
+
// a budget read off an injected clock is a number that timer will never honour.
|
|
344
|
+
const remaining = Math.max(0, reason.deadlineAt - systemClock.monotonic());
|
|
345
|
+
const idle = await waitForIdle(remaining);
|
|
346
|
+
if (!idle) {
|
|
347
|
+
report('warn', 'X_SHUTDOWN_TIMEOUT', {
|
|
348
|
+
code: 'X_SHUTDOWN_TIMEOUT',
|
|
349
|
+
cause: `${inflight} in-flight operations still running after ${deadlineMs}ms`,
|
|
350
|
+
fix: 'raise the budget past the slowest handler — configureLifecycle({ deadlineMs: 600_000 }) for a 10-minute one — and set terminationGracePeriodSeconds to at least as many seconds, or shorten the handler',
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
await runPhase('inflight', reason);
|
|
355
|
+
await runPhase('close', reason);
|
|
356
|
+
} catch (thrown) {
|
|
357
|
+
// Nothing above should reach here — every hook is caught by `settleWithin` and every line
|
|
358
|
+
// goes through `report`. If something does, the drain still ENDS: a rejected `drainPromise`
|
|
359
|
+
// is a memo that re-rejects for every later caller and an unhandled rejection that kills the
|
|
360
|
+
// process mid-drain, which is strictly worse than a drain that finished badly and said so.
|
|
361
|
+
report('error', 'drain failed', { signal, error: thrown });
|
|
362
|
+
} finally {
|
|
363
|
+
state = 'stopped';
|
|
324
364
|
}
|
|
325
|
-
|
|
326
|
-
await runPhase('inflight', reason);
|
|
327
|
-
await runPhase('close', reason);
|
|
328
|
-
state = 'stopped';
|
|
329
|
-
log.info('stopped', { signal });
|
|
365
|
+
report('info', 'stopped', { signal });
|
|
330
366
|
})();
|
|
331
367
|
|
|
332
368
|
return drainPromise;
|
|
@@ -345,9 +381,14 @@ export function installSignalHandlers(options?: SignalHandlerOptions): () => voi
|
|
|
345
381
|
|
|
346
382
|
for (const signal of signals) {
|
|
347
383
|
const handler = (): void => {
|
|
348
|
-
|
|
384
|
+
// Attached on BOTH settle paths, for the reason `settleWithin` gives: an unhandled rejection
|
|
385
|
+
// ends the process before the drain does, and the exit is what the kubelet is waiting for.
|
|
386
|
+
// `drain()` cannot reject today — that is the `try/finally` above, not luck — and this is
|
|
387
|
+
// the one line that keeps it true when someone changes the body.
|
|
388
|
+
const done = (): void => {
|
|
349
389
|
if (options?.exit === true) process.exit(0);
|
|
350
|
-
}
|
|
390
|
+
};
|
|
391
|
+
void drain(signal).then(done, done);
|
|
351
392
|
};
|
|
352
393
|
handlers.set(signal, handler);
|
|
353
394
|
process.on(signal, handler);
|
package/src/metrics.ts
CHANGED
|
@@ -111,12 +111,20 @@ export interface InstrumentOptions {
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
export interface GaugeOptions extends InstrumentOptions {
|
|
114
|
-
/**
|
|
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
|
+
*/
|
|
115
119
|
readonly observe?: (() => number) | undefined;
|
|
116
120
|
}
|
|
117
121
|
|
|
118
122
|
export interface HistogramOptions extends InstrumentOptions {
|
|
119
|
-
/**
|
|
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
|
+
*/
|
|
120
128
|
readonly bounds?: readonly number[] | undefined;
|
|
121
129
|
}
|
|
122
130
|
|
|
@@ -263,6 +271,7 @@ function declare(name: string, kind: MetricKind, options: GaugeOptions & Histogr
|
|
|
263
271
|
meta: { name, declared: existing.descriptor.kind, requested: kind },
|
|
264
272
|
});
|
|
265
273
|
}
|
|
274
|
+
assertSameDeclaration(name, existing, options);
|
|
266
275
|
return existing;
|
|
267
276
|
}
|
|
268
277
|
const maxSeries = options.maxSeries ?? DEFAULT_MAX_SERIES;
|
|
@@ -290,6 +299,39 @@ function declare(name: string, kind: MetricKind, options: GaugeOptions & Histogr
|
|
|
290
299
|
return instrument;
|
|
291
300
|
}
|
|
292
301
|
|
|
302
|
+
/**
|
|
303
|
+
* A second declaration that STATES a different shape is refused. The first declaration wins, so a
|
|
304
|
+
* second `histogram(name, { bounds })` recorded into buckets another module chose and a second
|
|
305
|
+
* `gauge(name, { observe })` was collected through the first module's observer — silently, in both
|
|
306
|
+
* cases, which is the whole failure. An OMITTED option is not a conflict: `gauge(name)` is how a
|
|
307
|
+
* module takes a handle on an instrument someone else declared, and `maxSeries` keeps its shipped
|
|
308
|
+
* first-declaration-wins rule because it decides a ceiling rather than what gets recorded.
|
|
309
|
+
*/
|
|
310
|
+
function assertSameDeclaration(
|
|
311
|
+
name: string,
|
|
312
|
+
existing: Instrument,
|
|
313
|
+
options: GaugeOptions & HistogramOptions,
|
|
314
|
+
): void {
|
|
315
|
+
const { bounds, observe } = options;
|
|
316
|
+
if (bounds !== undefined && !sameBounds(existing.bounds, bounds)) {
|
|
317
|
+
throw new MetricNameInvalidError({
|
|
318
|
+
cause: `"${name}" is already declared with bounds [${existing.bounds.join(', ')}] and is redeclared with [${bounds.join(', ')}]; the first declaration wins, so the second set would never be used`,
|
|
319
|
+
fix: `declare "${name}" once and export the handle — import it where you record — or give the second instrument its own name`,
|
|
320
|
+
meta: { name, declared: existing.bounds.join(','), requested: bounds.join(',') },
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
if (observe !== undefined && observe !== existing.observe) {
|
|
324
|
+
throw new MetricNameInvalidError({
|
|
325
|
+
cause: `"${name}" is already declared with an observe() callback and is redeclared with a different one; the first declaration wins, so the second callback would never be read`,
|
|
326
|
+
fix: `declare "${name}" once and export the handle — import it where you read — or give the second gauge its own name`,
|
|
327
|
+
meta: { name },
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const sameBounds = (left: readonly number[], right: readonly number[]): boolean =>
|
|
333
|
+
left.length === right.length && left.every((bound, index) => bound === right[index]);
|
|
334
|
+
|
|
293
335
|
/**
|
|
294
336
|
* Reported through the logger rather than thrown: the call site is `orderCounter.add(1, …)` deep
|
|
295
337
|
* inside a request, and killing that request would turn a metrics bug into a user-visible outage
|