diff --git a/.changeset/17785-tracing-otel-exporter-duration-units.md b/.changeset/17785-tracing-otel-exporter-duration-units.md new file mode 100644 index 0000000000..ab053b6829 --- /dev/null +++ b/.changeset/17785-tracing-otel-exporter-duration-units.md @@ -0,0 +1,98 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec)!: the four `system/tracing.zod.ts` duration keys carry their unit in the key name (#17785, ruling A on #15939) + + + +**BREAKING** — the OTel exporter deadline, the batch processor's two knobs and the background +span-export period now carry `Ms` in the key name. + +| | before | after | +|:--|:--|:--| +| `OpenTelemetryCompatibility.exporter` | `timeout: 10000` | `timeoutMs: 10000` | +| `OpenTelemetryCompatibility.exporter.batch` | `exportTimeout: 30000` | `exportTimeoutMs: 30000` | +| `OpenTelemetryCompatibility.exporter.batch` | `scheduledDelay: 5000` | `scheduledDelayMs: 5000` | +| `TracingConfig.performance` | `exportInterval: 5000` | `exportIntervalMs: 5000` | +| values, defaults, bounds | ms; 10000 / 30000 / 5000 / 5000; `int().positive()` | **unchanged** | + +## Migration + +```diff + const otel = OpenTelemetryCompatibilitySchema.parse({ + exporter: { + type: 'otlp_grpc', +- timeout: 10000, ++ timeoutMs: 10000, + batch: { +- exportTimeout: 30000, +- scheduledDelay: 5000, ++ exportTimeoutMs: 30000, ++ scheduledDelayMs: 5000, + }, + }, + resource: { serviceName: 'api-server' }, + }); + + const tracing = TracingConfigSchema.parse({ + name: 'default_tracing', + label: 'Default Tracing', +- performance: { exportInterval: 5000 }, ++ performance: { exportIntervalMs: 5000 }, + }); +``` + +Rename the keys. Every value is the same number of milliseconds it always was, the +10000 / 30000 / 5000 / 5000 defaults are unchanged, and nothing else on either def moves. + +## Why + +Each key named milliseconds in a source JSDoc — "Timeout in milliseconds", "Export timeout in +milliseconds", "Scheduled delay in milliseconds", "Background export interval in milliseconds" — +and the JSDoc above a key is not what `content/docs/references/**` renders; `.describe()` is. +Measured on this tree: all four carried **no `.describe()` at all**, so the published reference +row for each was a bare integer with no unit anywhere on the page. That is a strictly worse +channel than the unit-in-prose shape #14478 already refuses — here the reference reader had no +prose to misread. All four magnitudes read plausibly in both units (10000, 30000, 5000, 5000), +and an operator who reads seconds sets an exporter deadline 1000x short. Executes director-seat +ruling A on #15939 (2026-09-11, maintainer 「同意」, decision batch #115), the per-file +remediation of the #14478 rule, and closes the last of that ruling's seven cards. + +The suffix is the family's own spelling, counted in key position across `packages/spec/src`: +281 `*Ms` declarations over 42 distinct names, `timeoutMs` 65 of them and `intervalMs` 14, +against **0** key-position `timeoutSeconds`. The Delay-plus-`Ms` pairing is likewise already +attested (`maxDelayMs`, `initialDelayMs`, `retryDelayMs`, `delayMs`, `debounceDelayMs`) with no +competing `scheduledDelay` spelling anywhere. This file is milliseconds throughout and its own +landed precedent is `Span.duration → durationMs` (#15679) — the opposite of the sibling metrics +card, whose rows were seconds. + +`exporter.timeoutMs` and `exporter.batch.exportTimeoutMs` deliberately sit one nesting level +apart. The pair pre-exists the rename: the `batch` sub-object is the OpenTelemetry batch span +processor's own four knobs (max batch size, max queue size, scheduled delay, export timeout) +beside the exporter's own request deadline. Renaming either to something more distinctive would +depart from the vocabulary this shape mirrors, and the nesting already disambiguates every read +point — `exporter.timeoutMs` versus `exporter.batch.exportTimeoutMs`. + +## The kit + +- a `retiredKey()` tombstone on each old spelling, so `tsc` types it `never` and a value + reaching the parse raises the rename prescription instead of being silently stripped. Neither + `OpenTelemetryCompatibilitySchema` nor `TracingConfigSchema` nor any object nested inside them + is `.strict()`, so `unrecognized_keys` was never the alternative — a bare deletion would have + landed a default on an exporter deadline and a background export period +- the ADR-0087 D3 semantic entry `system-tracing-otel-exporter-durations-unit-in-key` and four + `RETIRED_KEYS_BY_MAJOR[18]` rows. No D2 conversion: `stack.zod.ts` declares no tracing + collection, no metadata-type binding or manifest embed carries either def, and a tracing + configuration is never a stored `sys_metadata` row — so the chain has no seam that runs on + them, the same reading `system-tracing-span-duration-unit-in-key` recorded for the other key + on this file +- pin tests: a refusal pin per row asserting the issue **code** (never a bare `toThrow()`) and + the FROM → TO prescription, an acceptance pin at each retired key's magnitude with the same + default, a bounds pin, and a describe pin proving the unit now reaches the published channel +- the `authorable-surface` / `authorable-defaults` ratchets move **nothing**, and that is the + correct outcome rather than an omission: those artifacts record top-level keys per def + (`build-schemas.ts` reads `schema.properties` one level deep) and every one of these four is + nested +- `Span.duration → durationMs`'s own entry is untouched — a predecessor's scoped record stays + true, and this round's entry opens by saying how it relates to it diff --git a/content/docs/references/system/tracing.mdx b/content/docs/references/system/tracing.mdx index 7061571d30..24b2b5572a 100644 --- a/content/docs/references/system/tracing.mdx +++ b/content/docs/references/system/tracing.mdx @@ -52,9 +52,10 @@ OpenTelemetry compatibility configuration | **endpoint** | `string` | optional | Exporter endpoint | | **protocol** | `string` | optional | Protocol version | | **headers** | `Record` | optional | HTTP headers | -| **timeout** | `integer` | optional (default: `10000`) | | +| **timeoutMs** | `integer` | optional (default: `10000`) | Exporter request timeout in milliseconds | +| **timeout** | `never` | optional | [REMOVED] `OpenTelemetryCompatibility.exporter.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Its unit (milliseconds) lived in a source JSDoc only and the key carried no describe at all, so the reference-page reader got a bare 10000 and could not tell it from 10000 seconds. Rename the key to `timeoutMs`; the value (milliseconds) and the 10000 default are unchanged. | | **compression** | `Enum<'none' \| 'gzip'>` | optional (default: `"none"`) | | -| **batch** | `{ maxBatchSize: integer; maxQueueSize: integer; exportTimeout: integer; scheduledDelay: integer }` | optional | | +| **batch** | `{ maxBatchSize: integer; maxQueueSize: integer; exportTimeoutMs: integer; scheduledDelayMs: integer }` | optional | | ### Nested Shape: `OpenTelemetryCompatibility.resource` @@ -467,7 +468,7 @@ Tracing configuration | **spanLimits** | `{ maxAttributes?: integer; maxEvents?: integer; maxLinks?: integer; maxAttributeValueLength?: integer }` | optional | | | **traceIdGenerator** | `Enum<'random' \| 'uuid' \| 'custom'>` | optional (default: `"random"`) | | | **customTraceIdGeneratorId** | `string` | optional | Custom generator identifier | -| **performance** | `{ asyncExport?: boolean; exportInterval?: integer }` | optional | | +| **performance** | `{ asyncExport?: boolean; exportIntervalMs?: integer }` | optional | | ### Nested Shape: `TracingConfig.sampling` @@ -491,6 +492,14 @@ Tracing configuration | **instrumentation** | `{ autoInstrumentation?: boolean; libraries?: string[]; disabledLibraries?: string[] }` | optional | | | **semanticConventionsVersion** | `string` | optional | Semantic conventions version | +### Nested Shape: `TracingConfig.performance` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **asyncExport** | `boolean` | optional (default: `true`) | | +| **exportIntervalMs** | `integer` | optional (default: `5000`) | Background span-export interval in milliseconds | +| **exportInterval** | `never` | optional | [REMOVED] `TracingConfig.performance.exportInterval` was renamed to `exportIntervalMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Its unit (milliseconds) lived in a source JSDoc only and the key carried no describe at all, so the reference-page reader got a bare 5000. Rename the key to `exportIntervalMs`; the value (milliseconds) and the 5000 default are unchanged. | + --- diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__OpenTelemetryCompatibility__exporter.batch.exportTimeout.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__OpenTelemetryCompatibility__exporter.batch.exportTimeout.ts new file mode 100644 index 0000000000..4e2b3593a9 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__OpenTelemetryCompatibility__exporter.batch.exportTimeout.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15939 ruling A (per-file remediation of #14478). `exporter.batch.exportTimeout` +// said "Export timeout in milliseconds" in a source JSDoc and carried NO +// `.describe()` at all, so the published reference page showed a bare 30000. +// Renamed to `exportTimeoutMs`; the value and the 30000 default are unchanged. +// Tombstoned with `retiredKey()`: the nested `batch` object is not strict, so a +// bare deletion would silently strip the key. No D2 conversion: not a stack +// collection member, not a stored row. See +// `system-tracing-otel-exporter-durations-unit-in-key`. +export const entry = 'system/OpenTelemetryCompatibility:exporter.batch.exportTimeout'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__OpenTelemetryCompatibility__exporter.batch.scheduledDelay.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__OpenTelemetryCompatibility__exporter.batch.scheduledDelay.ts new file mode 100644 index 0000000000..664a14f8ee --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__OpenTelemetryCompatibility__exporter.batch.scheduledDelay.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15939 ruling A (per-file remediation of #14478). `exporter.batch.scheduledDelay` +// said "Scheduled delay in milliseconds" in a source JSDoc and carried NO +// `.describe()` at all, so the published reference page showed a bare 5000. +// Renamed to `scheduledDelayMs`, the plain suffix: counted in key position across +// `packages/spec/src` at `98bd7986fe`, the Delay-plus-Ms pairing is already the +// family spelling (`maxDelayMs` 9, `initialDelayMs` 9, `maxRetryDelayMs` 5, +// `debounceDelayMs` 2, `delayMs` 2, `retryDelayMs` 1) and neither +// `scheduledDelayMs` nor `scheduledDelaySeconds` occurs there at all. +// The value and the 5000 default are unchanged. Tombstoned with `retiredKey()`: +// the nested `batch` object is not strict, so a bare deletion would silently +// strip the key. No D2 conversion: not a stack collection member, not a stored +// row. See `system-tracing-otel-exporter-durations-unit-in-key`. +export const entry = 'system/OpenTelemetryCompatibility:exporter.batch.scheduledDelay'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__OpenTelemetryCompatibility__exporter.timeout.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__OpenTelemetryCompatibility__exporter.timeout.ts new file mode 100644 index 0000000000..ba6799aadc --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__OpenTelemetryCompatibility__exporter.timeout.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15939 ruling A (per-file remediation of #14478). `exporter.timeout` said +// "Timeout in milliseconds" in a source JSDoc and carried NO `.describe()` at +// all, so the `content/docs/references/**` page published a bare 10000 with no +// unit anywhere on it. Renamed to `timeoutMs`; the value and the 10000 default +// are unchanged. It keeps the plain suffix even though its `exporter.batch` +// sibling becomes `exportTimeoutMs`: the two are the OpenTelemetry exporter's +// own request deadline and the batch processor's export deadline, two distinct +// knobs the shape already spelled apart, and the nesting keeps every read point +// unambiguous. Tombstoned with `retiredKey()`: the nested `exporter` object is +// not strict, so a bare deletion would silently strip the key and let a 10000 ms +// default land on an exporter deadline the operator had deliberately changed. +// No D2 conversion: not a stack collection member, not a stored row. See +// `system-tracing-otel-exporter-durations-unit-in-key`. +export const entry = 'system/OpenTelemetryCompatibility:exporter.timeout'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__TracingConfig__performance.exportInterval.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__TracingConfig__performance.exportInterval.ts new file mode 100644 index 0000000000..f3c86e014d --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__TracingConfig__performance.exportInterval.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15939 ruling A (per-file remediation of #14478). `performance.exportInterval` +// said "Background export interval in milliseconds" in a source JSDoc and +// carried NO `.describe()` at all, so the published reference page showed a bare +// 5000. Renamed to `exportIntervalMs`; the value and the 5000 default are +// unchanged. `intervalMs` is the family spelling already attested 14 times in key +// position across `packages/spec/src` at `98bd7986fe` (0 `exportIntervalMs` and 0 +// `exportIntervalSeconds` there, so nothing competes), so the suffix lands on a +// name the surface already uses. +// Tombstoned with `retiredKey()`: the nested `performance` object is not strict, +// so a bare deletion would silently strip the key and hand a background exporter +// its default period. No D2 conversion: not a stack collection member, not a +// stored row. See `system-tracing-otel-exporter-durations-unit-in-key`. +export const entry = 'system/TracingConfig:performance.exportInterval'; diff --git a/packages/spec/src/migrations/entries/semantic/18.system-tracing-otel-exporter-durations-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.system-tracing-otel-exporter-durations-unit-in-key.ts new file mode 100644 index 0000000000..4f65e36916 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.system-tracing-otel-exporter-durations-unit-in-key.ts @@ -0,0 +1,83 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'system-tracing-otel-exporter-durations-unit-in-key', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: 'the four tracing-configuration durations whose unit lived in a source JSDoc only: ' + + 'OpenTelemetryCompatibility.exporter.timeout, ' + + 'OpenTelemetryCompatibility.exporter.batch.exportTimeout, ' + + 'OpenTelemetryCompatibility.exporter.batch.scheduledDelay and ' + + 'TracingConfig.performance.exportInterval (system/tracing.zod.ts)', + replacement: 'timeoutMs, exportTimeoutMs, scheduledDelayMs and exportIntervalMs — rename each ' + + 'key; all four values (milliseconds) and their 10000 / 30000 / 5000 / 5000 defaults are ' + + 'unchanged', + reason: + 'Director-seat ruling A on #15939, 2026-09-11, carrying the maintainer\'s 「同意」 (decision ' + + 'batch #115), executing the #14478 rule per file. It follows ' + + 'system-tracing-span-duration-unit-in-key on this same file and does not amend it: that ' + + 'entry retired Span.duration under ruling B, whose population was the describe channel, ' + + 'and these four keys were never in it — they are the JSDoc-only channel #15939 opened, ' + + 'which is why one file carries two rounds. Each key named milliseconds in its JSDoc — ' + + '"Timeout in milliseconds", "Export timeout in milliseconds", "Scheduled delay in ' + + 'milliseconds", "Background export interval in milliseconds" — and the JSDoc above a key ' + + 'is NOT what content/docs/references/** renders; .describe() is. Measured on this tree: ' + + 'all four carried NO .describe() at all, so the published reference row for each was a ' + + 'bare integer with no unit anywhere on the page — a strictly worse channel than the ' + + 'unit-in-prose shape #14478 already refuses, since here the reference reader had no prose ' + + 'to misread. The magnitudes make the guess plausible in both directions: 10000, 30000, ' + + '5000 and 5000 are all defensible as seconds and as milliseconds, and an operator who ' + + 'reads seconds sets an exporter deadline 1000x short. The suffix is the family spelling, ' + + 'counted in key position at 98bd7986fe over packages/spec/src *.ts (reproduce with ' + + 'git grep -hoE on that ref): 281 *Ms declarations over 42 distinct names, timeoutMs 65 of ' + + 'them and intervalMs 14, against 0 key-position timeoutSeconds and 77 *Seconds of any ' + + 'name; the Delay-plus-Ms pairing is likewise already attested on that same ref ' + + '(maxDelayMs 9, initialDelayMs 9, maxRetryDelayMs 5, debounceDelayMs 2, delayMs 2, ' + + 'retryDelayMs 1) with 0 occurrences of any competing exportTimeout, scheduledDelay or ' + + 'exportInterval spelling, suffixed or Seconds. Note this file is milliseconds throughout ' + + 'and its own landed precedent is ' + + 'Span.duration to durationMs, the opposite of the sibling metrics card whose rows were ' + + 'seconds. exporter.timeoutMs and exporter.batch.exportTimeoutMs are deliberately allowed ' + + 'to sit one nesting level apart: the pair pre-exists the rename — the batch sub-object is ' + + 'the OpenTelemetry batch span processor\'s own four knobs (max batch size, max queue ' + + 'size, scheduled delay, export timeout) beside the exporter\'s own request deadline — so ' + + 'renaming either to something more distinctive would depart from the vocabulary the shape ' + + 'mirrors, and the nesting already disambiguates every read point ' + + '(exporter.timeoutMs vs exporter.batch.exportTimeoutMs). All four old spellings are ' + + 'retiredKey() tombstones: neither OpenTelemetryCompatibilitySchema nor TracingConfigSchema ' + + 'nor any object nested inside them is .strict(), so a bare deletion would be a SILENT ' + + 'STRIP (#3733, ADR-0104) — and the stripped value lands on an export deadline and a ' + + 'background export period. Why a semantic entry and not a D2 conversion: the conversion ' + + 'chain walks a normalized STACK, and neither def is an authorable surface — stack.zod.ts ' + + 'declares no tracing collection, no metadata-type binding or manifest embed carries ' + + 'either, and a tracing configuration is never a stored sys_metadata row — so a conversion ' + + 'would be a transform with no seam that ever runs. That is the same disposition ' + + 'system-tracing-span-duration-unit-in-key recorded for the other key on this file. ' + + 'Measured at 98bd7986fe: NO in-repo reader exists outside packages/spec — ' + + 'OpenTelemetryCompatibility, TracingConfig and all three batch key names occur 0 times ' + + 'across the whole tree at that ref excluding packages/spec and content/docs/references, ' + + 'against a lit control of 18920 Schema occurrences on exactly that corpus and ref — both ' + + 'counts from one git grep -o over 98bd7986fe with those two pathspec exclusions — and a ' + + 'dark control of 0; inside packages/spec the ' + + 'only occurrences are tracing.zod.ts, its test, and the generated rows in ' + + 'content/docs/references/system/tracing.mdx, which this rename regenerates. And the ' + + 'pinned objectui checkout — `.objectui-sha` = `53ded82bf7a494f54e344e19099dbf00854b8694` — names none of it: all 37 exports of ' + + 'tracing.zod.ts and each of the four key names occur 0 times across the 6409 files ' + + 'tracked at that sha (the 404 Span and 40 SpanSchema hits are objectui\'s own HTML ' + + 'text-span component, TextSpanSchema, an unrelated name), against two lit controls on ' + + 'that same corpus and sha: 10171 hits for the bare token objectstack, and 3479 for the ' + + 'package specifier @objectstack/spec.', + acceptanceCriteria: + 'Every author and reader of an OpenTelemetryCompatibility spells exporter.timeoutMs, ' + + 'exporter.batch.exportTimeoutMs and exporter.batch.scheduledDelayMs, and every one of a ' + + 'TracingConfig spells performance.exportIntervalMs. Authoring any old spelling fails to ' + + 'compile (input type `never`) and fails to parse with the rename prescription naming the ' + + 'suffixed key — not with a generic unrecognized_keys issue, which these non-strict shapes ' + + 'could never have raised anyway. Behaviour is unchanged: the same milliseconds, the same ' + + '10000 / 30000 / 5000 / 5000 defaults and the same int().positive() bounds, and all four ' + + 'published describes now name milliseconds where before there was no describe at all. ' + + 'The authorable-surface and authorable-defaults ledgers move nothing: every one of the ' + + 'four is NESTED, and those artifacts record top-level keys per def only.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index c7a7d1be24..c0dfb672c5 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -10845,6 +10845,85 @@ const step18: MigrationStep = { + 'one to check by hand rather than by search-and-replace: after the migration a reader ' + 'can tell at the authoring site that 300 and 30000 are not the same kind of number.', }, + { + id: 'system-tracing-otel-exporter-durations-unit-in-key', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: 'the four tracing-configuration durations whose unit lived in a source JSDoc only: ' + + 'OpenTelemetryCompatibility.exporter.timeout, ' + + 'OpenTelemetryCompatibility.exporter.batch.exportTimeout, ' + + 'OpenTelemetryCompatibility.exporter.batch.scheduledDelay and ' + + 'TracingConfig.performance.exportInterval (system/tracing.zod.ts)', + replacement: 'timeoutMs, exportTimeoutMs, scheduledDelayMs and exportIntervalMs — rename each ' + + 'key; all four values (milliseconds) and their 10000 / 30000 / 5000 / 5000 defaults are ' + + 'unchanged', + reason: + 'Director-seat ruling A on #15939, 2026-09-11, carrying the maintainer\'s 「同意」 (decision ' + + 'batch #115), executing the #14478 rule per file. It follows ' + + 'system-tracing-span-duration-unit-in-key on this same file and does not amend it: that ' + + 'entry retired Span.duration under ruling B, whose population was the describe channel, ' + + 'and these four keys were never in it — they are the JSDoc-only channel #15939 opened, ' + + 'which is why one file carries two rounds. Each key named milliseconds in its JSDoc — ' + + '"Timeout in milliseconds", "Export timeout in milliseconds", "Scheduled delay in ' + + 'milliseconds", "Background export interval in milliseconds" — and the JSDoc above a key ' + + 'is NOT what content/docs/references/** renders; .describe() is. Measured on this tree: ' + + 'all four carried NO .describe() at all, so the published reference row for each was a ' + + 'bare integer with no unit anywhere on the page — a strictly worse channel than the ' + + 'unit-in-prose shape #14478 already refuses, since here the reference reader had no prose ' + + 'to misread. The magnitudes make the guess plausible in both directions: 10000, 30000, ' + + '5000 and 5000 are all defensible as seconds and as milliseconds, and an operator who ' + + 'reads seconds sets an exporter deadline 1000x short. The suffix is the family spelling, ' + + 'counted in key position at 98bd7986fe over packages/spec/src *.ts (reproduce with ' + + 'git grep -hoE on that ref): 281 *Ms declarations over 42 distinct names, timeoutMs 65 of ' + + 'them and intervalMs 14, against 0 key-position timeoutSeconds and 77 *Seconds of any ' + + 'name; the Delay-plus-Ms pairing is likewise already attested on that same ref ' + + '(maxDelayMs 9, initialDelayMs 9, maxRetryDelayMs 5, debounceDelayMs 2, delayMs 2, ' + + 'retryDelayMs 1) with 0 occurrences of any competing exportTimeout, scheduledDelay or ' + + 'exportInterval spelling, suffixed or Seconds. Note this file is milliseconds throughout ' + + 'and its own landed precedent is ' + + 'Span.duration to durationMs, the opposite of the sibling metrics card whose rows were ' + + 'seconds. exporter.timeoutMs and exporter.batch.exportTimeoutMs are deliberately allowed ' + + 'to sit one nesting level apart: the pair pre-exists the rename — the batch sub-object is ' + + 'the OpenTelemetry batch span processor\'s own four knobs (max batch size, max queue ' + + 'size, scheduled delay, export timeout) beside the exporter\'s own request deadline — so ' + + 'renaming either to something more distinctive would depart from the vocabulary the shape ' + + 'mirrors, and the nesting already disambiguates every read point ' + + '(exporter.timeoutMs vs exporter.batch.exportTimeoutMs). All four old spellings are ' + + 'retiredKey() tombstones: neither OpenTelemetryCompatibilitySchema nor TracingConfigSchema ' + + 'nor any object nested inside them is .strict(), so a bare deletion would be a SILENT ' + + 'STRIP (#3733, ADR-0104) — and the stripped value lands on an export deadline and a ' + + 'background export period. Why a semantic entry and not a D2 conversion: the conversion ' + + 'chain walks a normalized STACK, and neither def is an authorable surface — stack.zod.ts ' + + 'declares no tracing collection, no metadata-type binding or manifest embed carries ' + + 'either, and a tracing configuration is never a stored sys_metadata row — so a conversion ' + + 'would be a transform with no seam that ever runs. That is the same disposition ' + + 'system-tracing-span-duration-unit-in-key recorded for the other key on this file. ' + + 'Measured at 98bd7986fe: NO in-repo reader exists outside packages/spec — ' + + 'OpenTelemetryCompatibility, TracingConfig and all three batch key names occur 0 times ' + + 'across the whole tree at that ref excluding packages/spec and content/docs/references, ' + + 'against a lit control of 18920 Schema occurrences on exactly that corpus and ref — both ' + + 'counts from one git grep -o over 98bd7986fe with those two pathspec exclusions — and a ' + + 'dark control of 0; inside packages/spec the ' + + 'only occurrences are tracing.zod.ts, its test, and the generated rows in ' + + 'content/docs/references/system/tracing.mdx, which this rename regenerates. And the ' + + 'pinned objectui checkout — `.objectui-sha` = `53ded82bf7a494f54e344e19099dbf00854b8694` — names none of it: all 37 exports of ' + + 'tracing.zod.ts and each of the four key names occur 0 times across the 6409 files ' + + 'tracked at that sha (the 404 Span and 40 SpanSchema hits are objectui\'s own HTML ' + + 'text-span component, TextSpanSchema, an unrelated name), against two lit controls on ' + + 'that same corpus and sha: 10171 hits for the bare token objectstack, and 3479 for the ' + + 'package specifier @objectstack/spec.', + acceptanceCriteria: + 'Every author and reader of an OpenTelemetryCompatibility spells exporter.timeoutMs, ' + + 'exporter.batch.exportTimeoutMs and exporter.batch.scheduledDelayMs, and every one of a ' + + 'TracingConfig spells performance.exportIntervalMs. Authoring any old spelling fails to ' + + 'compile (input type `never`) and fails to parse with the rename prescription naming the ' + + 'suffixed key — not with a generic unrecognized_keys issue, which these non-strict shapes ' + + 'could never have raised anyway. Behaviour is unchanged: the same milliseconds, the same ' + + '10000 / 30000 / 5000 / 5000 defaults and the same int().positive() bounds, and all four ' + + 'published describes now name milliseconds where before there was no describe at all. ' + + 'The authorable-surface and authorable-defaults ledgers move nothing: every one of the ' + + 'four is NESTED, and those artifacts record top-level keys per def only.', + }, { id: 'system-tracing-span-duration-unit-in-key', surface: 'Span.duration, the emitted trace-span length whose name carried no unit ' @@ -13770,6 +13849,42 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // stored metadata row. // See `system-metrics-jsdoc-durations-unit-in-key`. 'system/MetricsConfig:retention.period', + // #15939 ruling A (per-file remediation of #14478). `exporter.batch.exportTimeout` + // said "Export timeout in milliseconds" in a source JSDoc and carried NO + // `.describe()` at all, so the published reference page showed a bare 30000. + // Renamed to `exportTimeoutMs`; the value and the 30000 default are unchanged. + // Tombstoned with `retiredKey()`: the nested `batch` object is not strict, so a + // bare deletion would silently strip the key. No D2 conversion: not a stack + // collection member, not a stored row. See + // `system-tracing-otel-exporter-durations-unit-in-key`. + 'system/OpenTelemetryCompatibility:exporter.batch.exportTimeout', + // #15939 ruling A (per-file remediation of #14478). `exporter.batch.scheduledDelay` + // said "Scheduled delay in milliseconds" in a source JSDoc and carried NO + // `.describe()` at all, so the published reference page showed a bare 5000. + // Renamed to `scheduledDelayMs`, the plain suffix: counted in key position across + // `packages/spec/src` at `98bd7986fe`, the Delay-plus-Ms pairing is already the + // family spelling (`maxDelayMs` 9, `initialDelayMs` 9, `maxRetryDelayMs` 5, + // `debounceDelayMs` 2, `delayMs` 2, `retryDelayMs` 1) and neither + // `scheduledDelayMs` nor `scheduledDelaySeconds` occurs there at all. + // The value and the 5000 default are unchanged. Tombstoned with `retiredKey()`: + // the nested `batch` object is not strict, so a bare deletion would silently + // strip the key. No D2 conversion: not a stack collection member, not a stored + // row. See `system-tracing-otel-exporter-durations-unit-in-key`. + 'system/OpenTelemetryCompatibility:exporter.batch.scheduledDelay', + // #15939 ruling A (per-file remediation of #14478). `exporter.timeout` said + // "Timeout in milliseconds" in a source JSDoc and carried NO `.describe()` at + // all, so the `content/docs/references/**` page published a bare 10000 with no + // unit anywhere on it. Renamed to `timeoutMs`; the value and the 10000 default + // are unchanged. It keeps the plain suffix even though its `exporter.batch` + // sibling becomes `exportTimeoutMs`: the two are the OpenTelemetry exporter's + // own request deadline and the batch processor's export deadline, two distinct + // knobs the shape already spelled apart, and the nesting keeps every read point + // unambiguous. Tombstoned with `retiredKey()`: the nested `exporter` object is + // not strict, so a bare deletion would silently strip the key and let a 10000 ms + // default land on an exporter deadline the operator had deliberately changed. + // No D2 conversion: not a stack collection member, not a stored row. See + // `system-tracing-otel-exporter-durations-unit-in-key`. + 'system/OpenTelemetryCompatibility:exporter.timeout', // #15679 (stack card 4/6 of #14478) — ruling B. `QueueConfig.rateLimit.duration` // said "Duration in milliseconds" in prose and nothing else — while // `TaskResult.durationMs`, ninety lines earlier in the SAME file, already spelled @@ -13909,6 +14024,19 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // connection is host configuration, not a stored metadata row. // See `system-object-storage-durations-unit-in-key`. 'system/StorageConnection:timeout', + // #15939 ruling A (per-file remediation of #14478). `performance.exportInterval` + // said "Background export interval in milliseconds" in a source JSDoc and + // carried NO `.describe()` at all, so the published reference page showed a bare + // 5000. Renamed to `exportIntervalMs`; the value and the 5000 default are + // unchanged. `intervalMs` is the family spelling already attested 14 times in key + // position across `packages/spec/src` at `98bd7986fe` (0 `exportIntervalMs` and 0 + // `exportIntervalSeconds` there, so nothing competes), so the suffix lands on a + // name the surface already uses. + // Tombstoned with `retiredKey()`: the nested `performance` object is not strict, + // so a bare deletion would silently strip the key and hand a background exporter + // its default period. No D2 conversion: not a stack collection member, not a + // stored row. See `system-tracing-otel-exporter-durations-unit-in-key`. + 'system/TracingConfig:performance.exportInterval', // #14477 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-02, ruled A: // retire per family). One of the hour/minute/day-shaped deadline keys of the // incident-response / training / change-management families: declared on the diff --git a/packages/spec/src/system/tracing.test.ts b/packages/spec/src/system/tracing.test.ts index 0b1c814c78..29d2db8c7c 100644 --- a/packages/spec/src/system/tracing.test.ts +++ b/packages/spec/src/system/tracing.test.ts @@ -412,8 +412,8 @@ describe('OpenTelemetryCompatibilitySchema', () => { batch: { maxBatchSize: 1024, maxQueueSize: 4096, - exportTimeout: 60000, - scheduledDelay: 10000, + exportTimeoutMs: 60000, + scheduledDelayMs: 10000, }, }, resource: { @@ -434,7 +434,7 @@ describe('OpenTelemetryCompatibilitySchema', () => { }, }); - expect(config.exporter.timeout).toBe(10000); + expect(config.exporter.timeoutMs).toBe(10000); expect(config.exporter.compression).toBe('none'); }); }); @@ -542,11 +542,148 @@ describe('Span.duration carries its unit (#15679)', () => { expect(SpanSchema.safeParse({ ...base, durationMs: -1 }).success).toBe(false); }); - it('leaves the OTel exporter `timeout` alone — its describe names no unit, so it is outside the population', () => { - const config = OpenTelemetryCompatibilitySchema.parse({ - exporter: { type: 'console' }, - resource: { serviceName: 'test' }, +}); + +// #17785, ruling A on #15939 (per-file remediation of #14478). The four tracing +// durations whose unit lived in a source JSDoc only — and whose `.describe()` +// did not exist at all, so the published reference row was a bare integer. +// +// This block REPLACES, and relocates, the guard that used to close the #15679 +// describe above: `it('leaves the OTel exporter timeout alone — its describe +// names no unit, so it is outside the population')`. That pin was written to +// catch exactly this sweep, so it succeeds by failing: its key, its "names no +// unit" clause and its "outside the population" clause all go false here. It is +// replaced by the three-part shape #15679 itself set on this file — a refusal +// pin asserting the issue CODE and the prescription (never a bare `toThrow()`), +// an acceptance pin at the same magnitude and the same default, and a describe +// pin proving the unit now reaches the published channel — and moved out of a +// describe headed `Span.duration carries its unit`, which is not its subject. +// +// These shapes are NOT `.strict()`, so `unrecognized_keys` was never the +// alternative: a bare deletion would have been a silent strip that lands a +// default on an exporter deadline. +describe('the OTel exporter and performance durations carry their unit (#17785)', () => { + const otelBase = { + exporter: { type: 'console' as const }, + resource: { serviceName: 'test' }, + }; + const tracingBase = { name: 'test_tracing', label: 'Test Tracing' }; + + /** + * `describe()` is what `content/docs/references/**` publishes; JSDoc is not. + * + * Descends by `shape`, unwrapping wrappers (`.optional()`, `.default()`) only + * to reach a CHILD — never on the leaf, whose `description` `.describe()` set + * on the outermost node and which an unwrap would discard. + */ + const shapeOf = (node: unknown): Record | undefined => { + let cur = node as { shape?: Record; def?: { innerType?: unknown } }; + while (cur && !cur.shape && cur.def?.innerType) cur = cur.def.innerType as typeof cur; + return cur?.shape; + }; + const describeOf = (schema: unknown, path: readonly string[]): string | undefined => { + let cursor: unknown = schema; + for (const segment of path) { + const shape = shapeOf(cursor); + if (!shape) return undefined; + cursor = shape[segment]; + } + return (cursor as { description?: string } | undefined)?.description; + }; + + it.each([ + ['exporter.timeout', 'timeoutMs'], + ['exporter.batch.exportTimeout', 'exportTimeoutMs'], + ['exporter.batch.scheduledDelay', 'scheduledDelayMs'], + ] as const)('REFUSES the retired `%s` with the rename in the message', (retired, renamed) => { + const leaf = retired.split('.').pop()!; + const body = retired.startsWith('exporter.batch') + ? { ...otelBase, exporter: { ...otelBase.exporter, batch: { [leaf]: 1234 } } } + : { ...otelBase, exporter: { ...otelBase.exporter, [leaf]: 1234 } }; + const result = OpenTelemetryCompatibilitySchema.safeParse(body); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === `${retired}`); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain( + `\`OpenTelemetryCompatibility.${retired}\` was renamed to \`${renamed}\``, + ); + }); + + it('REFUSES the retired `performance.exportInterval` with the rename in the message', () => { + const result = TracingConfigSchema.safeParse({ + ...tracingBase, + performance: { exportInterval: 1234 }, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues + .find((i) => i.path.join('.') === 'performance.exportInterval'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain( + '`TracingConfig.performance.exportInterval` was renamed to `exportIntervalMs`', + ); + }); + + it('accepts the suffixed keys at the same magnitudes, and still applies the same defaults', () => { + const authored = OpenTelemetryCompatibilitySchema.parse({ + ...otelBase, + exporter: { + ...otelBase.exporter, + timeoutMs: 10000, + batch: { exportTimeoutMs: 60000, scheduledDelayMs: 10000 }, + }, + }); + expect(authored.exporter.timeoutMs).toBe(10000); + expect(authored.exporter.batch?.exportTimeoutMs).toBe(60000); + expect(authored.exporter.batch?.scheduledDelayMs).toBe(10000); + + // The defaults the rename must not move: 10000 / 30000 / 5000. + const defaulted = OpenTelemetryCompatibilitySchema.parse({ + ...otelBase, + exporter: { ...otelBase.exporter, batch: {} }, }); - expect(config.exporter.timeout).toBe(10000); + expect(defaulted.exporter.timeoutMs).toBe(10000); + expect(defaulted.exporter.batch?.exportTimeoutMs).toBe(30000); + expect(defaulted.exporter.batch?.scheduledDelayMs).toBe(5000); + + const tracing = TracingConfigSchema.parse({ ...tracingBase, performance: {} }); + expect(tracing.performance?.exportIntervalMs).toBe(5000); + expect( + TracingConfigSchema.parse({ + ...tracingBase, + performance: { exportIntervalMs: 250 }, + }).performance?.exportIntervalMs, + ).toBe(250); + }); + + it('still refuses a non-positive value on each renamed key', () => { + expect(OpenTelemetryCompatibilitySchema.safeParse({ + ...otelBase, + exporter: { ...otelBase.exporter, timeoutMs: 0 }, + }).success).toBe(false); + expect(OpenTelemetryCompatibilitySchema.safeParse({ + ...otelBase, + exporter: { ...otelBase.exporter, batch: { exportTimeoutMs: -1 } }, + }).success).toBe(false); + expect(OpenTelemetryCompatibilitySchema.safeParse({ + ...otelBase, + exporter: { ...otelBase.exporter, batch: { scheduledDelayMs: 0 } }, + }).success).toBe(false); + expect(TracingConfigSchema.safeParse({ + ...tracingBase, + performance: { exportIntervalMs: -1 }, + }).success).toBe(false); + }); + + it('publishes the unit in the `describe()` the reference pages render', () => { + expect(describeOf(OpenTelemetryCompatibilitySchema, ['exporter', 'timeoutMs'])) + .toBe('Exporter request timeout in milliseconds'); + expect(describeOf(OpenTelemetryCompatibilitySchema, ['exporter', 'batch', 'exportTimeoutMs'])) + .toBe('Batch export timeout in milliseconds'); + expect(describeOf(OpenTelemetryCompatibilitySchema, ['exporter', 'batch', 'scheduledDelayMs'])) + .toBe('Delay between scheduled batch exports, in milliseconds'); + expect(describeOf(TracingConfigSchema, ['performance', 'exportIntervalMs'])) + .toBe('Background span-export interval in milliseconds'); }); }); diff --git a/packages/spec/src/system/tracing.zod.ts b/packages/spec/src/system/tracing.zod.ts index 976315c6c7..632a43965e 100644 --- a/packages/spec/src/system/tracing.zod.ts +++ b/packages/spec/src/system/tracing.zod.ts @@ -536,9 +536,25 @@ export const OpenTelemetryCompatibilitySchema = lazySchema(() => z.object({ headers: z.record(z.string(), z.string()).optional().describe('HTTP headers'), /** - * Timeout in milliseconds - */ - timeout: z.number().int().positive().optional().default(10000), + * Per-export request deadline, in milliseconds. + * + * Renamed from `timeout` (#17785, ruling A on #15939 executing #14478): + * the unit lived in this JSDoc only and the key carried no `.describe()` + * at all, so the reference page published a bare 10000. Tombstoned rather + * than deleted because this nested object is not `.strict()`. + */ + timeoutMs: z.number().int().positive().optional().default(10000) + .describe('Exporter request timeout in milliseconds'), + + /** Tombstone for the rename above (#17785, ruling A on #15939). */ + timeout: retiredKey( + '`OpenTelemetryCompatibility.exporter.timeout` was renamed to `timeoutMs` in ' + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, ' + + 'not only in the describe prose. Its unit (milliseconds) lived in a source JSDoc ' + + 'only and the key carried no describe at all, so the reference-page reader got a ' + + 'bare 10000 and could not tell it from 10000 seconds. Rename the key to `timeoutMs`; ' + + 'the value (milliseconds) and the 10000 default are unchanged.', + ), /** * Compression @@ -560,14 +576,44 @@ export const OpenTelemetryCompatibilitySchema = lazySchema(() => z.object({ maxQueueSize: z.number().int().positive().optional().default(2048), /** - * Export timeout in milliseconds + * Batch-processor export deadline, in milliseconds. + * + * Renamed from `exportTimeout` (#17785, ruling A on #15939 executing + * #14478): the unit lived in this JSDoc only and the key carried no + * `.describe()` at all. Tombstoned rather than deleted because this + * nested object is not `.strict()`. */ - exportTimeout: z.number().int().positive().optional().default(30000), + exportTimeoutMs: z.number().int().positive().optional().default(30000) + .describe('Batch export timeout in milliseconds'), /** - * Scheduled delay in milliseconds + * Delay between two scheduled batch exports, in milliseconds. + * + * Renamed from `scheduledDelay` (#17785, ruling A on #15939 executing + * #14478): the unit lived in this JSDoc only and the key carried no + * `.describe()` at all. Tombstoned rather than deleted because this + * nested object is not `.strict()`. */ - scheduledDelay: z.number().int().positive().optional().default(5000), + scheduledDelayMs: z.number().int().positive().optional().default(5000) + .describe('Delay between scheduled batch exports, in milliseconds'), + + /** Tombstones for the two renames above (#17785, ruling A on #15939). */ + exportTimeout: retiredKey( + '`OpenTelemetryCompatibility.exporter.batch.exportTimeout` was renamed to ' + + '`exportTimeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number ' + + 'lives in the key name, not only in the describe prose. Its unit (milliseconds) ' + + 'lived in a source JSDoc only and the key carried no describe at all, so the ' + + 'reference-page reader got a bare 30000. Rename the key to `exportTimeoutMs`; the ' + + 'value (milliseconds) and the 30000 default are unchanged.', + ), + scheduledDelay: retiredKey( + '`OpenTelemetryCompatibility.exporter.batch.scheduledDelay` was renamed to ' + + '`scheduledDelayMs` in @objectstack/spec 17 — the unit of a duration-shaped number ' + + 'lives in the key name, not only in the describe prose. Its unit (milliseconds) ' + + 'lived in a source JSDoc only and the key carried no describe at all, so the ' + + 'reference-page reader got a bare 5000. Rename the key to `scheduledDelayMs`; the ' + + 'value (milliseconds) and the 5000 default are unchanged.', + ), }).optional(), }).describe('Exporter configuration'), @@ -718,9 +764,25 @@ export const TracingConfigSchema = lazySchema(() => z.object({ asyncExport: z.boolean().optional().default(true), /** - * Background export interval in milliseconds - */ - exportInterval: z.number().int().positive().optional().default(5000), + * Background span-export interval, in milliseconds. + * + * Renamed from `exportInterval` (#17785, ruling A on #15939 executing + * #14478): the unit lived in this JSDoc only and the key carried no + * `.describe()` at all. Tombstoned rather than deleted because this + * nested object is not `.strict()`. + */ + exportIntervalMs: z.number().int().positive().optional().default(5000) + .describe('Background span-export interval in milliseconds'), + + /** Tombstone for the rename above (#17785, ruling A on #15939). */ + exportInterval: retiredKey( + '`TracingConfig.performance.exportInterval` was renamed to `exportIntervalMs` in ' + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, ' + + 'not only in the describe prose. Its unit (milliseconds) lived in a source JSDoc ' + + 'only and the key carried no describe at all, so the reference-page reader got a ' + + 'bare 5000. Rename the key to `exportIntervalMs`; the value (milliseconds) and the ' + + '5000 default are unchanged.', + ), }).optional(), }).describe('Tracing configuration'));