@ultimat3/core 24.0.0 → 25.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 +24 -27
- package/README.md +71 -34
- package/package.json +4 -7
- package/src/actor.ts +9 -0
- package/src/address-class.ts +40 -4
- package/src/assert.ts +9 -5
- package/src/audit.ts +144 -0
- package/src/aws-sigv4.ts +275 -0
- package/src/backoff.ts +16 -0
- package/src/bunfs.ts +17 -0
- package/src/client-dispatch.ts +24 -3
- package/src/client-flight.ts +68 -13
- package/src/client-problem.ts +62 -6
- package/src/client-retry-after.ts +47 -0
- package/src/client-transport.ts +3 -1
- package/src/client-wire.ts +27 -3
- package/src/config-ai.ts +32 -0
- package/src/config-defaults.ts +18 -11
- package/src/config-fixes.ts +0 -10
- package/src/config-health.ts +9 -2
- package/src/config-jobs.ts +51 -0
- package/src/config-keys.ts +170 -0
- package/src/config-mail.ts +73 -0
- package/src/config-merge.ts +1 -1
- package/src/config-navigation.ts +1 -25
- package/src/config-pwa.ts +42 -5
- package/src/config-removed.ts +131 -0
- package/src/config-shape.ts +0 -33
- package/src/config.ts +71 -94
- package/src/context.ts +16 -12
- package/src/cookie.ts +267 -3
- package/src/core-error-codes.ts +2 -0
- package/src/cursor-page.ts +41 -0
- package/src/cursor.ts +23 -5
- package/src/deprecation.ts +77 -0
- package/src/dev-secrets.ts +18 -7
- package/src/drain-deadline.ts +43 -0
- package/src/env-example.ts +9 -29
- package/src/errors.ts +18 -9
- package/src/exports/error-contract.ts +0 -1
- package/src/exports/observability.ts +1 -1
- package/src/exports/secrets.ts +3 -0
- package/src/finite-option.ts +1 -1
- package/src/flight-gate.ts +29 -14
- package/src/generation-fence.ts +1 -1
- package/src/ids.ts +7 -7
- package/src/image/canvas.ts +76 -5
- package/src/image/pipeline.ts +17 -5
- package/src/image/raster.ts +24 -1
- package/src/index.ts +89 -35
- package/src/iso-date.ts +1 -1
- package/src/lifecycle-errors.ts +1 -1
- package/src/lifecycle-readiness.ts +60 -2
- package/src/lifecycle-signals.ts +27 -2
- package/src/lifecycle-types.ts +96 -0
- package/src/lifecycle.ts +44 -133
- package/src/locale-direction.ts +1 -1
- package/src/logger.ts +16 -7
- package/src/mcp-exposure.ts +70 -8
- package/src/measurement-actor.ts +16 -1
- package/src/metric-errors.ts +32 -0
- package/src/metric-registry.ts +151 -0
- package/src/metric-series.ts +94 -0
- package/src/metrics.ts +9 -255
- package/src/page.ts +4 -2
- package/src/registrar.ts +1 -0
- package/src/retry.ts +25 -5
- package/src/secrets-key-file.ts +139 -0
- package/src/secrets-store.ts +32 -16
- package/src/service.ts +5 -5
- package/src/single-flight.ts +1 -1
- package/src/telemetry.ts +1 -1
- package/src/theme-storage.ts +12 -0
- package/src/type-pins.ts +51 -1
- package/src/image/fixtures.ts +0 -263
- package/src/time-zone-name.ts +0 -14
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Single responsibility: one series per label set under an instrument — its stable key, and the
|
|
2
|
+
// cardinality ceiling past which every new label set folds into one overflow series, reported once.
|
|
3
|
+
|
|
4
|
+
import { logger } from './logger';
|
|
5
|
+
import { MetricCardinalityError } from './metric-errors';
|
|
6
|
+
import { assertLabelNames } from './metric-names';
|
|
7
|
+
import type { Instrument, Series } from './metric-registry';
|
|
8
|
+
import type { MetricAttributes } from './metrics-types';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The label the folded series carries. OTel's own cardinality-limit spelling, deliberately NOT
|
|
12
|
+
* `__overflow`: Prometheus treats `__`-prefixed labels as internal and strips them during
|
|
13
|
+
* relabeling, so an overflow series named that way would merge back into the unlabelled series
|
|
14
|
+
* and the drop would be invisible in exactly the place it has to be visible.
|
|
15
|
+
*/
|
|
16
|
+
export const OVERFLOW_ATTRIBUTE = 'otel_metric_overflow';
|
|
17
|
+
|
|
18
|
+
const OVERFLOW_ATTRIBUTES: MetricAttributes = Object.freeze({ [OVERFLOW_ATTRIBUTE]: true });
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Stable series key: attribute order must not create a second series for one label set, and no
|
|
22
|
+
* label set may spell another one's key.
|
|
23
|
+
*
|
|
24
|
+
* `JSON.stringify` over the sorted pairs, because a DELIMITER cannot carry the second property:
|
|
25
|
+
* the key was the pairs joined by control characters (U+0000 inside a pair, U+0001 between them),
|
|
26
|
+
* and a value holding those bytes IS another set's key — `{ a: 'b\u0001c\u0000d' }` was
|
|
27
|
+
* `{ a: 'b', c: 'd' }`, so the point landed on whichever series arrived first and was exported
|
|
28
|
+
* under labels the caller never passed. Attribute values are app data. Quoting is the only total
|
|
29
|
+
* answer and is not slower: 644 ns/op against the join's 709, on a 3-label set. `String(value)`
|
|
30
|
+
* stays, so `1` and `'1'` are still one series rather than two rows an exporter renders alike.
|
|
31
|
+
*/
|
|
32
|
+
function seriesKey(attributes: MetricAttributes): string {
|
|
33
|
+
const entries = Object.entries(attributes);
|
|
34
|
+
if (entries.length === 0) return '';
|
|
35
|
+
return JSON.stringify(
|
|
36
|
+
entries.sort(([a], [b]) => (a < b ? -1 : 1)).map(([key, value]) => [key, String(value)]),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Reported through the logger rather than thrown: the call site is `orderCounter.add(1, …)` deep
|
|
42
|
+
* inside a request, and killing that request would turn a metrics bug into a user-visible outage
|
|
43
|
+
* — which is the same trade `finite()` does NOT make, because a NaN is a caller bug at one call
|
|
44
|
+
* site while this is a design bug the whole instrument shares.
|
|
45
|
+
*/
|
|
46
|
+
function reportOverflow(instrument: Instrument): void {
|
|
47
|
+
if (instrument.overflowed) return;
|
|
48
|
+
instrument.overflowed = true;
|
|
49
|
+
const { name, kind } = instrument.descriptor;
|
|
50
|
+
const error = new MetricCardinalityError({
|
|
51
|
+
cause: `${name} reached its ceiling of ${instrument.maxSeries} label set(s); every further label set folds into one ${OVERFLOW_ATTRIBUTE}="true" series`,
|
|
52
|
+
fix: `drop the unbounded label from the ${name} call site (an id, a path, an email is never a label), or raise it deliberately: ${kind}('${name}', { maxSeries: ${instrument.maxSeries * 2} })`,
|
|
53
|
+
meta: { metric: name, maxSeries: instrument.maxSeries },
|
|
54
|
+
});
|
|
55
|
+
logger.error(error.format(), { code: error.code, metric: name });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function createSeries(instrument: Instrument, key: string, attributes: MetricAttributes): Series {
|
|
59
|
+
const created: Series = {
|
|
60
|
+
attributes,
|
|
61
|
+
value: 0,
|
|
62
|
+
count: 0,
|
|
63
|
+
min: Number.POSITIVE_INFINITY,
|
|
64
|
+
max: Number.NEGATIVE_INFINITY,
|
|
65
|
+
buckets: new Array<number>(instrument.bounds.length + 1).fill(0),
|
|
66
|
+
};
|
|
67
|
+
instrument.series.set(key, created);
|
|
68
|
+
return created;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const OVERFLOW_KEY = seriesKey(OVERFLOW_ATTRIBUTES);
|
|
72
|
+
|
|
73
|
+
export function seriesFor(instrument: Instrument, attributes: MetricAttributes): Series {
|
|
74
|
+
const key = seriesKey(attributes);
|
|
75
|
+
const found = instrument.series.get(key);
|
|
76
|
+
if (found !== undefined) return found;
|
|
77
|
+
// On the MISS, ahead of the ceiling — never inside `createSeries`, which the overflow branch
|
|
78
|
+
// below returns without reaching. A screen that ran only where a series is BORN was a screen
|
|
79
|
+
// that depended on load: `bad"key` threw on a fresh process and was swallowed on a busy one,
|
|
80
|
+
// once the instrument had filled up, which is exactly when an unparseable label is likeliest to
|
|
81
|
+
// arrive. A hit needs no check — a key in the map passed this on the way in.
|
|
82
|
+
assertLabelNames(instrument.descriptor.name, attributes);
|
|
83
|
+
if (instrument.series.size >= instrument.maxSeries) {
|
|
84
|
+
reportOverflow(instrument);
|
|
85
|
+
// Created directly rather than through this function again: the overflow series is the ONE
|
|
86
|
+
// allocation the ceiling does not apply to, and routing it back through the check is an
|
|
87
|
+
// infinite recursion the first time the cap is hit.
|
|
88
|
+
return (
|
|
89
|
+
instrument.series.get(OVERFLOW_KEY) ??
|
|
90
|
+
createSeries(instrument, OVERFLOW_KEY, OVERFLOW_ATTRIBUTES)
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return createSeries(instrument, key, attributes);
|
|
94
|
+
}
|
package/src/metrics.ts
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
import { assert } from './assert';
|
|
6
6
|
import { type Clock, systemClock } from './clock';
|
|
7
7
|
import { renderThrowable } from './error-render';
|
|
8
|
-
import { type CodedErrorInit, UltimateError } from './errors';
|
|
9
8
|
import { logger } from './logger';
|
|
10
|
-
import {
|
|
9
|
+
import { finite, MetricValueInvalidError } from './metric-errors';
|
|
10
|
+
import { declare, type Instrument, instruments } from './metric-registry';
|
|
11
|
+
import { seriesFor } from './metric-series';
|
|
11
12
|
import type {
|
|
12
13
|
Counter,
|
|
13
14
|
Gauge,
|
|
@@ -15,19 +16,19 @@ import type {
|
|
|
15
16
|
Histogram,
|
|
16
17
|
HistogramOptions,
|
|
17
18
|
InstrumentOptions,
|
|
18
|
-
MetricAttributes,
|
|
19
19
|
MetricCollection,
|
|
20
|
-
MetricDescriptor,
|
|
21
20
|
MetricExporter,
|
|
22
|
-
MetricKind,
|
|
23
21
|
MetricPoint,
|
|
24
22
|
} from './metrics-types';
|
|
25
23
|
import { serviceResource } from './telemetry';
|
|
26
24
|
|
|
27
|
-
// The data model
|
|
28
|
-
// unchanged, so nothing that imports a metric type
|
|
29
|
-
// path.
|
|
25
|
+
// The data model, the identifier grammar, the refusals, the registry and the series store are
|
|
26
|
+
// modules of their own; the public surface is unchanged, so nothing that imports a metric type, a
|
|
27
|
+
// constant or an error from here learns a second path.
|
|
28
|
+
export { MetricCardinalityError, MetricValueInvalidError } from './metric-errors';
|
|
30
29
|
export { MetricNameInvalidError } from './metric-names';
|
|
30
|
+
export { DEFAULT_HISTOGRAM_BOUNDS, DEFAULT_MAX_SERIES } from './metric-registry';
|
|
31
|
+
export { OVERFLOW_ATTRIBUTE } from './metric-series';
|
|
31
32
|
export type {
|
|
32
33
|
Counter,
|
|
33
34
|
Gauge,
|
|
@@ -46,44 +47,6 @@ export type {
|
|
|
46
47
|
ReadableMetric,
|
|
47
48
|
} from './metrics-types';
|
|
48
49
|
|
|
49
|
-
export class MetricValueInvalidError extends UltimateError {
|
|
50
|
-
static readonly code = 'X_METRIC_VALUE_INVALID';
|
|
51
|
-
override readonly name = 'MetricValueInvalidError';
|
|
52
|
-
constructor(init: CodedErrorInit) {
|
|
53
|
-
super({ ...init, code: MetricValueInvalidError.code });
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export class MetricCardinalityError extends UltimateError {
|
|
58
|
-
static readonly code = 'X_METRIC_CARDINALITY';
|
|
59
|
-
override readonly name = 'MetricCardinalityError';
|
|
60
|
-
constructor(init: CodedErrorInit) {
|
|
61
|
-
super({ ...init, code: MetricCardinalityError.code });
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* The per-instrument series ceiling. 2000 is roomy for a bounded label set — every route pattern
|
|
67
|
-
* times every status class times every method — and small enough that the process notices an
|
|
68
|
-
* unbounded one long before the scrape body does.
|
|
69
|
-
*/
|
|
70
|
-
export const DEFAULT_MAX_SERIES = 2000;
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* The label the folded series carries. OTel's own cardinality-limit spelling, deliberately NOT
|
|
74
|
-
* `__overflow`: Prometheus treats `__`-prefixed labels as internal and strips them during
|
|
75
|
-
* relabeling, so an overflow series named that way would merge back into the unlabelled series
|
|
76
|
-
* and the drop would be invisible in exactly the place it has to be visible.
|
|
77
|
-
*/
|
|
78
|
-
export const OVERFLOW_ATTRIBUTE = 'otel_metric_overflow';
|
|
79
|
-
|
|
80
|
-
const OVERFLOW_ATTRIBUTES: MetricAttributes = Object.freeze({ [OVERFLOW_ATTRIBUTE]: true });
|
|
81
|
-
|
|
82
|
-
/** OTel's default explicit bucket boundaries for a duration histogram, in seconds. */
|
|
83
|
-
export const DEFAULT_HISTOGRAM_BOUNDS: readonly number[] = Object.freeze([
|
|
84
|
-
0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10,
|
|
85
|
-
]);
|
|
86
|
-
|
|
87
50
|
export const noopMetricExporter: MetricExporter = Object.freeze({
|
|
88
51
|
export(): void {
|
|
89
52
|
// Intentionally empty: instruments are always live, and free until an exporter is configured.
|
|
@@ -115,29 +78,6 @@ export interface MetricsOptions {
|
|
|
115
78
|
readonly enabled?: boolean | undefined;
|
|
116
79
|
}
|
|
117
80
|
|
|
118
|
-
interface Series {
|
|
119
|
-
readonly attributes: MetricAttributes;
|
|
120
|
-
value: number;
|
|
121
|
-
count: number;
|
|
122
|
-
min: number;
|
|
123
|
-
max: number;
|
|
124
|
-
buckets: number[];
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
interface Instrument {
|
|
128
|
-
readonly descriptor: MetricDescriptor;
|
|
129
|
-
readonly series: Map<string, Series>;
|
|
130
|
-
readonly bounds: readonly number[];
|
|
131
|
-
readonly observe: (() => number) | undefined;
|
|
132
|
-
readonly maxSeries: number;
|
|
133
|
-
/** Reported once. A cardinality blow-up is one bug, not one log line per call. */
|
|
134
|
-
overflowed: boolean;
|
|
135
|
-
/** Reported once, for the same reason: a scrape every 15s must not become a log every 15s. */
|
|
136
|
-
observeFailed: boolean;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const instruments = new Map<string, Instrument>();
|
|
140
|
-
|
|
141
81
|
let exporter: MetricExporter = noopMetricExporter;
|
|
142
82
|
let clock: Clock = systemClock;
|
|
143
83
|
let enabled = true;
|
|
@@ -164,154 +104,6 @@ export function resetMetrics(): void {
|
|
|
164
104
|
}
|
|
165
105
|
}
|
|
166
106
|
|
|
167
|
-
/**
|
|
168
|
-
* Stable series key: attribute order must not create a second series for one label set, and no
|
|
169
|
-
* label set may spell another one's key.
|
|
170
|
-
*
|
|
171
|
-
* `JSON.stringify` over the sorted pairs, because a DELIMITER cannot carry the second property:
|
|
172
|
-
* the key was the pairs joined by control characters (U+0000 inside a pair, U+0001 between them),
|
|
173
|
-
* and a value holding those bytes IS another set's key — `{ a: 'b\u0001c\u0000d' }` was
|
|
174
|
-
* `{ a: 'b', c: 'd' }`, so the point landed on whichever series arrived first and was exported
|
|
175
|
-
* under labels the caller never passed. Attribute values are app data. Quoting is the only total
|
|
176
|
-
* answer and is not slower: 644 ns/op against the join's 709, on a 3-label set. `String(value)`
|
|
177
|
-
* stays, so `1` and `'1'` are still one series rather than two rows an exporter renders alike.
|
|
178
|
-
*/
|
|
179
|
-
function seriesKey(attributes: MetricAttributes): string {
|
|
180
|
-
const entries = Object.entries(attributes);
|
|
181
|
-
if (entries.length === 0) return '';
|
|
182
|
-
return JSON.stringify(
|
|
183
|
-
entries.sort(([a], [b]) => (a < b ? -1 : 1)).map(([key, value]) => [key, String(value)]),
|
|
184
|
-
);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function finite(name: string, value: number): number {
|
|
188
|
-
if (!Number.isFinite(value)) {
|
|
189
|
-
throw new MetricValueInvalidError({
|
|
190
|
-
cause: `${name} was given ${String(value)}, which is not a finite number`,
|
|
191
|
-
fix: `guard the value at the call site: Number.isFinite(v) before recording into ${name}`,
|
|
192
|
-
meta: { metric: name, received: String(value) },
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
return value;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* Bounds are strictly ascending finite numbers, refused at DECLARATION like `maxSeries` beside it.
|
|
200
|
-
* `record` takes the first bound an observation fits, and the exposition format emits one
|
|
201
|
-
* cumulative `le` series per bound in array order — so `[1, 0.5, 5]` both counted observations
|
|
202
|
-
* into a bucket that was not theirs and rendered a non-monotonic `le` series that Prometheus and
|
|
203
|
-
* OpenMetrics each reject. Two wrong numbers, neither visible from the other, and nothing at the
|
|
204
|
-
* call site to notice: the observations themselves were all valid.
|
|
205
|
-
*/
|
|
206
|
-
function assertBounds(name: string, bounds: readonly number[] | undefined): void {
|
|
207
|
-
if (bounds === undefined) return;
|
|
208
|
-
const bad = bounds.findIndex((bound, index) => {
|
|
209
|
-
const previous = index === 0 ? Number.NEGATIVE_INFINITY : (bounds[index - 1] as number);
|
|
210
|
-
return !Number.isFinite(bound) || bound <= previous;
|
|
211
|
-
});
|
|
212
|
-
if (bad === -1) return;
|
|
213
|
-
const repaired = [...new Set(bounds.filter((bound) => Number.isFinite(bound)))].sort(
|
|
214
|
-
(left, right) => left - right,
|
|
215
|
-
);
|
|
216
|
-
throw new MetricNameInvalidError({
|
|
217
|
-
cause: `${name} declared bounds [${bounds.map((bound) => String(bound)).join(', ')}], which are not strictly ascending finite numbers — [${String(bad)}] is ${String(bounds[bad])}`,
|
|
218
|
-
fix: `sort the bounds and drop the duplicates: histogram('${name}', { bounds: [${repaired.join(', ')}] })`,
|
|
219
|
-
meta: { metric: name, bounds: bounds.map((bound) => String(bound)), at: bad },
|
|
220
|
-
});
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function declare(name: string, kind: MetricKind, options: GaugeOptions & HistogramOptions) {
|
|
224
|
-
assertMetricName(name);
|
|
225
|
-
assertBounds(name, options.bounds);
|
|
226
|
-
const existing = instruments.get(name);
|
|
227
|
-
if (existing !== undefined) {
|
|
228
|
-
if (existing.descriptor.kind !== kind) {
|
|
229
|
-
throw new MetricNameInvalidError({
|
|
230
|
-
cause: `"${name}" is already declared as a ${existing.descriptor.kind}, redeclared as a ${kind}`,
|
|
231
|
-
fix: `rename one of the two instruments named "${name}" — one metric name, one kind`,
|
|
232
|
-
meta: { name, declared: existing.descriptor.kind, requested: kind },
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
assertSameDeclaration(name, existing, options);
|
|
236
|
-
return existing;
|
|
237
|
-
}
|
|
238
|
-
const maxSeries = options.maxSeries ?? DEFAULT_MAX_SERIES;
|
|
239
|
-
if (!Number.isInteger(maxSeries) || maxSeries < 1) {
|
|
240
|
-
throw new MetricCardinalityError({
|
|
241
|
-
cause: `${name} declared maxSeries ${String(maxSeries)}, which is not a positive integer`,
|
|
242
|
-
fix: `pass a positive integer: counter('${name}', { maxSeries: ${DEFAULT_MAX_SERIES} })`,
|
|
243
|
-
meta: { metric: name, maxSeries: String(maxSeries) },
|
|
244
|
-
});
|
|
245
|
-
}
|
|
246
|
-
const instrument: Instrument = {
|
|
247
|
-
descriptor: {
|
|
248
|
-
name,
|
|
249
|
-
kind,
|
|
250
|
-
unit: options.unit ?? '1',
|
|
251
|
-
description: options.description ?? '',
|
|
252
|
-
},
|
|
253
|
-
series: new Map<string, Series>(),
|
|
254
|
-
bounds: options.bounds ?? DEFAULT_HISTOGRAM_BOUNDS,
|
|
255
|
-
observe: options.observe,
|
|
256
|
-
maxSeries,
|
|
257
|
-
overflowed: false,
|
|
258
|
-
observeFailed: false,
|
|
259
|
-
};
|
|
260
|
-
instruments.set(name, instrument);
|
|
261
|
-
return instrument;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/**
|
|
265
|
-
* A second declaration that STATES a different shape is refused. The first declaration wins, so a
|
|
266
|
-
* second `histogram(name, { bounds })` recorded into buckets another module chose and a second
|
|
267
|
-
* `gauge(name, { observe })` was collected through the first module's observer — silently, in both
|
|
268
|
-
* cases, which is the whole failure. An OMITTED option is not a conflict: `gauge(name)` is how a
|
|
269
|
-
* module takes a handle on an instrument someone else declared, and `maxSeries` keeps its shipped
|
|
270
|
-
* first-declaration-wins rule because it decides a ceiling rather than what gets recorded.
|
|
271
|
-
*/
|
|
272
|
-
function assertSameDeclaration(
|
|
273
|
-
name: string,
|
|
274
|
-
existing: Instrument,
|
|
275
|
-
options: GaugeOptions & HistogramOptions,
|
|
276
|
-
): void {
|
|
277
|
-
const { bounds, observe } = options;
|
|
278
|
-
if (bounds !== undefined && !sameBounds(existing.bounds, bounds)) {
|
|
279
|
-
throw new MetricNameInvalidError({
|
|
280
|
-
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`,
|
|
281
|
-
fix: `declare "${name}" once and export the handle — import it where you record — or give the second instrument its own name`,
|
|
282
|
-
meta: { name, declared: existing.bounds.join(','), requested: bounds.join(',') },
|
|
283
|
-
});
|
|
284
|
-
}
|
|
285
|
-
if (observe !== undefined && observe !== existing.observe) {
|
|
286
|
-
throw new MetricNameInvalidError({
|
|
287
|
-
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`,
|
|
288
|
-
fix: `declare "${name}" once and export the handle — import it where you read — or give the second gauge its own name`,
|
|
289
|
-
meta: { name },
|
|
290
|
-
});
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
const sameBounds = (left: readonly number[], right: readonly number[]): boolean =>
|
|
295
|
-
left.length === right.length && left.every((bound, index) => bound === right[index]);
|
|
296
|
-
|
|
297
|
-
/**
|
|
298
|
-
* Reported through the logger rather than thrown: the call site is `orderCounter.add(1, …)` deep
|
|
299
|
-
* inside a request, and killing that request would turn a metrics bug into a user-visible outage
|
|
300
|
-
* — which is the same trade `finite()` does NOT make, because a NaN is a caller bug at one call
|
|
301
|
-
* site while this is a design bug the whole instrument shares.
|
|
302
|
-
*/
|
|
303
|
-
function reportOverflow(instrument: Instrument): void {
|
|
304
|
-
if (instrument.overflowed) return;
|
|
305
|
-
instrument.overflowed = true;
|
|
306
|
-
const { name, kind } = instrument.descriptor;
|
|
307
|
-
const error = new MetricCardinalityError({
|
|
308
|
-
cause: `${name} reached its ceiling of ${instrument.maxSeries} label set(s); every further label set folds into one ${OVERFLOW_ATTRIBUTE}="true" series`,
|
|
309
|
-
fix: `drop the unbounded label from the ${name} call site (an id, a path, an email is never a label), or raise it deliberately: ${kind}('${name}', { maxSeries: ${instrument.maxSeries * 2} })`,
|
|
310
|
-
meta: { metric: name, maxSeries: instrument.maxSeries },
|
|
311
|
-
});
|
|
312
|
-
logger.error(error.format(), { code: error.code, metric: name });
|
|
313
|
-
}
|
|
314
|
-
|
|
315
107
|
/**
|
|
316
108
|
* Reported through the logger for `reportOverflow`'s reason and once for the same one — a scrape
|
|
317
109
|
* runs on a timer, so a permanently broken observer would otherwise write a log line every
|
|
@@ -332,44 +124,6 @@ function reportObserveFailure(instrument: Instrument, thrown: unknown): void {
|
|
|
332
124
|
logger.error(error.format(), { code: error.code, metric: name });
|
|
333
125
|
}
|
|
334
126
|
|
|
335
|
-
function createSeries(instrument: Instrument, key: string, attributes: MetricAttributes): Series {
|
|
336
|
-
const created: Series = {
|
|
337
|
-
attributes,
|
|
338
|
-
value: 0,
|
|
339
|
-
count: 0,
|
|
340
|
-
min: Number.POSITIVE_INFINITY,
|
|
341
|
-
max: Number.NEGATIVE_INFINITY,
|
|
342
|
-
buckets: new Array<number>(instrument.bounds.length + 1).fill(0),
|
|
343
|
-
};
|
|
344
|
-
instrument.series.set(key, created);
|
|
345
|
-
return created;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
const OVERFLOW_KEY = seriesKey(OVERFLOW_ATTRIBUTES);
|
|
349
|
-
|
|
350
|
-
function seriesFor(instrument: Instrument, attributes: MetricAttributes): Series {
|
|
351
|
-
const key = seriesKey(attributes);
|
|
352
|
-
const found = instrument.series.get(key);
|
|
353
|
-
if (found !== undefined) return found;
|
|
354
|
-
// On the MISS, ahead of the ceiling — never inside `createSeries`, which the overflow branch
|
|
355
|
-
// below returns without reaching. A screen that ran only where a series is BORN was a screen
|
|
356
|
-
// that depended on load: `bad"key` threw on a fresh process and was swallowed on a busy one,
|
|
357
|
-
// once the instrument had filled up, which is exactly when an unparseable label is likeliest to
|
|
358
|
-
// arrive. A hit needs no check — a key in the map passed this on the way in.
|
|
359
|
-
assertLabelNames(instrument.descriptor.name, attributes);
|
|
360
|
-
if (instrument.series.size >= instrument.maxSeries) {
|
|
361
|
-
reportOverflow(instrument);
|
|
362
|
-
// Created directly rather than through this function again: the overflow series is the ONE
|
|
363
|
-
// allocation the ceiling does not apply to, and routing it back through the check is an
|
|
364
|
-
// infinite recursion the first time the cap is hit.
|
|
365
|
-
return (
|
|
366
|
-
instrument.series.get(OVERFLOW_KEY) ??
|
|
367
|
-
createSeries(instrument, OVERFLOW_KEY, OVERFLOW_ATTRIBUTES)
|
|
368
|
-
);
|
|
369
|
-
}
|
|
370
|
-
return createSeries(instrument, key, attributes);
|
|
371
|
-
}
|
|
372
|
-
|
|
373
127
|
/** Monotonic sum. A negative `add` is a bug in the caller, never a silent decrement. */
|
|
374
128
|
export function counter(name: string, options?: InstrumentOptions): Counter {
|
|
375
129
|
const instrument = declare(name, 'counter', options ?? {});
|
package/src/page.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// The client seam's own functions, for the hooks a browser island calls: the one transport, the
|
|
11
11
|
// URL rule, the principal-supersession reader, and the small helpers realtime's browser half uses.
|
|
12
12
|
// Each reaches no titles table — `page-bundle.test.ts` builds all of them together to prove it.
|
|
13
|
-
export {
|
|
13
|
+
export { assertCoded } from './assert';
|
|
14
14
|
export type { AsyncState } from './async-state';
|
|
15
15
|
export type { JitterMode, Random } from './backoff';
|
|
16
16
|
export { backoffDelay } from './backoff';
|
|
@@ -32,7 +32,7 @@ export type { UltimateErrorInit } from './errors';
|
|
|
32
32
|
export { isUltimateError, UltimateError } from './errors';
|
|
33
33
|
export { finiteCount, finiteOption } from './finite-option';
|
|
34
34
|
export { isSuperseded } from './generation-fence';
|
|
35
|
-
export {
|
|
35
|
+
export { uuidV7 } from './ids';
|
|
36
36
|
export { isJsonObject } from './json-object';
|
|
37
37
|
export type { OutboxDrainMessage } from './outbox-drain';
|
|
38
38
|
export { OUTBOX_DRAIN_MESSAGE } from './outbox-drain';
|
|
@@ -54,5 +54,7 @@ export type { RecordEnvelope, RecordRows } from './record-envelope';
|
|
|
54
54
|
export { decodeRecordEnvelope, RECORDS_HEADER } from './record-envelope';
|
|
55
55
|
export type { PageClient, RecordSink } from './record-sink';
|
|
56
56
|
export { pageClient } from './record-sink';
|
|
57
|
+
// The key the theme boot script reads and the toggle island writes.
|
|
58
|
+
export { THEME_STORAGE_KEY } from './theme-storage';
|
|
57
59
|
// A write's public name, so the page's store can recognise the `records` frame its own write made.
|
|
58
60
|
export { isWriteDigest, WRITE_DIGEST_LENGTH, writeDigest } from './write-digest';
|
package/src/registrar.ts
CHANGED
|
@@ -84,6 +84,7 @@ export const PRIMITIVE_FACTORIES = Object.freeze<readonly PrimitiveFactory[]>(
|
|
|
84
84
|
{ factory: 'exportRows', pkg: '@ultimat3/jobs', kind: 'job' },
|
|
85
85
|
{ factory: 'purge', pkg: '@ultimat3/jobs', kind: 'job' },
|
|
86
86
|
{ factory: 'webhook', pkg: '@ultimat3/jobs', kind: 'job' },
|
|
87
|
+
{ factory: 'mcpConfirmations', pkg: '@ultimat3/mcp', kind: 'action' },
|
|
87
88
|
{ factory: 'notifier', pkg: '@ultimat3/notify', kind: 'job' },
|
|
88
89
|
{ factory: 'scrape', pkg: '@ultimat3/scraping', kind: 'job' },
|
|
89
90
|
] satisfies readonly PrimitiveFactory[]
|
package/src/retry.ts
CHANGED
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
// nothing in the tree consulted it before deciding to try again, so four packages each shipped
|
|
4
4
|
// their own loop and only `@ultimat3/jobs`' asked the classification at all.
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
type BackoffCurve,
|
|
8
|
+
backoffDelay,
|
|
9
|
+
type JitterMode,
|
|
10
|
+
jitterStatedDelay,
|
|
11
|
+
type Random,
|
|
12
|
+
} from './backoff';
|
|
7
13
|
import { systemClock } from './clock';
|
|
8
14
|
import { classifyThrown, type ErrorRetry, statedDelayMs } from './error-retry';
|
|
9
15
|
import { finiteCount, finiteOption } from './finite-option';
|
|
@@ -13,7 +19,11 @@ export interface RetryPolicy {
|
|
|
13
19
|
readonly attempts: number;
|
|
14
20
|
/** The first delay, in ms. */
|
|
15
21
|
readonly base: number;
|
|
16
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Ceiling for any single delay, in ms. A `retry-after` the responder named is clamped to it
|
|
24
|
+
* too, and then gets its spread on top — at most half again, so a burst told one delay does
|
|
25
|
+
* not wake together (`jitterStatedDelay`).
|
|
26
|
+
*/
|
|
17
27
|
readonly max: number;
|
|
18
28
|
/**
|
|
19
29
|
* Required, unlike `backoffDelay`'s, and that is the point: a retry loop with no jitter is the
|
|
@@ -92,9 +102,19 @@ export function retryDecision(
|
|
|
92
102
|
const stated = classification === 'retry-after' ? statedDelayMs(error) : undefined;
|
|
93
103
|
return {
|
|
94
104
|
retry: true,
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
105
|
+
// The stated delay is a FLOOR plus a spread (`jitterStatedDelay`), never the bare number: a
|
|
106
|
+
// shedding server tells a whole burst `Retry-After: 1`, and waiting exactly that replays the
|
|
107
|
+
// burst in lockstep every second. The floor is clamped by the policy's own ceiling, which is
|
|
108
|
+
// what `max` is for — a responder naming a day is still a responder this deployment has not
|
|
109
|
+
// agreed to wait a day for — and the spread on top is at most half of it.
|
|
110
|
+
// `jitter: 'none'` is honoured here too — the caller's one decision, for a test or a printable
|
|
111
|
+
// schedule — and only that mode gets the bare floor.
|
|
112
|
+
delayMs:
|
|
113
|
+
stated === undefined
|
|
114
|
+
? computed
|
|
115
|
+
: policy.jitter === 'none'
|
|
116
|
+
? Math.min(stated, policy.max)
|
|
117
|
+
: jitterStatedDelay(Math.min(stated, policy.max), policy.max, random),
|
|
98
118
|
attempt,
|
|
99
119
|
nextAttempt: attempt + 1,
|
|
100
120
|
classification,
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Single responsibility: writing a master-key file that only its owner can read, on every platform,
|
|
2
|
+
// and renaming one over another without losing to a reader that holds it open. POSIX gets the first
|
|
3
|
+
// from the create mode. Windows ignores the mode — the file inherits the profile directory's ACL —
|
|
4
|
+
// so there the temp file's ACL is cut to the current account BEFORE the rename makes it live.
|
|
5
|
+
|
|
6
|
+
// why: `node:fs` sync — Bun.write takes no mode and has no atomic rename; both run once, at a CLI
|
|
7
|
+
// command or at boot, before anything is served, so there is nothing for an async call to overlap.
|
|
8
|
+
import { renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { backoffDelay } from './backoff';
|
|
10
|
+
import { renderCauseValue, stringField } from './error-render';
|
|
11
|
+
import { UltimateError } from './errors';
|
|
12
|
+
|
|
13
|
+
/** How long `icacls` or `whoami` may take before the write is refused as a failed ACL. */
|
|
14
|
+
const ACL_TIMEOUT_MS = 10_000;
|
|
15
|
+
|
|
16
|
+
/** Owner read/write — the mode every key file is CREATED with. */
|
|
17
|
+
export const OWNER_ONLY_MODE = 0o600;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Tries for a rename over a file another process holds open. Windows refuses that rename with
|
|
21
|
+
* EPERM or EBUSY rather than replacing the name the way POSIX does — an editor, an indexer or an
|
|
22
|
+
* antivirus scan holding `.secrets.key` for a moment — so a short, bounded retry outlasts the
|
|
23
|
+
* holder; a holder that never lets go is still an error, after ~150 ms rather than never.
|
|
24
|
+
*/
|
|
25
|
+
export const RENAME_ATTEMPTS = 5;
|
|
26
|
+
const isHeldOpen = (code: string | undefined): boolean => code === 'EPERM' || code === 'EBUSY';
|
|
27
|
+
|
|
28
|
+
/** Every effect, injectable: the Windows branch is proven on Linux with a fake. */
|
|
29
|
+
export interface KeyFileIo {
|
|
30
|
+
readonly platform: string;
|
|
31
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
32
|
+
readonly writeExclusive: (path: string, text: string, mode: number) => void;
|
|
33
|
+
readonly rename: (from: string, to: string) => void;
|
|
34
|
+
readonly remove: (path: string) => void;
|
|
35
|
+
readonly runAcl: (argv: readonly string[]) => { exitCode: number; output: string };
|
|
36
|
+
readonly username: () => string;
|
|
37
|
+
readonly sleep: (ms: number) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Built per call, never at import: the barrel reaches this module from browser bundles too. */
|
|
41
|
+
const systemIo = (): KeyFileIo => ({
|
|
42
|
+
platform: process.platform,
|
|
43
|
+
env: process.env,
|
|
44
|
+
// `wx`: the mode applies only when a write CREATES the file, so never write into a leftover.
|
|
45
|
+
writeExclusive: (path, text, mode) =>
|
|
46
|
+
writeFileSync(path, text, { encoding: 'utf-8', mode, flag: 'wx' }),
|
|
47
|
+
rename: renameSync,
|
|
48
|
+
remove: (path) => rmSync(path, { force: true }),
|
|
49
|
+
runAcl: (argv) => {
|
|
50
|
+
// Bounded: icacls on one local file answers in milliseconds; a hang must not hold the boot.
|
|
51
|
+
const ran = Bun.spawnSync([...argv], {
|
|
52
|
+
stdout: 'pipe',
|
|
53
|
+
stderr: 'pipe',
|
|
54
|
+
timeout: ACL_TIMEOUT_MS,
|
|
55
|
+
});
|
|
56
|
+
return {
|
|
57
|
+
exitCode: ran.exitCode,
|
|
58
|
+
output: `${ran.stdout.toString()}${ran.stderr.toString()}`.trim(),
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
// `whoami` prints `DOMAIN\user`, the form icacls resolves. Never `node:os`'s `userInfo`: the
|
|
62
|
+
// browser polyfill lacks it, and the barrel reaches this module from every island's bundle.
|
|
63
|
+
username: () =>
|
|
64
|
+
Bun.spawnSync(['whoami'], { stdout: 'pipe', timeout: ACL_TIMEOUT_MS }).stdout.toString().trim(),
|
|
65
|
+
sleep: (ms) => Bun.sleepSync(ms),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/** The account the key is granted to: `DOMAIN\user` where Windows names both. */
|
|
69
|
+
export function aclPrincipal(
|
|
70
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
71
|
+
username: () => string,
|
|
72
|
+
): string {
|
|
73
|
+
const user = env['USERNAME'];
|
|
74
|
+
const domain = env['USERDOMAIN'];
|
|
75
|
+
if (user !== undefined && user !== '') {
|
|
76
|
+
return domain !== undefined && domain !== '' ? `${domain}\\${user}` : user;
|
|
77
|
+
}
|
|
78
|
+
return username();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* `/inheritance:r` drops every ACE the directory handed down; `/grant:r` REPLACES any explicit one
|
|
83
|
+
* for the principal with full control. What is left is one entry: the account that wrote the key.
|
|
84
|
+
*/
|
|
85
|
+
export function ownerOnlyAclArgv(path: string, principal: string): readonly string[] {
|
|
86
|
+
return ['icacls', path, '/inheritance:r', '/grant:r', `${principal}:F`];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** icacls refused, so the key was never written: no file is better than a readable one. */
|
|
90
|
+
export class SecretsKeyAclError extends UltimateError {
|
|
91
|
+
constructor(input: { path: string; principal: string; exitCode: number; output: string }) {
|
|
92
|
+
super({
|
|
93
|
+
code: 'X_SECRETS_KEY_ACL_FAILED',
|
|
94
|
+
cause: `icacls could not restrict ${renderCauseValue(input.path)} to ${renderCauseValue(input.principal)} (exit ${input.exitCode}: ${renderCauseValue(input.output)}), so the key was not written — on Windows a file's mode does not keep other accounts out, its ACL does`,
|
|
95
|
+
fix: 'x doctor --json # on Windows: where.exe icacls must resolve (C:\\Windows\\System32) and whoami names the account the key is granted to — or keep no key file and set ULTIMATE_SECRETS_KEY instead',
|
|
96
|
+
meta: { path: input.path, exitCode: input.exitCode },
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** On win32, cut `path`'s ACL to the current account. A no-op elsewhere: the mode did it. */
|
|
102
|
+
export function restrictToOwner(path: string, io: KeyFileIo = systemIo()): void {
|
|
103
|
+
if (io.platform !== 'win32') return;
|
|
104
|
+
const principal = aclPrincipal(io.env, io.username);
|
|
105
|
+
const ran = io.runAcl(ownerOnlyAclArgv(path, principal));
|
|
106
|
+
if (ran.exitCode !== 0) {
|
|
107
|
+
throw new SecretsKeyAclError({ path, principal, exitCode: ran.exitCode, output: ran.output });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** `rename(from, to)`, retried on EPERM/EBUSY only, `RENAME_ATTEMPTS` tries in all. */
|
|
112
|
+
export function renameOver(from: string, to: string, io: KeyFileIo = systemIo()): void {
|
|
113
|
+
for (let attempt = 1; ; attempt++) {
|
|
114
|
+
try {
|
|
115
|
+
io.rename(from, to);
|
|
116
|
+
return;
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (attempt >= RENAME_ATTEMPTS || !isHeldOpen(stringField(error, 'code'))) throw error;
|
|
119
|
+
io.sleep(backoffDelay({ attempt, base: 10, max: 160 }));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Write `text` to `path`, readable by its owner alone, atomically: a fresh temp file created at
|
|
126
|
+
* 0600 (and ACL-restricted on Windows) is renamed over the target, so a reader sees the old file or
|
|
127
|
+
* the new one, and rotating over a key that was world-readable never leaves the new key so.
|
|
128
|
+
*/
|
|
129
|
+
export function writeOwnerOnlyFile(path: string, text: string, io: KeyFileIo = systemIo()): void {
|
|
130
|
+
const temp = `${path}.${crypto.randomUUID()}.tmp`;
|
|
131
|
+
try {
|
|
132
|
+
io.writeExclusive(temp, text, OWNER_ONLY_MODE);
|
|
133
|
+
restrictToOwner(temp, io);
|
|
134
|
+
renameOver(temp, path, io);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
io.remove(temp);
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
}
|