Skip to content
98 changes: 98 additions & 0 deletions .changeset/17785-tracing-otel-exporter-duration-units.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: registered system-tracing-otel-exporter-durations-unit-in-key -->

**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
15 changes: 12 additions & 3 deletions content/docs/references/system/tracing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,10 @@ OpenTelemetry compatibility configuration
| **endpoint** | `string` | optional | Exporter endpoint |
| **protocol** | `string` | optional | Protocol version |
| **headers** | `Record<string, string>` | 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`

Expand Down Expand Up @@ -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`

Expand All @@ -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. |


---

Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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.',
};
Loading
Loading