@ultimat3/core 23.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.
Files changed (98) hide show
  1. package/CLAUDE.md +29 -26
  2. package/README.md +98 -30
  3. package/package.json +4 -7
  4. package/src/actor.ts +9 -0
  5. package/src/address-class.ts +40 -4
  6. package/src/assert.ts +9 -5
  7. package/src/audit.ts +144 -0
  8. package/src/aws-sigv4.ts +275 -0
  9. package/src/backoff.ts +16 -0
  10. package/src/bunfs.ts +17 -0
  11. package/src/client-dispatch.ts +24 -3
  12. package/src/client-flight.ts +68 -13
  13. package/src/client-problem.ts +62 -6
  14. package/src/client-retry-after.ts +47 -0
  15. package/src/client-transport.ts +3 -1
  16. package/src/client-wire.ts +27 -3
  17. package/src/config-ai.ts +32 -0
  18. package/src/config-defaults.ts +53 -0
  19. package/src/config-fixes.ts +0 -10
  20. package/src/config-health.ts +9 -2
  21. package/src/config-jobs.ts +51 -0
  22. package/src/config-keys.ts +170 -0
  23. package/src/config-mail.ts +73 -0
  24. package/src/config-merge.ts +9 -1
  25. package/src/config-navigation.ts +1 -25
  26. package/src/config-pwa.ts +42 -5
  27. package/src/config-removed.ts +131 -0
  28. package/src/config-shape.ts +79 -0
  29. package/src/config-site.ts +14 -3
  30. package/src/config.ts +141 -173
  31. package/src/context.ts +29 -13
  32. package/src/cookie.ts +299 -0
  33. package/src/core-error-codes.ts +2 -0
  34. package/src/cursor-page.ts +41 -0
  35. package/src/cursor.ts +26 -5
  36. package/src/decimal-order.ts +5 -4
  37. package/src/deprecation.ts +77 -0
  38. package/src/dev-secrets.ts +18 -7
  39. package/src/drain-deadline.ts +43 -0
  40. package/src/env-example.ts +9 -29
  41. package/src/error-reporter-sentry.ts +7 -3
  42. package/src/errors.ts +18 -9
  43. package/src/exports/error-contract.ts +0 -1
  44. package/src/exports/observability.ts +1 -1
  45. package/src/exports/secrets.ts +3 -0
  46. package/src/finite-option.ts +1 -1
  47. package/src/flight-gate.ts +43 -16
  48. package/src/fnv1a.ts +19 -0
  49. package/src/generation-fence.ts +1 -1
  50. package/src/health-disclosure.ts +43 -0
  51. package/src/host-rules.ts +28 -1
  52. package/src/html-escape.ts +24 -0
  53. package/src/ids.ts +7 -7
  54. package/src/image/canvas.ts +76 -5
  55. package/src/image/errors.ts +3 -1
  56. package/src/image/pipeline.ts +17 -5
  57. package/src/image/png-pixels.ts +29 -6
  58. package/src/image/probe.ts +7 -2
  59. package/src/image/raster.ts +27 -2
  60. package/src/index.ts +99 -33
  61. package/src/iso-date.ts +1 -1
  62. package/src/lifecycle-errors.ts +1 -1
  63. package/src/lifecycle-readiness.ts +60 -2
  64. package/src/lifecycle-signals.ts +27 -2
  65. package/src/lifecycle-types.ts +96 -0
  66. package/src/lifecycle.ts +44 -133
  67. package/src/locale-direction.ts +1 -1
  68. package/src/logger.ts +92 -16
  69. package/src/mcp-exposure.ts +70 -8
  70. package/src/measurement-actor.ts +16 -1
  71. package/src/metric-errors.ts +32 -0
  72. package/src/metric-registry.ts +151 -0
  73. package/src/metric-series.ts +94 -0
  74. package/src/metrics.ts +9 -255
  75. package/src/nearest-name.ts +11 -2
  76. package/src/otlp-metric-exporter.ts +1 -1
  77. package/src/otlp-span-exporter.ts +1 -1
  78. package/src/otlp.ts +44 -13
  79. package/src/page.ts +4 -2
  80. package/src/pg-executor.ts +15 -0
  81. package/src/public-cause.ts +37 -0
  82. package/src/registrar.ts +22 -4
  83. package/src/retry.ts +40 -7
  84. package/src/route-rank.ts +36 -0
  85. package/src/same-origin.ts +1 -1
  86. package/src/sampler.ts +6 -2
  87. package/src/secrets-errors.ts +14 -3
  88. package/src/secrets-key-file.ts +139 -0
  89. package/src/secrets-store.ts +32 -16
  90. package/src/service.ts +5 -5
  91. package/src/single-flight.ts +1 -1
  92. package/src/source-mask.ts +14 -8
  93. package/src/store-mode.ts +23 -0
  94. package/src/telemetry.ts +1 -1
  95. package/src/theme-storage.ts +12 -0
  96. package/src/type-pins.ts +51 -1
  97. package/src/image/fixtures.ts +0 -263
  98. package/src/time-zone-name.ts +0 -14
@@ -30,6 +30,9 @@ export function defineMeasurementActor(factory: MeasurementActorFactory): void {
30
30
  /**
31
31
  * `kind: 'service'` and `'*'`: weighing bytes needs no data authority, and an app's own
32
32
  * `requireMember()` has nothing to resolve for a service actor, which is the honest answer.
33
+ * No org and no roles, so a page whose policy names a role or whose `load` reads a tenant-scoped
34
+ * entity is refused under it — the `budgets` step's `X_BUDGET_UNMEASURED` names this actor, the
35
+ * refusal and `defineMeasurementActor()` for exactly that page.
33
36
  */
34
37
  const defaultMeasurementActor = (): Actor =>
35
38
  serviceActor({ id: MEASUREMENT_ACTOR_ID, permissions: ['*'] });
@@ -41,11 +44,23 @@ export async function measurementActor(): Promise<Actor> {
41
44
  assert(
42
45
  typeof actor === 'object' && actor !== null && typeof actor.id === 'string',
43
46
  'the factory handed to defineMeasurementActor() answered no actor',
44
- 'return one from it in app.config.ts: defineMeasurementActor(() => userActor({ id: "measure", roles: ["member"] }))',
47
+ // Placeholders, not `roles: ["member"]`: the actor exists to pass the app's OWN gates, and a
48
+ // literal role here is one a `dev`-gated console refuses too (#675).
49
+ 'return one from it in app.config.ts: defineMeasurementActor(() => userActor({ id: "measure", orgId: "<an org your dev seed creates>", roles: ["<the role your gated pages require>"] }))',
45
50
  );
46
51
  return actor;
47
52
  }
48
53
 
54
+ /**
55
+ * The factory declared now, or `undefined` for the default — read, never called. The test
56
+ * preload's file boundary snapshots it beside the other process registries: `app.config.ts`
57
+ * declares at module scope, ONCE per process, so a file that imported an app's config left every
58
+ * later file measuring as that app's actor (`prerender-actor.test.ts`, denied X_FORBIDDEN).
59
+ */
60
+ export function declaredMeasurementActor(): MeasurementActorFactory | undefined {
61
+ return declared;
62
+ }
63
+
49
64
  /** Back to the default. For a test, and for nothing else. */
50
65
  export function resetMeasurementActor(): void {
51
66
  declared = undefined;
@@ -0,0 +1,32 @@
1
+ // Single responsibility: the two coded refusals a metric VALUE or a series CEILING earns, and the
2
+ // finiteness screen every recording path runs first. The name grammar's refusal is
3
+ // `metric-names.ts`'s; `metrics.ts` re-exports all three, so callers keep one import path.
4
+
5
+ import { type CodedErrorInit, UltimateError } from './errors';
6
+
7
+ export class MetricValueInvalidError extends UltimateError {
8
+ static readonly code = 'X_METRIC_VALUE_INVALID';
9
+ override readonly name = 'MetricValueInvalidError';
10
+ constructor(init: CodedErrorInit) {
11
+ super({ ...init, code: MetricValueInvalidError.code });
12
+ }
13
+ }
14
+
15
+ export class MetricCardinalityError extends UltimateError {
16
+ static readonly code = 'X_METRIC_CARDINALITY';
17
+ override readonly name = 'MetricCardinalityError';
18
+ constructor(init: CodedErrorInit) {
19
+ super({ ...init, code: MetricCardinalityError.code });
20
+ }
21
+ }
22
+
23
+ export function finite(name: string, value: number): number {
24
+ if (!Number.isFinite(value)) {
25
+ throw new MetricValueInvalidError({
26
+ cause: `${name} was given ${String(value)}, which is not a finite number`,
27
+ fix: `guard the value at the call site: Number.isFinite(v) before recording into ${name}`,
28
+ meta: { metric: name, received: String(value) },
29
+ });
30
+ }
31
+ return value;
32
+ }
@@ -0,0 +1,151 @@
1
+ // Single responsibility: the instrument registry — one declaration per metric name, its shape
2
+ // refused at declaration (bounds, ceiling, a conflicting redeclaration) rather than at the first
3
+ // recording. `metric-series.ts` stores points under an instrument; `metrics.ts` is the public seam.
4
+
5
+ import { MetricCardinalityError } from './metric-errors';
6
+ import { assertMetricName, MetricNameInvalidError } from './metric-names';
7
+ import type {
8
+ GaugeOptions,
9
+ HistogramOptions,
10
+ MetricAttributes,
11
+ MetricDescriptor,
12
+ MetricKind,
13
+ } from './metrics-types';
14
+
15
+ /**
16
+ * The per-instrument series ceiling. 2000 is roomy for a bounded label set — every route pattern
17
+ * times every status class times every method — and small enough that the process notices an
18
+ * unbounded one long before the scrape body does.
19
+ */
20
+ export const DEFAULT_MAX_SERIES = 2000;
21
+
22
+ /** OTel's default explicit bucket boundaries for a duration histogram, in seconds. */
23
+ export const DEFAULT_HISTOGRAM_BOUNDS: readonly number[] = Object.freeze([
24
+ 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,
25
+ ]);
26
+
27
+ export interface Series {
28
+ readonly attributes: MetricAttributes;
29
+ value: number;
30
+ count: number;
31
+ min: number;
32
+ max: number;
33
+ buckets: number[];
34
+ }
35
+
36
+ export interface Instrument {
37
+ readonly descriptor: MetricDescriptor;
38
+ readonly series: Map<string, Series>;
39
+ readonly bounds: readonly number[];
40
+ readonly observe: (() => number) | undefined;
41
+ readonly maxSeries: number;
42
+ /** Reported once. A cardinality blow-up is one bug, not one log line per call. */
43
+ overflowed: boolean;
44
+ /** Reported once, for the same reason: a scrape every 15s must not become a log every 15s. */
45
+ observeFailed: boolean;
46
+ }
47
+
48
+ export const instruments = new Map<string, Instrument>();
49
+
50
+ /**
51
+ * Bounds are strictly ascending finite numbers, refused at DECLARATION like `maxSeries` beside it.
52
+ * `record` takes the first bound an observation fits, and the exposition format emits one
53
+ * cumulative `le` series per bound in array order — so `[1, 0.5, 5]` both counted observations
54
+ * into a bucket that was not theirs and rendered a non-monotonic `le` series that Prometheus and
55
+ * OpenMetrics each reject. Two wrong numbers, neither visible from the other, and nothing at the
56
+ * call site to notice: the observations themselves were all valid.
57
+ */
58
+ function assertBounds(name: string, bounds: readonly number[] | undefined): void {
59
+ if (bounds === undefined) return;
60
+ const bad = bounds.findIndex((bound, index) => {
61
+ const previous = index === 0 ? Number.NEGATIVE_INFINITY : (bounds[index - 1] as number);
62
+ return !Number.isFinite(bound) || bound <= previous;
63
+ });
64
+ if (bad === -1) return;
65
+ const repaired = [...new Set(bounds.filter((bound) => Number.isFinite(bound)))].sort(
66
+ (left, right) => left - right,
67
+ );
68
+ throw new MetricNameInvalidError({
69
+ cause: `${name} declared bounds [${bounds.map((bound) => String(bound)).join(', ')}], which are not strictly ascending finite numbers — [${String(bad)}] is ${String(bounds[bad])}`,
70
+ fix: `sort the bounds and drop the duplicates: histogram('${name}', { bounds: [${repaired.join(', ')}] })`,
71
+ meta: { metric: name, bounds: bounds.map((bound) => String(bound)), at: bad },
72
+ });
73
+ }
74
+
75
+ export function declare(
76
+ name: string,
77
+ kind: MetricKind,
78
+ options: GaugeOptions & HistogramOptions,
79
+ ): Instrument {
80
+ assertMetricName(name);
81
+ assertBounds(name, options.bounds);
82
+ const existing = instruments.get(name);
83
+ if (existing !== undefined) {
84
+ if (existing.descriptor.kind !== kind) {
85
+ throw new MetricNameInvalidError({
86
+ cause: `"${name}" is already declared as a ${existing.descriptor.kind}, redeclared as a ${kind}`,
87
+ fix: `rename one of the two instruments named "${name}" — one metric name, one kind`,
88
+ meta: { name, declared: existing.descriptor.kind, requested: kind },
89
+ });
90
+ }
91
+ assertSameDeclaration(name, existing, options);
92
+ return existing;
93
+ }
94
+ const maxSeries = options.maxSeries ?? DEFAULT_MAX_SERIES;
95
+ if (!Number.isInteger(maxSeries) || maxSeries < 1) {
96
+ throw new MetricCardinalityError({
97
+ cause: `${name} declared maxSeries ${String(maxSeries)}, which is not a positive integer`,
98
+ fix: `pass a positive integer: counter('${name}', { maxSeries: ${DEFAULT_MAX_SERIES} })`,
99
+ meta: { metric: name, maxSeries: String(maxSeries) },
100
+ });
101
+ }
102
+ const instrument: Instrument = {
103
+ descriptor: {
104
+ name,
105
+ kind,
106
+ unit: options.unit ?? '1',
107
+ description: options.description ?? '',
108
+ },
109
+ series: new Map<string, Series>(),
110
+ bounds: options.bounds ?? DEFAULT_HISTOGRAM_BOUNDS,
111
+ observe: options.observe,
112
+ maxSeries,
113
+ overflowed: false,
114
+ observeFailed: false,
115
+ };
116
+ instruments.set(name, instrument);
117
+ return instrument;
118
+ }
119
+
120
+ /**
121
+ * A second declaration that STATES a different shape is refused. The first declaration wins, so a
122
+ * second `histogram(name, { bounds })` recorded into buckets another module chose and a second
123
+ * `gauge(name, { observe })` was collected through the first module's observer — silently, in both
124
+ * cases, which is the whole failure. An OMITTED option is not a conflict: `gauge(name)` is how a
125
+ * module takes a handle on an instrument someone else declared, and `maxSeries` keeps its shipped
126
+ * first-declaration-wins rule because it decides a ceiling rather than what gets recorded.
127
+ */
128
+ function assertSameDeclaration(
129
+ name: string,
130
+ existing: Instrument,
131
+ options: GaugeOptions & HistogramOptions,
132
+ ): void {
133
+ const { bounds, observe } = options;
134
+ if (bounds !== undefined && !sameBounds(existing.bounds, bounds)) {
135
+ throw new MetricNameInvalidError({
136
+ 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`,
137
+ fix: `declare "${name}" once and export the handle — import it where you record — or give the second instrument its own name`,
138
+ meta: { name, declared: existing.bounds.join(','), requested: bounds.join(',') },
139
+ });
140
+ }
141
+ if (observe !== undefined && observe !== existing.observe) {
142
+ throw new MetricNameInvalidError({
143
+ 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`,
144
+ fix: `declare "${name}" once and export the handle — import it where you read — or give the second gauge its own name`,
145
+ meta: { name },
146
+ });
147
+ }
148
+ }
149
+
150
+ const sameBounds = (left: readonly number[], right: readonly number[]): boolean =>
151
+ left.length === right.length && left.every((bound, index) => bound === right[index]);
@@ -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 { assertLabelNames, assertMetricName, MetricNameInvalidError } from './metric-names';
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 and the identifier grammar are modules of their own; the public surface is
28
- // unchanged, so nothing that imports a metric type or the name error from here learns a second
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 ?? {});
@@ -27,7 +27,16 @@ const distance = (a: string, b: string): number => {
27
27
  const MAX_EDITS = 3;
28
28
 
29
29
  /**
30
- * The nearest candidate within `MAX_EDITS`, or `undefined` when nothing is close enough. Ties keep
30
+ * The cutoff for one pair: `MAX_EDITS`, but never as many edits as the longer name has
31
+ * characters. A fixed 3 is a typo in `migrate` and a different word in `db` — replacing ALL of a
32
+ * one- or two-letter input costs at most its length, so `nearestName('a', ['db', 'gen'])`
33
+ * answered `db` with nothing typed in common.
34
+ */
35
+ const cutoff = (input: string, candidate: string): number =>
36
+ Math.min(MAX_EDITS, Math.max(input.length, candidate.length) - 1);
37
+
38
+ /**
39
+ * The nearest candidate within its cutoff, or `undefined` when nothing is close enough. Ties keep
31
40
  * the FIRST candidate, which is the order the caller declared them in — `definePermissions([...])`
32
41
  * and a `CommandSpec` list are both authored orders, and a stable answer is what lets a test pin one.
33
42
  */
@@ -36,7 +45,7 @@ export const nearestName = (input: string, candidates: readonly string[]): strin
36
45
  let bestScore = MAX_EDITS + 1;
37
46
  for (const candidate of candidates) {
38
47
  const score = distance(input, candidate);
39
- if (score < bestScore) {
48
+ if (score < bestScore && score <= cutoff(input, candidate)) {
40
49
  best = candidate;
41
50
  bestScore = score;
42
51
  }
@@ -118,7 +118,7 @@ export interface OtlpMetricExporter extends MetricExporter {
118
118
  */
119
119
  export function otlpMetricExporter(options: OtlpMetricExporterOptions = {}): OtlpMetricExporter {
120
120
  const url = otlpEndpoint('metrics', options.endpoint);
121
- const headers = otlpHeaders(options.headers);
121
+ const headers = otlpHeaders(options.headers, process.env, 'metrics');
122
122
  const timeoutMs = assertFiniteOtlpBound('timeoutMs', options.timeoutMs ?? 10_000);
123
123
  const send = options.fetch ?? globalThis.fetch;
124
124
  let startedAtMs = options.startedAtMs;
@@ -126,7 +126,7 @@ export interface OtlpSpanExporter extends SpanExporter {
126
126
  */
127
127
  export function otlpSpanExporter(options: OtlpSpanExporterOptions = {}): OtlpSpanExporter {
128
128
  const url = otlpEndpoint('traces', options.endpoint);
129
- const headers = otlpHeaders(options.headers);
129
+ const headers = otlpHeaders(options.headers, process.env, 'traces');
130
130
  const maxBatchSize = assertFiniteOtlpBound('maxBatchSize', options.maxBatchSize ?? 512);
131
131
  const maxQueueSize = assertFiniteOtlpBound('maxQueueSize', options.maxQueueSize ?? 2048);
132
132
  const timeoutMs = assertFiniteOtlpBound('timeoutMs', options.timeoutMs ?? 10_000);