Skip to content

[core] Fix time parsing for region-tagged run IDs - #2943

Merged
VaguelySerious merged 4 commits into
mainfrom
peter/ttfs-runid-tag-bit
Jul 15, 2026
Merged

[core] Fix time parsing for region-tagged run IDs#2943
VaguelySerious merged 4 commits into
mainfrom
peter/ttfs-runid-tag-bit

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented Jul 15, 2026

Copy link
Copy Markdown
Member

Summary

TTFS reported exactly 0 for every run with a region-tagged run ID (#1981 rollout). Run-ID tagging schemes (World.createRunId(), e.g. world-vercel's region-tagged IDs) set the MSB of the ULID's 48-bit timestamp as a tag bit. runIdCreatedAt() — the TTFS anchor — decoded it as part of the creation time, placing the anchor ~4,460 years in the future, so Math.max(0, end − anchor) clamped every sample to 0. In production, the entire TTFS distribution (p50 through max) for the affected cohort has been exactly 0 since Jul 14 ~08:30 UTC; untagged cohorts report real values throughout.

  • runIdCreatedAt() now clears the tag bit before use (a set MSB can never be a real creation time).
  • computeStepLatencyEventData() now drops samples whose raw duration is more negative than a plausible clock skew (60 s) instead of clamping — a future anchor corruption will surface as missing data, not an all-zero distribution silently dragging every percentile down.

Testing

  • run-id-time.test.ts: tagged-ULID decode (tag bit cleared).
  • step-latency.test.ts: corrupt-anchor samples dropped (TTFS-only and mixed TTFS/STSO cases); small-skew clamping unchanged.
  • runtime.test.ts: end-to-end latency drive with a region-tagged run ID asserts TTFS ≈ run age (the exact production regression shape).

🤖 Generated with Claude Code

Also included

The multi-region e2e lane failed deterministically on this PR with workflow executed in hnd1, tagged icn1. This is the geo-elastic execution locality the test's EXECUTION_ADJACENCY map already documents (kix1-tagged runs observed executing in hnd1/Tokyo) — production lifecycle metrics show icn1-tagged runs executing in hnd1 about half the time over the last 6h, with tagging/data placement remaining strictly icn1. Added hnd1 to icn1's tolerated execution set.

…version
Two fixes to the step latency telemetry, both surfaced by production data:
1. TTFS reported exactly 0 for every run with a region-tagged run ID.
Tagging schemes set the MSB of the ULID's 48-bit timestamp as a tag
bit, which runIdCreatedAt() decoded as part of the creation time —
placing the TTFS anchor millennia in the future so every sample
clamped to 0. The tag bit is now cleared before use, and durations
more negative than a plausible clock skew drop the sample entirely
instead of clamping, so a future anchor corruption surfaces as
missing data rather than an all-zero distribution.
2. Latency samples now carry the @workflow/core version that computed
them (eventData.sdkVersion, lifted into the v4 frame meta). The
transport user-agent identifies the HTTP client package, which
resolves independently of core in consumer lockfiles, so it cannot
attribute measurements to the runtime code that produced them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@VaguelySerious
VaguelySerious requested review from a team and ijjk as code ownersJuly 15, 2026 17:56
@vercel

vercelBot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 15, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8a7f1cf

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actionsBot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 8a7f1cf · Wed, 15 Jul 2026 19:57:24 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1356 (+5.3%)1714 🔴1792 🔴1911 🔴30
TTFShook + stream1539 (+7.3%)1959 🔴2055 🔴2155 🔴30
STSO1020 steps (1-20)299 (+8.9%)337 🔴381 🔴505 🔴19
STSO1020 steps (101-120)462 (+10%)496 🔴664 🔴758 🔴19
STSO1020 steps (1001-1020)971 (+16%)1029 🔴1235 🔴1291 🔴19
WOstream1356 (+5.3%)17141792191130
WOhook + stream1539 (+7.3%)19592055215530
SLstream4432 (-9.8%)5029 🔴5800 🔴6079 🔴30
SLhook + stream5086 (-0.9%)5598 🔴5685 🔴5920 🔴30
📜 Previous results (2)

0d300e8

Wed, 15 Jul 2026 19:33:17 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1285 (±0%)1842 🔴1927 🔴2211 🔴30
TTFShook + stream1722 (+20%)2088 🔴2228 🔴2457 🔴30
STSO1020 steps (1-20)376 (+37%)372 🔴679 🔴1205 🔴19
STSO1020 steps (101-120)454 (+8.2%)479 🔴623 🔴757 🔴19
STSO1020 steps (1001-1020)879 (+5.2%)946 🔴1016 🔴1053 🔴19
WOstream1285 (±0%)18421927221130
WOhook + stream1722 (+20%)20882228245730
SLstream4728 (-3.8%)5657 🔴5729 🔴6087 🔴30
SLhook + stream4884 (-4.8%)5494 🔴5649 🔴5830 🔴30

461aae4

Wed, 15 Jul 2026 18:24:40 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1507 (+17%)1791 🔴1840 🔴2486 🔴30
TTFShook + stream1703 (+19%)2090 🔴2139 🔴2332 🔴30
STSO1020 steps (1-20)308 (+12%)339 🔴468 🔴540 🔴19
STSO1020 steps (101-120)412 (-1.7%)423 🔴544 🔴546 🔴19
STSO1020 steps (1001-1020)971 (+16%)964 🔴1105 🔴2031 🔴19
WOstream1507 (+17%)17911840248630
WOhook + stream1703 (+19%)20902139233230
SLstream4887 (-0.5%)5499 🔴5767 🔴5984 🔴30
SLhook + stream4843 (-5.6%)5424 🔴5763 🔴5903 🔴30

Avg deltas compare against the most recent benchmark run on main at the time of this run.

Metrics — TTFS: time to first step body execution · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (time outside step bodies, client start → last step body exit) · SL: stream latency (first chunk write → visible to the reader)

Scenarios — stream: one step that streams chunks back to the client; no hooks, so the run stays in turbo mode · hook + stream: registers a hook before the same streaming step, which exits turbo mode · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges

🟢/🔴 mark percentiles within/above target. Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

TTFS/WO compare client vs deployment clocks and SL compares the step runner’s clock vs the client’s (NTP-synced in CI). WO ends at the last step body exit, the closest observable proxy for the final step-completion request.

@github-actions

github-actionsBot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
❌ ▲ Vercel Production145212301683
✅ 💻 Local Development161702191836
✅ 📦 Local Production161702191836
✅ 🐘 Local Postgres161702191836
✅ 🪟 Windows15300153
✅ 📋 Other89401771071
✅ vercel-multi-region270027
Total7377110648442

❌ Failed Tests

▲ Vercel Production (1 failed)

fastify (1 failed):

Details by Category

❌ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro126027
✅ example126027
✅ express126027
❌ fastify125127
✅ hono126027
✅ nextjs-turbopack15003
✅ nextjs-webpack15003
✅ nitro126027
✅ nuxt126027
✅ sveltekit14508
✅ vite126027
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15300
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128025
✅ e2e-local-dev-tanstack-start-128025
✅ e2e-local-postgres-nest-stable128025
✅ e2e-local-postgres-tanstack-start-128025
✅ e2e-local-prod-nest-stable128025
✅ e2e-local-prod-tanstack-start-128025
✅ e2e-vercel-prod-tanstack-start126027
✅ vercel-multi-region
AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: failure
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: success
  • Windows: success

Check the workflow run for details.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both fixes are well-motivated and the diagnosis writeup is excellent. I verified the mechanics independently:

  • ulid@3.0.1's decodeTime bound is TIME_MAX = 2^48 - 1, so a tagged timestamp (real time + 2^47) decodes without throwing and the subtract-after-guard in runIdCreatedAt is correct. Using subtraction rather than a bitwise mask also matters here since JS bitwise ops truncate at 32 bits. ✅
  • Drop-instead-of-clamp is the right failure mode: a future multi-bit tagging scheme that the MSB-clear doesn't fully handle would surface as missing samples via the MAX_CLOCK_SKEW_MS guard rather than an all-zero distribution. ✅
  • v4 wire compat: parseV4EventMeta in workflow-server picks known fields and ignores unknown meta keys, so older servers tolerate the new sdkVersion frame meta. ✅
  • version.ts is genversion-generated in build/typecheck/dev scripts, and release builds run after the changeset version bump, so the reported version is correct on published artifacts. ✅

One blocking issue on release ordering for the sdkVersion field (inline).

? { eventCount: tracking.eventCount }
: {}),
optimizations,
sdkVersion: coreVersion,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking (release ordering, not this code): core attaches sdkVersion to step-terminal eventData unconditionally, but workflow-server's persistence strip list (CLIENT_LATENCY_TELEMETRY_FIELDS in lib/data/events.ts) is ['ttfs','stso','stepCount','eventCount','optimizations'] and does not include sdkVersion, and snapshotClientLatencyTelemetry doesn't consume it either.

On the v4 wire this is safe (world-vercel moves it to frame meta; the server ignores unknown meta fields and never copies meta into eventData). But on the v2/v3 wire, eventData is sent raw, so sdkVersion would be persisted into the stored, user-visible event's eventData, violating the server's documented invariant that latency telemetry is "aggregate telemetry, not run state" and stays out of event reads and DynamoDB. The affected cohort is old @workflow/world-vercel + new @workflow/core resolved independently in a consumer lockfile, which is exactly the mixed-version scenario this PR's motivation describes (unless the old v3 client path zod-strips unknown eventData keys before POSTing; I couldn't rule that in or out from the current repo since the v3 implementation was replaced).

Since merging here auto-releases via changesets, can we land and deploy the server-side counterpart first (add sdkVersion to the strip list + pick it up in snapshotClientLatencyTelemetry/metric tags)? That both closes the leak and makes the field actually consumable, so the release order becomes server-then-client and this PR merges as-is.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry this wasn't ready for review yet, should have made it a draft PR. The core version stuff is something the AI add unilaterally, removed now

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed after the sdkVersion removal — my previous blocking item is resolved, and the remaining diff is the TTFS fix I already verified mechanically (ulid decodeTime accepts tagged timestamps since TIME_MAX = 2^48 - 1; subtraction rather than a 32-bit-truncating bitwise mask; drop-instead-of-clamp surfaces future anchor corruption as missing data instead of a zeroed distribution). Test coverage is thorough at all three layers (codec, compute, runtime e2e).

The EXECUTION_ADJACENCY addition (icn1 tolerating hnd1) is a reasonable geo-elastic tolerance — Seoul→Tokyo is nearer than the already-listed syd1.

One recommendation inline about delegating the tag-bit knowledge to @workflow/world-vercel/run-id's decode() — non-blocking since the current code is correct, but worth doing before merge while the file is hot.

try {
return decodeTime(ulidPart);
const time = decodeTime(ulidPart);
return time >= TIMESTAMP_TAG_BIT ? time - TIMESTAMP_TAG_BIT : time;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommendation (non-blocking): consider delegating the tag-bit handling to the scheme's own codec instead of hand-rolling the clear here:

import{decodeTime}from'ulid';import{decode}from'@workflow/world-vercel/run-id';// in the try block:returndecodeTime(decode(ulidPart).ulid);

decode() already returns the ULID with the tag bit cleared, throws on malformed input (your existing catch covers that), and @workflow/core already depends on @workflow/world-vercel, so this adds no new edge (run-id is a leaf module, no cycle).

The reason it matters: the tagging scheme carries a 5-bit version field, i.e. it explicitly anticipates layout evolution. If a future version moves bits around, decode() in world-vercel gets updated as the single source of truth and this anchor stays correct automatically, whereas a local 2^47 subtraction can silently diverge. The MAX_CLOCK_SKEW_MS drop guard you added would at least turn that divergence into missing data rather than bad data, which is why I'm not calling this blocking — but single-sourcing the codec removes the failure mode entirely.

If you'd rather keep core free of scheme knowledge as a matter of principle, the current MSB-clear is defensible too (a set MSB can never be a real timestamp under any scheme) — in that case maybe just soften the doc comment's implication that MSB-tagging is a general World.createRunId() convention, since today it's specifically world-vercel's.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 8a7f1cfrunIdCreatedAt now delegates to decode() from @workflow/world-vercel/run-id (verified: the dependency already exists in core's dependencies, ./run-id is an exported subpath, and world-vercel has no dependency back on core, so no cycle). The hand-rolled TIMESTAMP_TAG_BIT constant is gone; the doc comment now points at the codec as the single source of truth. Tests (34) and typecheck pass.

Review feedback: instead of hand-rolling the timestamp tag-bit clear
(2^47 subtraction), use decode() from @workflow/world-vercel/run-id,
which returns the ULID with the tag bit cleared and throws on malformed
input. The tagging scheme carries a 5-bit version field, so if the
layout ever evolves the codec stays the single source of truth and this
anchor tracks it automatically.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@VaguelySerious
VaguelySerious merged commit fd107b9 into mainJul 15, 2026
102 of 104 checks passed
@github-actionsgithub-actionsBot mentioned this pull request Jul 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for fd107b9 (AI decision).

This fixes a bug in the TTFS/STSO step-latency telemetry feature, which is main-only: the touched source files (packages/core/src/runtime/run-id-time.ts and step-latency.ts) and their tests do not exist on stable, and stable's runtime.test.ts contains no latency-telemetry tests to attach the runtime.test.ts change to. Since the corrected functionality is entirely absent from stable, there is nothing to backport.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

fd107b9c33db397b513ef134f458a1083bde7d98

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@VaguelySerious@TooTallNate
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
[core] Fix time parsing for region-tagged run IDs by VaguelySerious · Pull Request #2943 · vercel/workflow · GitHub
Skip to content

[core] Fix time parsing for region-tagged run IDs - #2943

Merged
VaguelySerious merged 4 commits into
mainfrom
peter/ttfs-runid-tag-bit
Jul 15, 2026
Merged

[core] Fix time parsing for region-tagged run IDs#2943
VaguelySerious merged 4 commits into
mainfrom
peter/ttfs-runid-tag-bit

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented Jul 15, 2026

Copy link
Copy Markdown
Member

Summary

TTFS reported exactly 0 for every run with a region-tagged run ID (#1981 rollout). Run-ID tagging schemes (World.createRunId(), e.g. world-vercel's region-tagged IDs) set the MSB of the ULID's 48-bit timestamp as a tag bit. runIdCreatedAt() — the TTFS anchor — decoded it as part of the creation time, placing the anchor ~4,460 years in the future, so Math.max(0, end − anchor) clamped every sample to 0. In production, the entire TTFS distribution (p50 through max) for the affected cohort has been exactly 0 since Jul 14 ~08:30 UTC; untagged cohorts report real values throughout.

  • runIdCreatedAt() now clears the tag bit before use (a set MSB can never be a real creation time).
  • computeStepLatencyEventData() now drops samples whose raw duration is more negative than a plausible clock skew (60 s) instead of clamping — a future anchor corruption will surface as missing data, not an all-zero distribution silently dragging every percentile down.

Testing

  • run-id-time.test.ts: tagged-ULID decode (tag bit cleared).
  • step-latency.test.ts: corrupt-anchor samples dropped (TTFS-only and mixed TTFS/STSO cases); small-skew clamping unchanged.
  • runtime.test.ts: end-to-end latency drive with a region-tagged run ID asserts TTFS ≈ run age (the exact production regression shape).

🤖 Generated with Claude Code

Also included

The multi-region e2e lane failed deterministically on this PR with workflow executed in hnd1, tagged icn1. This is the geo-elastic execution locality the test's EXECUTION_ADJACENCY map already documents (kix1-tagged runs observed executing in hnd1/Tokyo) — production lifecycle metrics show icn1-tagged runs executing in hnd1 about half the time over the last 6h, with tagging/data placement remaining strictly icn1. Added hnd1 to icn1's tolerated execution set.

…version
Two fixes to the step latency telemetry, both surfaced by production data:
1. TTFS reported exactly 0 for every run with a region-tagged run ID.
Tagging schemes set the MSB of the ULID's 48-bit timestamp as a tag
bit, which runIdCreatedAt() decoded as part of the creation time —
placing the TTFS anchor millennia in the future so every sample
clamped to 0. The tag bit is now cleared before use, and durations
more negative than a plausible clock skew drop the sample entirely
instead of clamping, so a future anchor corruption surfaces as
missing data rather than an all-zero distribution.
2. Latency samples now carry the @workflow/core version that computed
them (eventData.sdkVersion, lifted into the v4 frame meta). The
transport user-agent identifies the HTTP client package, which
resolves independently of core in consumer lockfiles, so it cannot
attribute measurements to the runtime code that produced them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@VaguelySerious
VaguelySerious requested review from a team and ijjk as code ownersJuly 15, 2026 17:56
@vercel

vercelBot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 15, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8a7f1cf

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actionsBot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 8a7f1cf · Wed, 15 Jul 2026 19:57:24 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1356 (+5.3%)1714 🔴1792 🔴1911 🔴30
TTFShook + stream1539 (+7.3%)1959 🔴2055 🔴2155 🔴30
STSO1020 steps (1-20)299 (+8.9%)337 🔴381 🔴505 🔴19
STSO1020 steps (101-120)462 (+10%)496 🔴664 🔴758 🔴19
STSO1020 steps (1001-1020)971 (+16%)1029 🔴1235 🔴1291 🔴19
WOstream1356 (+5.3%)17141792191130
WOhook + stream1539 (+7.3%)19592055215530
SLstream4432 (-9.8%)5029 🔴5800 🔴6079 🔴30
SLhook + stream5086 (-0.9%)5598 🔴5685 🔴5920 🔴30
📜 Previous results (2)

0d300e8

Wed, 15 Jul 2026 19:33:17 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1285 (±0%)1842 🔴1927 🔴2211 🔴30
TTFShook + stream1722 (+20%)2088 🔴2228 🔴2457 🔴30
STSO1020 steps (1-20)376 (+37%)372 🔴679 🔴1205 🔴19
STSO1020 steps (101-120)454 (+8.2%)479 🔴623 🔴757 🔴19
STSO1020 steps (1001-1020)879 (+5.2%)946 🔴1016 🔴1053 🔴19
WOstream1285 (±0%)18421927221130
WOhook + stream1722 (+20%)20882228245730
SLstream4728 (-3.8%)5657 🔴5729 🔴6087 🔴30
SLhook + stream4884 (-4.8%)5494 🔴5649 🔴5830 🔴30

461aae4

Wed, 15 Jul 2026 18:24:40 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1507 (+17%)1791 🔴1840 🔴2486 🔴30
TTFShook + stream1703 (+19%)2090 🔴2139 🔴2332 🔴30
STSO1020 steps (1-20)308 (+12%)339 🔴468 🔴540 🔴19
STSO1020 steps (101-120)412 (-1.7%)423 🔴544 🔴546 🔴19
STSO1020 steps (1001-1020)971 (+16%)964 🔴1105 🔴2031 🔴19
WOstream1507 (+17%)17911840248630
WOhook + stream1703 (+19%)20902139233230
SLstream4887 (-0.5%)5499 🔴5767 🔴5984 🔴30
SLhook + stream4843 (-5.6%)5424 🔴5763 🔴5903 🔴30

Avg deltas compare against the most recent benchmark run on main at the time of this run.

Metrics — TTFS: time to first step body execution · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (time outside step bodies, client start → last step body exit) · SL: stream latency (first chunk write → visible to the reader)

Scenarios — stream: one step that streams chunks back to the client; no hooks, so the run stays in turbo mode · hook + stream: registers a hook before the same streaming step, which exits turbo mode · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges

🟢/🔴 mark percentiles within/above target. Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

TTFS/WO compare client vs deployment clocks and SL compares the step runner’s clock vs the client’s (NTP-synced in CI). WO ends at the last step body exit, the closest observable proxy for the final step-completion request.

@github-actions

github-actionsBot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
❌ ▲ Vercel Production145212301683
✅ 💻 Local Development161702191836
✅ 📦 Local Production161702191836
✅ 🐘 Local Postgres161702191836
✅ 🪟 Windows15300153
✅ 📋 Other89401771071
✅ vercel-multi-region270027
Total7377110648442

❌ Failed Tests

▲ Vercel Production (1 failed)

fastify (1 failed):

Details by Category

❌ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro126027
✅ example126027
✅ express126027
❌ fastify125127
✅ hono126027
✅ nextjs-turbopack15003
✅ nextjs-webpack15003
✅ nitro126027
✅ nuxt126027
✅ sveltekit14508
✅ vite126027
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15300
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128025
✅ e2e-local-dev-tanstack-start-128025
✅ e2e-local-postgres-nest-stable128025
✅ e2e-local-postgres-tanstack-start-128025
✅ e2e-local-prod-nest-stable128025
✅ e2e-local-prod-tanstack-start-128025
✅ e2e-vercel-prod-tanstack-start126027
✅ vercel-multi-region
AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: failure
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: success
  • Windows: success

Check the workflow run for details.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both fixes are well-motivated and the diagnosis writeup is excellent. I verified the mechanics independently:

  • ulid@3.0.1's decodeTime bound is TIME_MAX = 2^48 - 1, so a tagged timestamp (real time + 2^47) decodes without throwing and the subtract-after-guard in runIdCreatedAt is correct. Using subtraction rather than a bitwise mask also matters here since JS bitwise ops truncate at 32 bits. ✅
  • Drop-instead-of-clamp is the right failure mode: a future multi-bit tagging scheme that the MSB-clear doesn't fully handle would surface as missing samples via the MAX_CLOCK_SKEW_MS guard rather than an all-zero distribution. ✅
  • v4 wire compat: parseV4EventMeta in workflow-server picks known fields and ignores unknown meta keys, so older servers tolerate the new sdkVersion frame meta. ✅
  • version.ts is genversion-generated in build/typecheck/dev scripts, and release builds run after the changeset version bump, so the reported version is correct on published artifacts. ✅

One blocking issue on release ordering for the sdkVersion field (inline).

? { eventCount: tracking.eventCount }
: {}),
optimizations,
sdkVersion: coreVersion,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking (release ordering, not this code): core attaches sdkVersion to step-terminal eventData unconditionally, but workflow-server's persistence strip list (CLIENT_LATENCY_TELEMETRY_FIELDS in lib/data/events.ts) is ['ttfs','stso','stepCount','eventCount','optimizations'] and does not include sdkVersion, and snapshotClientLatencyTelemetry doesn't consume it either.

On the v4 wire this is safe (world-vercel moves it to frame meta; the server ignores unknown meta fields and never copies meta into eventData). But on the v2/v3 wire, eventData is sent raw, so sdkVersion would be persisted into the stored, user-visible event's eventData, violating the server's documented invariant that latency telemetry is "aggregate telemetry, not run state" and stays out of event reads and DynamoDB. The affected cohort is old @workflow/world-vercel + new @workflow/core resolved independently in a consumer lockfile, which is exactly the mixed-version scenario this PR's motivation describes (unless the old v3 client path zod-strips unknown eventData keys before POSTing; I couldn't rule that in or out from the current repo since the v3 implementation was replaced).

Since merging here auto-releases via changesets, can we land and deploy the server-side counterpart first (add sdkVersion to the strip list + pick it up in snapshotClientLatencyTelemetry/metric tags)? That both closes the leak and makes the field actually consumable, so the release order becomes server-then-client and this PR merges as-is.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry this wasn't ready for review yet, should have made it a draft PR. The core version stuff is something the AI add unilaterally, removed now

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed after the sdkVersion removal — my previous blocking item is resolved, and the remaining diff is the TTFS fix I already verified mechanically (ulid decodeTime accepts tagged timestamps since TIME_MAX = 2^48 - 1; subtraction rather than a 32-bit-truncating bitwise mask; drop-instead-of-clamp surfaces future anchor corruption as missing data instead of a zeroed distribution). Test coverage is thorough at all three layers (codec, compute, runtime e2e).

The EXECUTION_ADJACENCY addition (icn1 tolerating hnd1) is a reasonable geo-elastic tolerance — Seoul→Tokyo is nearer than the already-listed syd1.

One recommendation inline about delegating the tag-bit knowledge to @workflow/world-vercel/run-id's decode() — non-blocking since the current code is correct, but worth doing before merge while the file is hot.

try {
return decodeTime(ulidPart);
const time = decodeTime(ulidPart);
return time >= TIMESTAMP_TAG_BIT ? time - TIMESTAMP_TAG_BIT : time;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommendation (non-blocking): consider delegating the tag-bit handling to the scheme's own codec instead of hand-rolling the clear here:

import{decodeTime}from'ulid';import{decode}from'@workflow/world-vercel/run-id';// in the try block:returndecodeTime(decode(ulidPart).ulid);

decode() already returns the ULID with the tag bit cleared, throws on malformed input (your existing catch covers that), and @workflow/core already depends on @workflow/world-vercel, so this adds no new edge (run-id is a leaf module, no cycle).

The reason it matters: the tagging scheme carries a 5-bit version field, i.e. it explicitly anticipates layout evolution. If a future version moves bits around, decode() in world-vercel gets updated as the single source of truth and this anchor stays correct automatically, whereas a local 2^47 subtraction can silently diverge. The MAX_CLOCK_SKEW_MS drop guard you added would at least turn that divergence into missing data rather than bad data, which is why I'm not calling this blocking — but single-sourcing the codec removes the failure mode entirely.

If you'd rather keep core free of scheme knowledge as a matter of principle, the current MSB-clear is defensible too (a set MSB can never be a real timestamp under any scheme) — in that case maybe just soften the doc comment's implication that MSB-tagging is a general World.createRunId() convention, since today it's specifically world-vercel's.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 8a7f1cfrunIdCreatedAt now delegates to decode() from @workflow/world-vercel/run-id (verified: the dependency already exists in core's dependencies, ./run-id is an exported subpath, and world-vercel has no dependency back on core, so no cycle). The hand-rolled TIMESTAMP_TAG_BIT constant is gone; the doc comment now points at the codec as the single source of truth. Tests (34) and typecheck pass.

Review feedback: instead of hand-rolling the timestamp tag-bit clear
(2^47 subtraction), use decode() from @workflow/world-vercel/run-id,
which returns the ULID with the tag bit cleared and throws on malformed
input. The tagging scheme carries a 5-bit version field, so if the
layout ever evolves the codec stays the single source of truth and this
anchor tracks it automatically.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@VaguelySerious
VaguelySerious merged commit fd107b9 into mainJul 15, 2026
102 of 104 checks passed
@github-actionsgithub-actionsBot mentioned this pull request Jul 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for fd107b9 (AI decision).

This fixes a bug in the TTFS/STSO step-latency telemetry feature, which is main-only: the touched source files (packages/core/src/runtime/run-id-time.ts and step-latency.ts) and their tests do not exist on stable, and stable's runtime.test.ts contains no latency-telemetry tests to attach the runtime.test.ts change to. Since the corrected functionality is entirely absent from stable, there is nothing to backport.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

fd107b9c33db397b513ef134f458a1083bde7d98

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@VaguelySerious@TooTallNate
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [core] Fix time parsing for region-tagged run IDs by VaguelySerious · Pull Request #2943 · vercel/workflow · GitHub
Skip to content

[core] Fix time parsing for region-tagged run IDs - #2943

Merged
VaguelySerious merged 4 commits into
mainfrom
peter/ttfs-runid-tag-bit
Jul 15, 2026
Merged

[core] Fix time parsing for region-tagged run IDs#2943
VaguelySerious merged 4 commits into
mainfrom
peter/ttfs-runid-tag-bit

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented Jul 15, 2026

Copy link
Copy Markdown
Member

Summary

TTFS reported exactly 0 for every run with a region-tagged run ID (#1981 rollout). Run-ID tagging schemes (World.createRunId(), e.g. world-vercel's region-tagged IDs) set the MSB of the ULID's 48-bit timestamp as a tag bit. runIdCreatedAt() — the TTFS anchor — decoded it as part of the creation time, placing the anchor ~4,460 years in the future, so Math.max(0, end − anchor) clamped every sample to 0. In production, the entire TTFS distribution (p50 through max) for the affected cohort has been exactly 0 since Jul 14 ~08:30 UTC; untagged cohorts report real values throughout.

  • runIdCreatedAt() now clears the tag bit before use (a set MSB can never be a real creation time).
  • computeStepLatencyEventData() now drops samples whose raw duration is more negative than a plausible clock skew (60 s) instead of clamping — a future anchor corruption will surface as missing data, not an all-zero distribution silently dragging every percentile down.

Testing

  • run-id-time.test.ts: tagged-ULID decode (tag bit cleared).
  • step-latency.test.ts: corrupt-anchor samples dropped (TTFS-only and mixed TTFS/STSO cases); small-skew clamping unchanged.
  • runtime.test.ts: end-to-end latency drive with a region-tagged run ID asserts TTFS ≈ run age (the exact production regression shape).

🤖 Generated with Claude Code

Also included

The multi-region e2e lane failed deterministically on this PR with workflow executed in hnd1, tagged icn1. This is the geo-elastic execution locality the test's EXECUTION_ADJACENCY map already documents (kix1-tagged runs observed executing in hnd1/Tokyo) — production lifecycle metrics show icn1-tagged runs executing in hnd1 about half the time over the last 6h, with tagging/data placement remaining strictly icn1. Added hnd1 to icn1's tolerated execution set.

…version
Two fixes to the step latency telemetry, both surfaced by production data:
1. TTFS reported exactly 0 for every run with a region-tagged run ID.
Tagging schemes set the MSB of the ULID's 48-bit timestamp as a tag
bit, which runIdCreatedAt() decoded as part of the creation time —
placing the TTFS anchor millennia in the future so every sample
clamped to 0. The tag bit is now cleared before use, and durations
more negative than a plausible clock skew drop the sample entirely
instead of clamping, so a future anchor corruption surfaces as
missing data rather than an all-zero distribution.
2. Latency samples now carry the @workflow/core version that computed
them (eventData.sdkVersion, lifted into the v4 frame meta). The
transport user-agent identifies the HTTP client package, which
resolves independently of core in consumer lockfiles, so it cannot
attribute measurements to the runtime code that produced them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@VaguelySerious
VaguelySerious requested review from a team and ijjk as code ownersJuly 15, 2026 17:56
@vercel

vercelBot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 15, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8a7f1cf

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actionsBot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 8a7f1cf · Wed, 15 Jul 2026 19:57:24 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1356 (+5.3%)1714 🔴1792 🔴1911 🔴30
TTFShook + stream1539 (+7.3%)1959 🔴2055 🔴2155 🔴30
STSO1020 steps (1-20)299 (+8.9%)337 🔴381 🔴505 🔴19
STSO1020 steps (101-120)462 (+10%)496 🔴664 🔴758 🔴19
STSO1020 steps (1001-1020)971 (+16%)1029 🔴1235 🔴1291 🔴19
WOstream1356 (+5.3%)17141792191130
WOhook + stream1539 (+7.3%)19592055215530
SLstream4432 (-9.8%)5029 🔴5800 🔴6079 🔴30
SLhook + stream5086 (-0.9%)5598 🔴5685 🔴5920 🔴30
📜 Previous results (2)

0d300e8

Wed, 15 Jul 2026 19:33:17 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1285 (±0%)1842 🔴1927 🔴2211 🔴30
TTFShook + stream1722 (+20%)2088 🔴2228 🔴2457 🔴30
STSO1020 steps (1-20)376 (+37%)372 🔴679 🔴1205 🔴19
STSO1020 steps (101-120)454 (+8.2%)479 🔴623 🔴757 🔴19
STSO1020 steps (1001-1020)879 (+5.2%)946 🔴1016 🔴1053 🔴19
WOstream1285 (±0%)18421927221130
WOhook + stream1722 (+20%)20882228245730
SLstream4728 (-3.8%)5657 🔴5729 🔴6087 🔴30
SLhook + stream4884 (-4.8%)5494 🔴5649 🔴5830 🔴30

461aae4

Wed, 15 Jul 2026 18:24:40 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1507 (+17%)1791 🔴1840 🔴2486 🔴30
TTFShook + stream1703 (+19%)2090 🔴2139 🔴2332 🔴30
STSO1020 steps (1-20)308 (+12%)339 🔴468 🔴540 🔴19
STSO1020 steps (101-120)412 (-1.7%)423 🔴544 🔴546 🔴19
STSO1020 steps (1001-1020)971 (+16%)964 🔴1105 🔴2031 🔴19
WOstream1507 (+17%)17911840248630
WOhook + stream1703 (+19%)20902139233230
SLstream4887 (-0.5%)5499 🔴5767 🔴5984 🔴30
SLhook + stream4843 (-5.6%)5424 🔴5763 🔴5903 🔴30

Avg deltas compare against the most recent benchmark run on main at the time of this run.

Metrics — TTFS: time to first step body execution · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (time outside step bodies, client start → last step body exit) · SL: stream latency (first chunk write → visible to the reader)

Scenarios — stream: one step that streams chunks back to the client; no hooks, so the run stays in turbo mode · hook + stream: registers a hook before the same streaming step, which exits turbo mode · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges

🟢/🔴 mark percentiles within/above target. Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

TTFS/WO compare client vs deployment clocks and SL compares the step runner’s clock vs the client’s (NTP-synced in CI). WO ends at the last step body exit, the closest observable proxy for the final step-completion request.

@github-actions

github-actionsBot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
❌ ▲ Vercel Production145212301683
✅ 💻 Local Development161702191836
✅ 📦 Local Production161702191836
✅ 🐘 Local Postgres161702191836
✅ 🪟 Windows15300153
✅ 📋 Other89401771071
✅ vercel-multi-region270027
Total7377110648442

❌ Failed Tests

▲ Vercel Production (1 failed)

fastify (1 failed):

Details by Category

❌ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro126027
✅ example126027
✅ express126027
❌ fastify125127
✅ hono126027
✅ nextjs-turbopack15003
✅ nextjs-webpack15003
✅ nitro126027
✅ nuxt126027
✅ sveltekit14508
✅ vite126027
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15300
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128025
✅ e2e-local-dev-tanstack-start-128025
✅ e2e-local-postgres-nest-stable128025
✅ e2e-local-postgres-tanstack-start-128025
✅ e2e-local-prod-nest-stable128025
✅ e2e-local-prod-tanstack-start-128025
✅ e2e-vercel-prod-tanstack-start126027
✅ vercel-multi-region
AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: failure
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: success
  • Windows: success

Check the workflow run for details.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both fixes are well-motivated and the diagnosis writeup is excellent. I verified the mechanics independently:

  • ulid@3.0.1's decodeTime bound is TIME_MAX = 2^48 - 1, so a tagged timestamp (real time + 2^47) decodes without throwing and the subtract-after-guard in runIdCreatedAt is correct. Using subtraction rather than a bitwise mask also matters here since JS bitwise ops truncate at 32 bits. ✅
  • Drop-instead-of-clamp is the right failure mode: a future multi-bit tagging scheme that the MSB-clear doesn't fully handle would surface as missing samples via the MAX_CLOCK_SKEW_MS guard rather than an all-zero distribution. ✅
  • v4 wire compat: parseV4EventMeta in workflow-server picks known fields and ignores unknown meta keys, so older servers tolerate the new sdkVersion frame meta. ✅
  • version.ts is genversion-generated in build/typecheck/dev scripts, and release builds run after the changeset version bump, so the reported version is correct on published artifacts. ✅

One blocking issue on release ordering for the sdkVersion field (inline).

? { eventCount: tracking.eventCount }
: {}),
optimizations,
sdkVersion: coreVersion,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking (release ordering, not this code): core attaches sdkVersion to step-terminal eventData unconditionally, but workflow-server's persistence strip list (CLIENT_LATENCY_TELEMETRY_FIELDS in lib/data/events.ts) is ['ttfs','stso','stepCount','eventCount','optimizations'] and does not include sdkVersion, and snapshotClientLatencyTelemetry doesn't consume it either.

On the v4 wire this is safe (world-vercel moves it to frame meta; the server ignores unknown meta fields and never copies meta into eventData). But on the v2/v3 wire, eventData is sent raw, so sdkVersion would be persisted into the stored, user-visible event's eventData, violating the server's documented invariant that latency telemetry is "aggregate telemetry, not run state" and stays out of event reads and DynamoDB. The affected cohort is old @workflow/world-vercel + new @workflow/core resolved independently in a consumer lockfile, which is exactly the mixed-version scenario this PR's motivation describes (unless the old v3 client path zod-strips unknown eventData keys before POSTing; I couldn't rule that in or out from the current repo since the v3 implementation was replaced).

Since merging here auto-releases via changesets, can we land and deploy the server-side counterpart first (add sdkVersion to the strip list + pick it up in snapshotClientLatencyTelemetry/metric tags)? That both closes the leak and makes the field actually consumable, so the release order becomes server-then-client and this PR merges as-is.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry this wasn't ready for review yet, should have made it a draft PR. The core version stuff is something the AI add unilaterally, removed now

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed after the sdkVersion removal — my previous blocking item is resolved, and the remaining diff is the TTFS fix I already verified mechanically (ulid decodeTime accepts tagged timestamps since TIME_MAX = 2^48 - 1; subtraction rather than a 32-bit-truncating bitwise mask; drop-instead-of-clamp surfaces future anchor corruption as missing data instead of a zeroed distribution). Test coverage is thorough at all three layers (codec, compute, runtime e2e).

The EXECUTION_ADJACENCY addition (icn1 tolerating hnd1) is a reasonable geo-elastic tolerance — Seoul→Tokyo is nearer than the already-listed syd1.

One recommendation inline about delegating the tag-bit knowledge to @workflow/world-vercel/run-id's decode() — non-blocking since the current code is correct, but worth doing before merge while the file is hot.

try {
return decodeTime(ulidPart);
const time = decodeTime(ulidPart);
return time >= TIMESTAMP_TAG_BIT ? time - TIMESTAMP_TAG_BIT : time;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommendation (non-blocking): consider delegating the tag-bit handling to the scheme's own codec instead of hand-rolling the clear here:

import{decodeTime}from'ulid';import{decode}from'@workflow/world-vercel/run-id';// in the try block:returndecodeTime(decode(ulidPart).ulid);

decode() already returns the ULID with the tag bit cleared, throws on malformed input (your existing catch covers that), and @workflow/core already depends on @workflow/world-vercel, so this adds no new edge (run-id is a leaf module, no cycle).

The reason it matters: the tagging scheme carries a 5-bit version field, i.e. it explicitly anticipates layout evolution. If a future version moves bits around, decode() in world-vercel gets updated as the single source of truth and this anchor stays correct automatically, whereas a local 2^47 subtraction can silently diverge. The MAX_CLOCK_SKEW_MS drop guard you added would at least turn that divergence into missing data rather than bad data, which is why I'm not calling this blocking — but single-sourcing the codec removes the failure mode entirely.

If you'd rather keep core free of scheme knowledge as a matter of principle, the current MSB-clear is defensible too (a set MSB can never be a real timestamp under any scheme) — in that case maybe just soften the doc comment's implication that MSB-tagging is a general World.createRunId() convention, since today it's specifically world-vercel's.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 8a7f1cfrunIdCreatedAt now delegates to decode() from @workflow/world-vercel/run-id (verified: the dependency already exists in core's dependencies, ./run-id is an exported subpath, and world-vercel has no dependency back on core, so no cycle). The hand-rolled TIMESTAMP_TAG_BIT constant is gone; the doc comment now points at the codec as the single source of truth. Tests (34) and typecheck pass.

Review feedback: instead of hand-rolling the timestamp tag-bit clear
(2^47 subtraction), use decode() from @workflow/world-vercel/run-id,
which returns the ULID with the tag bit cleared and throws on malformed
input. The tagging scheme carries a 5-bit version field, so if the
layout ever evolves the codec stays the single source of truth and this
anchor tracks it automatically.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@VaguelySerious
VaguelySerious merged commit fd107b9 into mainJul 15, 2026
102 of 104 checks passed
@github-actionsgithub-actionsBot mentioned this pull request Jul 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for fd107b9 (AI decision).

This fixes a bug in the TTFS/STSO step-latency telemetry feature, which is main-only: the touched source files (packages/core/src/runtime/run-id-time.ts and step-latency.ts) and their tests do not exist on stable, and stable's runtime.test.ts contains no latency-telemetry tests to attach the runtime.test.ts change to. Since the corrected functionality is entirely absent from stable, there is nothing to backport.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

fd107b9c33db397b513ef134f458a1083bde7d98

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@VaguelySerious@TooTallNate
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [core] Fix time parsing for region-tagged run IDs by VaguelySerious · Pull Request #2943 · vercel/workflow · GitHub
Skip to content

[core] Fix time parsing for region-tagged run IDs - #2943

Merged
VaguelySerious merged 4 commits into
mainfrom
peter/ttfs-runid-tag-bit
Jul 15, 2026
Merged

[core] Fix time parsing for region-tagged run IDs#2943
VaguelySerious merged 4 commits into
mainfrom
peter/ttfs-runid-tag-bit

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented Jul 15, 2026

Copy link
Copy Markdown
Member

Summary

TTFS reported exactly 0 for every run with a region-tagged run ID (#1981 rollout). Run-ID tagging schemes (World.createRunId(), e.g. world-vercel's region-tagged IDs) set the MSB of the ULID's 48-bit timestamp as a tag bit. runIdCreatedAt() — the TTFS anchor — decoded it as part of the creation time, placing the anchor ~4,460 years in the future, so Math.max(0, end − anchor) clamped every sample to 0. In production, the entire TTFS distribution (p50 through max) for the affected cohort has been exactly 0 since Jul 14 ~08:30 UTC; untagged cohorts report real values throughout.

  • runIdCreatedAt() now clears the tag bit before use (a set MSB can never be a real creation time).
  • computeStepLatencyEventData() now drops samples whose raw duration is more negative than a plausible clock skew (60 s) instead of clamping — a future anchor corruption will surface as missing data, not an all-zero distribution silently dragging every percentile down.

Testing

  • run-id-time.test.ts: tagged-ULID decode (tag bit cleared).
  • step-latency.test.ts: corrupt-anchor samples dropped (TTFS-only and mixed TTFS/STSO cases); small-skew clamping unchanged.
  • runtime.test.ts: end-to-end latency drive with a region-tagged run ID asserts TTFS ≈ run age (the exact production regression shape).

🤖 Generated with Claude Code

Also included

The multi-region e2e lane failed deterministically on this PR with workflow executed in hnd1, tagged icn1. This is the geo-elastic execution locality the test's EXECUTION_ADJACENCY map already documents (kix1-tagged runs observed executing in hnd1/Tokyo) — production lifecycle metrics show icn1-tagged runs executing in hnd1 about half the time over the last 6h, with tagging/data placement remaining strictly icn1. Added hnd1 to icn1's tolerated execution set.

…version
Two fixes to the step latency telemetry, both surfaced by production data:
1. TTFS reported exactly 0 for every run with a region-tagged run ID.
Tagging schemes set the MSB of the ULID's 48-bit timestamp as a tag
bit, which runIdCreatedAt() decoded as part of the creation time —
placing the TTFS anchor millennia in the future so every sample
clamped to 0. The tag bit is now cleared before use, and durations
more negative than a plausible clock skew drop the sample entirely
instead of clamping, so a future anchor corruption surfaces as
missing data rather than an all-zero distribution.
2. Latency samples now carry the @workflow/core version that computed
them (eventData.sdkVersion, lifted into the v4 frame meta). The
transport user-agent identifies the HTTP client package, which
resolves independently of core in consumer lockfiles, so it cannot
attribute measurements to the runtime code that produced them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@VaguelySerious
VaguelySerious requested review from a team and ijjk as code ownersJuly 15, 2026 17:56
@vercel

vercelBot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 15, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8a7f1cf

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actionsBot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 8a7f1cf · Wed, 15 Jul 2026 19:57:24 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1356 (+5.3%)1714 🔴1792 🔴1911 🔴30
TTFShook + stream1539 (+7.3%)1959 🔴2055 🔴2155 🔴30
STSO1020 steps (1-20)299 (+8.9%)337 🔴381 🔴505 🔴19
STSO1020 steps (101-120)462 (+10%)496 🔴664 🔴758 🔴19
STSO1020 steps (1001-1020)971 (+16%)1029 🔴1235 🔴1291 🔴19
WOstream1356 (+5.3%)17141792191130
WOhook + stream1539 (+7.3%)19592055215530
SLstream4432 (-9.8%)5029 🔴5800 🔴6079 🔴30
SLhook + stream5086 (-0.9%)5598 🔴5685 🔴5920 🔴30
📜 Previous results (2)

0d300e8

Wed, 15 Jul 2026 19:33:17 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1285 (±0%)1842 🔴1927 🔴2211 🔴30
TTFShook + stream1722 (+20%)2088 🔴2228 🔴2457 🔴30
STSO1020 steps (1-20)376 (+37%)372 🔴679 🔴1205 🔴19
STSO1020 steps (101-120)454 (+8.2%)479 🔴623 🔴757 🔴19
STSO1020 steps (1001-1020)879 (+5.2%)946 🔴1016 🔴1053 🔴19
WOstream1285 (±0%)18421927221130
WOhook + stream1722 (+20%)20882228245730
SLstream4728 (-3.8%)5657 🔴5729 🔴6087 🔴30
SLhook + stream4884 (-4.8%)5494 🔴5649 🔴5830 🔴30

461aae4

Wed, 15 Jul 2026 18:24:40 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1507 (+17%)1791 🔴1840 🔴2486 🔴30
TTFShook + stream1703 (+19%)2090 🔴2139 🔴2332 🔴30
STSO1020 steps (1-20)308 (+12%)339 🔴468 🔴540 🔴19
STSO1020 steps (101-120)412 (-1.7%)423 🔴544 🔴546 🔴19
STSO1020 steps (1001-1020)971 (+16%)964 🔴1105 🔴2031 🔴19
WOstream1507 (+17%)17911840248630
WOhook + stream1703 (+19%)20902139233230
SLstream4887 (-0.5%)5499 🔴5767 🔴5984 🔴30
SLhook + stream4843 (-5.6%)5424 🔴5763 🔴5903 🔴30

Avg deltas compare against the most recent benchmark run on main at the time of this run.

Metrics — TTFS: time to first step body execution · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (time outside step bodies, client start → last step body exit) · SL: stream latency (first chunk write → visible to the reader)

Scenarios — stream: one step that streams chunks back to the client; no hooks, so the run stays in turbo mode · hook + stream: registers a hook before the same streaming step, which exits turbo mode · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges

🟢/🔴 mark percentiles within/above target. Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

TTFS/WO compare client vs deployment clocks and SL compares the step runner’s clock vs the client’s (NTP-synced in CI). WO ends at the last step body exit, the closest observable proxy for the final step-completion request.

@github-actions

github-actionsBot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
❌ ▲ Vercel Production145212301683
✅ 💻 Local Development161702191836
✅ 📦 Local Production161702191836
✅ 🐘 Local Postgres161702191836
✅ 🪟 Windows15300153
✅ 📋 Other89401771071
✅ vercel-multi-region270027
Total7377110648442

❌ Failed Tests

▲ Vercel Production (1 failed)

fastify (1 failed):

Details by Category

❌ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro126027
✅ example126027
✅ express126027
❌ fastify125127
✅ hono126027
✅ nextjs-turbopack15003
✅ nextjs-webpack15003
✅ nitro126027
✅ nuxt126027
✅ sveltekit14508
✅ vite126027
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15300
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128025
✅ e2e-local-dev-tanstack-start-128025
✅ e2e-local-postgres-nest-stable128025
✅ e2e-local-postgres-tanstack-start-128025
✅ e2e-local-prod-nest-stable128025
✅ e2e-local-prod-tanstack-start-128025
✅ e2e-vercel-prod-tanstack-start126027
✅ vercel-multi-region
AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: failure
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: success
  • Windows: success

Check the workflow run for details.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both fixes are well-motivated and the diagnosis writeup is excellent. I verified the mechanics independently:

  • ulid@3.0.1's decodeTime bound is TIME_MAX = 2^48 - 1, so a tagged timestamp (real time + 2^47) decodes without throwing and the subtract-after-guard in runIdCreatedAt is correct. Using subtraction rather than a bitwise mask also matters here since JS bitwise ops truncate at 32 bits. ✅
  • Drop-instead-of-clamp is the right failure mode: a future multi-bit tagging scheme that the MSB-clear doesn't fully handle would surface as missing samples via the MAX_CLOCK_SKEW_MS guard rather than an all-zero distribution. ✅
  • v4 wire compat: parseV4EventMeta in workflow-server picks known fields and ignores unknown meta keys, so older servers tolerate the new sdkVersion frame meta. ✅
  • version.ts is genversion-generated in build/typecheck/dev scripts, and release builds run after the changeset version bump, so the reported version is correct on published artifacts. ✅

One blocking issue on release ordering for the sdkVersion field (inline).

? { eventCount: tracking.eventCount }
: {}),
optimizations,
sdkVersion: coreVersion,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking (release ordering, not this code): core attaches sdkVersion to step-terminal eventData unconditionally, but workflow-server's persistence strip list (CLIENT_LATENCY_TELEMETRY_FIELDS in lib/data/events.ts) is ['ttfs','stso','stepCount','eventCount','optimizations'] and does not include sdkVersion, and snapshotClientLatencyTelemetry doesn't consume it either.

On the v4 wire this is safe (world-vercel moves it to frame meta; the server ignores unknown meta fields and never copies meta into eventData). But on the v2/v3 wire, eventData is sent raw, so sdkVersion would be persisted into the stored, user-visible event's eventData, violating the server's documented invariant that latency telemetry is "aggregate telemetry, not run state" and stays out of event reads and DynamoDB. The affected cohort is old @workflow/world-vercel + new @workflow/core resolved independently in a consumer lockfile, which is exactly the mixed-version scenario this PR's motivation describes (unless the old v3 client path zod-strips unknown eventData keys before POSTing; I couldn't rule that in or out from the current repo since the v3 implementation was replaced).

Since merging here auto-releases via changesets, can we land and deploy the server-side counterpart first (add sdkVersion to the strip list + pick it up in snapshotClientLatencyTelemetry/metric tags)? That both closes the leak and makes the field actually consumable, so the release order becomes server-then-client and this PR merges as-is.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry this wasn't ready for review yet, should have made it a draft PR. The core version stuff is something the AI add unilaterally, removed now

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed after the sdkVersion removal — my previous blocking item is resolved, and the remaining diff is the TTFS fix I already verified mechanically (ulid decodeTime accepts tagged timestamps since TIME_MAX = 2^48 - 1; subtraction rather than a 32-bit-truncating bitwise mask; drop-instead-of-clamp surfaces future anchor corruption as missing data instead of a zeroed distribution). Test coverage is thorough at all three layers (codec, compute, runtime e2e).

The EXECUTION_ADJACENCY addition (icn1 tolerating hnd1) is a reasonable geo-elastic tolerance — Seoul→Tokyo is nearer than the already-listed syd1.

One recommendation inline about delegating the tag-bit knowledge to @workflow/world-vercel/run-id's decode() — non-blocking since the current code is correct, but worth doing before merge while the file is hot.

try {
return decodeTime(ulidPart);
const time = decodeTime(ulidPart);
return time >= TIMESTAMP_TAG_BIT ? time - TIMESTAMP_TAG_BIT : time;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommendation (non-blocking): consider delegating the tag-bit handling to the scheme's own codec instead of hand-rolling the clear here:

import{decodeTime}from'ulid';import{decode}from'@workflow/world-vercel/run-id';// in the try block:returndecodeTime(decode(ulidPart).ulid);

decode() already returns the ULID with the tag bit cleared, throws on malformed input (your existing catch covers that), and @workflow/core already depends on @workflow/world-vercel, so this adds no new edge (run-id is a leaf module, no cycle).

The reason it matters: the tagging scheme carries a 5-bit version field, i.e. it explicitly anticipates layout evolution. If a future version moves bits around, decode() in world-vercel gets updated as the single source of truth and this anchor stays correct automatically, whereas a local 2^47 subtraction can silently diverge. The MAX_CLOCK_SKEW_MS drop guard you added would at least turn that divergence into missing data rather than bad data, which is why I'm not calling this blocking — but single-sourcing the codec removes the failure mode entirely.

If you'd rather keep core free of scheme knowledge as a matter of principle, the current MSB-clear is defensible too (a set MSB can never be a real timestamp under any scheme) — in that case maybe just soften the doc comment's implication that MSB-tagging is a general World.createRunId() convention, since today it's specifically world-vercel's.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 8a7f1cfrunIdCreatedAt now delegates to decode() from @workflow/world-vercel/run-id (verified: the dependency already exists in core's dependencies, ./run-id is an exported subpath, and world-vercel has no dependency back on core, so no cycle). The hand-rolled TIMESTAMP_TAG_BIT constant is gone; the doc comment now points at the codec as the single source of truth. Tests (34) and typecheck pass.

Review feedback: instead of hand-rolling the timestamp tag-bit clear
(2^47 subtraction), use decode() from @workflow/world-vercel/run-id,
which returns the ULID with the tag bit cleared and throws on malformed
input. The tagging scheme carries a 5-bit version field, so if the
layout ever evolves the codec stays the single source of truth and this
anchor tracks it automatically.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@VaguelySerious
VaguelySerious merged commit fd107b9 into mainJul 15, 2026
102 of 104 checks passed
@github-actionsgithub-actionsBot mentioned this pull request Jul 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for fd107b9 (AI decision).

This fixes a bug in the TTFS/STSO step-latency telemetry feature, which is main-only: the touched source files (packages/core/src/runtime/run-id-time.ts and step-latency.ts) and their tests do not exist on stable, and stable's runtime.test.ts contains no latency-telemetry tests to attach the runtime.test.ts change to. Since the corrected functionality is entirely absent from stable, there is nothing to backport.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

fd107b9c33db397b513ef134f458a1083bde7d98

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@VaguelySerious@TooTallNate
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' [core] Fix time parsing for region-tagged run IDs by VaguelySerious · Pull Request #2943 · vercel/workflow · GitHub
Skip to content

[core] Fix time parsing for region-tagged run IDs - #2943

Merged
VaguelySerious merged 4 commits into
mainfrom
peter/ttfs-runid-tag-bit
Jul 15, 2026
Merged

[core] Fix time parsing for region-tagged run IDs#2943
VaguelySerious merged 4 commits into
mainfrom
peter/ttfs-runid-tag-bit

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented Jul 15, 2026

Copy link
Copy Markdown
Member

Summary

TTFS reported exactly 0 for every run with a region-tagged run ID (#1981 rollout). Run-ID tagging schemes (World.createRunId(), e.g. world-vercel's region-tagged IDs) set the MSB of the ULID's 48-bit timestamp as a tag bit. runIdCreatedAt() — the TTFS anchor — decoded it as part of the creation time, placing the anchor ~4,460 years in the future, so Math.max(0, end − anchor) clamped every sample to 0. In production, the entire TTFS distribution (p50 through max) for the affected cohort has been exactly 0 since Jul 14 ~08:30 UTC; untagged cohorts report real values throughout.

  • runIdCreatedAt() now clears the tag bit before use (a set MSB can never be a real creation time).
  • computeStepLatencyEventData() now drops samples whose raw duration is more negative than a plausible clock skew (60 s) instead of clamping — a future anchor corruption will surface as missing data, not an all-zero distribution silently dragging every percentile down.

Testing

  • run-id-time.test.ts: tagged-ULID decode (tag bit cleared).
  • step-latency.test.ts: corrupt-anchor samples dropped (TTFS-only and mixed TTFS/STSO cases); small-skew clamping unchanged.
  • runtime.test.ts: end-to-end latency drive with a region-tagged run ID asserts TTFS ≈ run age (the exact production regression shape).

🤖 Generated with Claude Code

Also included

The multi-region e2e lane failed deterministically on this PR with workflow executed in hnd1, tagged icn1. This is the geo-elastic execution locality the test's EXECUTION_ADJACENCY map already documents (kix1-tagged runs observed executing in hnd1/Tokyo) — production lifecycle metrics show icn1-tagged runs executing in hnd1 about half the time over the last 6h, with tagging/data placement remaining strictly icn1. Added hnd1 to icn1's tolerated execution set.

…version
Two fixes to the step latency telemetry, both surfaced by production data:
1. TTFS reported exactly 0 for every run with a region-tagged run ID.
Tagging schemes set the MSB of the ULID's 48-bit timestamp as a tag
bit, which runIdCreatedAt() decoded as part of the creation time —
placing the TTFS anchor millennia in the future so every sample
clamped to 0. The tag bit is now cleared before use, and durations
more negative than a plausible clock skew drop the sample entirely
instead of clamping, so a future anchor corruption surfaces as
missing data rather than an all-zero distribution.
2. Latency samples now carry the @workflow/core version that computed
them (eventData.sdkVersion, lifted into the v4 frame meta). The
transport user-agent identifies the HTTP client package, which
resolves independently of core in consumer lockfiles, so it cannot
attribute measurements to the runtime code that produced them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@VaguelySerious
VaguelySerious requested review from a team and ijjk as code ownersJuly 15, 2026 17:56
@vercel

vercelBot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 15, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8a7f1cf

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actionsBot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 8a7f1cf · Wed, 15 Jul 2026 19:57:24 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1356 (+5.3%)1714 🔴1792 🔴1911 🔴30
TTFShook + stream1539 (+7.3%)1959 🔴2055 🔴2155 🔴30
STSO1020 steps (1-20)299 (+8.9%)337 🔴381 🔴505 🔴19
STSO1020 steps (101-120)462 (+10%)496 🔴664 🔴758 🔴19
STSO1020 steps (1001-1020)971 (+16%)1029 🔴1235 🔴1291 🔴19
WOstream1356 (+5.3%)17141792191130
WOhook + stream1539 (+7.3%)19592055215530
SLstream4432 (-9.8%)5029 🔴5800 🔴6079 🔴30
SLhook + stream5086 (-0.9%)5598 🔴5685 🔴5920 🔴30
📜 Previous results (2)

0d300e8

Wed, 15 Jul 2026 19:33:17 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1285 (±0%)1842 🔴1927 🔴2211 🔴30
TTFShook + stream1722 (+20%)2088 🔴2228 🔴2457 🔴30
STSO1020 steps (1-20)376 (+37%)372 🔴679 🔴1205 🔴19
STSO1020 steps (101-120)454 (+8.2%)479 🔴623 🔴757 🔴19
STSO1020 steps (1001-1020)879 (+5.2%)946 🔴1016 🔴1053 🔴19
WOstream1285 (±0%)18421927221130
WOhook + stream1722 (+20%)20882228245730
SLstream4728 (-3.8%)5657 🔴5729 🔴6087 🔴30
SLhook + stream4884 (-4.8%)5494 🔴5649 🔴5830 🔴30

461aae4

Wed, 15 Jul 2026 18:24:40 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1507 (+17%)1791 🔴1840 🔴2486 🔴30
TTFShook + stream1703 (+19%)2090 🔴2139 🔴2332 🔴30
STSO1020 steps (1-20)308 (+12%)339 🔴468 🔴540 🔴19
STSO1020 steps (101-120)412 (-1.7%)423 🔴544 🔴546 🔴19
STSO1020 steps (1001-1020)971 (+16%)964 🔴1105 🔴2031 🔴19
WOstream1507 (+17%)17911840248630
WOhook + stream1703 (+19%)20902139233230
SLstream4887 (-0.5%)5499 🔴5767 🔴5984 🔴30
SLhook + stream4843 (-5.6%)5424 🔴5763 🔴5903 🔴30

Avg deltas compare against the most recent benchmark run on main at the time of this run.

Metrics — TTFS: time to first step body execution · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (time outside step bodies, client start → last step body exit) · SL: stream latency (first chunk write → visible to the reader)

Scenarios — stream: one step that streams chunks back to the client; no hooks, so the run stays in turbo mode · hook + stream: registers a hook before the same streaming step, which exits turbo mode · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges

🟢/🔴 mark percentiles within/above target. Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

TTFS/WO compare client vs deployment clocks and SL compares the step runner’s clock vs the client’s (NTP-synced in CI). WO ends at the last step body exit, the closest observable proxy for the final step-completion request.

@github-actions

github-actionsBot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
❌ ▲ Vercel Production145212301683
✅ 💻 Local Development161702191836
✅ 📦 Local Production161702191836
✅ 🐘 Local Postgres161702191836
✅ 🪟 Windows15300153
✅ 📋 Other89401771071
✅ vercel-multi-region270027
Total7377110648442

❌ Failed Tests

▲ Vercel Production (1 failed)

fastify (1 failed):

Details by Category

❌ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro126027
✅ example126027
✅ express126027
❌ fastify125127
✅ hono126027
✅ nextjs-turbopack15003
✅ nextjs-webpack15003
✅ nitro126027
✅ nuxt126027
✅ sveltekit14508
✅ vite126027
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15300
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128025
✅ e2e-local-dev-tanstack-start-128025
✅ e2e-local-postgres-nest-stable128025
✅ e2e-local-postgres-tanstack-start-128025
✅ e2e-local-prod-nest-stable128025
✅ e2e-local-prod-tanstack-start-128025
✅ e2e-vercel-prod-tanstack-start126027
✅ vercel-multi-region
AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: failure
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: success
  • Windows: success

Check the workflow run for details.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both fixes are well-motivated and the diagnosis writeup is excellent. I verified the mechanics independently:

  • ulid@3.0.1's decodeTime bound is TIME_MAX = 2^48 - 1, so a tagged timestamp (real time + 2^47) decodes without throwing and the subtract-after-guard in runIdCreatedAt is correct. Using subtraction rather than a bitwise mask also matters here since JS bitwise ops truncate at 32 bits. ✅
  • Drop-instead-of-clamp is the right failure mode: a future multi-bit tagging scheme that the MSB-clear doesn't fully handle would surface as missing samples via the MAX_CLOCK_SKEW_MS guard rather than an all-zero distribution. ✅
  • v4 wire compat: parseV4EventMeta in workflow-server picks known fields and ignores unknown meta keys, so older servers tolerate the new sdkVersion frame meta. ✅
  • version.ts is genversion-generated in build/typecheck/dev scripts, and release builds run after the changeset version bump, so the reported version is correct on published artifacts. ✅

One blocking issue on release ordering for the sdkVersion field (inline).

? { eventCount: tracking.eventCount }
: {}),
optimizations,
sdkVersion: coreVersion,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking (release ordering, not this code): core attaches sdkVersion to step-terminal eventData unconditionally, but workflow-server's persistence strip list (CLIENT_LATENCY_TELEMETRY_FIELDS in lib/data/events.ts) is ['ttfs','stso','stepCount','eventCount','optimizations'] and does not include sdkVersion, and snapshotClientLatencyTelemetry doesn't consume it either.

On the v4 wire this is safe (world-vercel moves it to frame meta; the server ignores unknown meta fields and never copies meta into eventData). But on the v2/v3 wire, eventData is sent raw, so sdkVersion would be persisted into the stored, user-visible event's eventData, violating the server's documented invariant that latency telemetry is "aggregate telemetry, not run state" and stays out of event reads and DynamoDB. The affected cohort is old @workflow/world-vercel + new @workflow/core resolved independently in a consumer lockfile, which is exactly the mixed-version scenario this PR's motivation describes (unless the old v3 client path zod-strips unknown eventData keys before POSTing; I couldn't rule that in or out from the current repo since the v3 implementation was replaced).

Since merging here auto-releases via changesets, can we land and deploy the server-side counterpart first (add sdkVersion to the strip list + pick it up in snapshotClientLatencyTelemetry/metric tags)? That both closes the leak and makes the field actually consumable, so the release order becomes server-then-client and this PR merges as-is.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry this wasn't ready for review yet, should have made it a draft PR. The core version stuff is something the AI add unilaterally, removed now

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed after the sdkVersion removal — my previous blocking item is resolved, and the remaining diff is the TTFS fix I already verified mechanically (ulid decodeTime accepts tagged timestamps since TIME_MAX = 2^48 - 1; subtraction rather than a 32-bit-truncating bitwise mask; drop-instead-of-clamp surfaces future anchor corruption as missing data instead of a zeroed distribution). Test coverage is thorough at all three layers (codec, compute, runtime e2e).

The EXECUTION_ADJACENCY addition (icn1 tolerating hnd1) is a reasonable geo-elastic tolerance — Seoul→Tokyo is nearer than the already-listed syd1.

One recommendation inline about delegating the tag-bit knowledge to @workflow/world-vercel/run-id's decode() — non-blocking since the current code is correct, but worth doing before merge while the file is hot.

try {
return decodeTime(ulidPart);
const time = decodeTime(ulidPart);
return time >= TIMESTAMP_TAG_BIT ? time - TIMESTAMP_TAG_BIT : time;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommendation (non-blocking): consider delegating the tag-bit handling to the scheme's own codec instead of hand-rolling the clear here:

import{decodeTime}from'ulid';import{decode}from'@workflow/world-vercel/run-id';// in the try block:returndecodeTime(decode(ulidPart).ulid);

decode() already returns the ULID with the tag bit cleared, throws on malformed input (your existing catch covers that), and @workflow/core already depends on @workflow/world-vercel, so this adds no new edge (run-id is a leaf module, no cycle).

The reason it matters: the tagging scheme carries a 5-bit version field, i.e. it explicitly anticipates layout evolution. If a future version moves bits around, decode() in world-vercel gets updated as the single source of truth and this anchor stays correct automatically, whereas a local 2^47 subtraction can silently diverge. The MAX_CLOCK_SKEW_MS drop guard you added would at least turn that divergence into missing data rather than bad data, which is why I'm not calling this blocking — but single-sourcing the codec removes the failure mode entirely.

If you'd rather keep core free of scheme knowledge as a matter of principle, the current MSB-clear is defensible too (a set MSB can never be a real timestamp under any scheme) — in that case maybe just soften the doc comment's implication that MSB-tagging is a general World.createRunId() convention, since today it's specifically world-vercel's.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 8a7f1cfrunIdCreatedAt now delegates to decode() from @workflow/world-vercel/run-id (verified: the dependency already exists in core's dependencies, ./run-id is an exported subpath, and world-vercel has no dependency back on core, so no cycle). The hand-rolled TIMESTAMP_TAG_BIT constant is gone; the doc comment now points at the codec as the single source of truth. Tests (34) and typecheck pass.

Review feedback: instead of hand-rolling the timestamp tag-bit clear
(2^47 subtraction), use decode() from @workflow/world-vercel/run-id,
which returns the ULID with the tag bit cleared and throws on malformed
input. The tagging scheme carries a 5-bit version field, so if the
layout ever evolves the codec stays the single source of truth and this
anchor tracks it automatically.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@VaguelySerious
VaguelySerious merged commit fd107b9 into mainJul 15, 2026
102 of 104 checks passed
@github-actionsgithub-actionsBot mentioned this pull request Jul 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for fd107b9 (AI decision).

This fixes a bug in the TTFS/STSO step-latency telemetry feature, which is main-only: the touched source files (packages/core/src/runtime/run-id-time.ts and step-latency.ts) and their tests do not exist on stable, and stable's runtime.test.ts contains no latency-telemetry tests to attach the runtime.test.ts change to. Since the corrected functionality is entirely absent from stable, there is nothing to backport.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

fd107b9c33db397b513ef134f458a1083bde7d98

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@VaguelySerious@TooTallNate
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [core] Fix time parsing for region-tagged run IDs by VaguelySerious · Pull Request #2943 · vercel/workflow · GitHub
Skip to content

[core] Fix time parsing for region-tagged run IDs - #2943

Merged
VaguelySerious merged 4 commits into
mainfrom
peter/ttfs-runid-tag-bit
Jul 15, 2026
Merged

[core] Fix time parsing for region-tagged run IDs#2943
VaguelySerious merged 4 commits into
mainfrom
peter/ttfs-runid-tag-bit

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented Jul 15, 2026

Copy link
Copy Markdown
Member

Summary

TTFS reported exactly 0 for every run with a region-tagged run ID (#1981 rollout). Run-ID tagging schemes (World.createRunId(), e.g. world-vercel's region-tagged IDs) set the MSB of the ULID's 48-bit timestamp as a tag bit. runIdCreatedAt() — the TTFS anchor — decoded it as part of the creation time, placing the anchor ~4,460 years in the future, so Math.max(0, end − anchor) clamped every sample to 0. In production, the entire TTFS distribution (p50 through max) for the affected cohort has been exactly 0 since Jul 14 ~08:30 UTC; untagged cohorts report real values throughout.

  • runIdCreatedAt() now clears the tag bit before use (a set MSB can never be a real creation time).
  • computeStepLatencyEventData() now drops samples whose raw duration is more negative than a plausible clock skew (60 s) instead of clamping — a future anchor corruption will surface as missing data, not an all-zero distribution silently dragging every percentile down.

Testing

  • run-id-time.test.ts: tagged-ULID decode (tag bit cleared).
  • step-latency.test.ts: corrupt-anchor samples dropped (TTFS-only and mixed TTFS/STSO cases); small-skew clamping unchanged.
  • runtime.test.ts: end-to-end latency drive with a region-tagged run ID asserts TTFS ≈ run age (the exact production regression shape).

🤖 Generated with Claude Code

Also included

The multi-region e2e lane failed deterministically on this PR with workflow executed in hnd1, tagged icn1. This is the geo-elastic execution locality the test's EXECUTION_ADJACENCY map already documents (kix1-tagged runs observed executing in hnd1/Tokyo) — production lifecycle metrics show icn1-tagged runs executing in hnd1 about half the time over the last 6h, with tagging/data placement remaining strictly icn1. Added hnd1 to icn1's tolerated execution set.

…version
Two fixes to the step latency telemetry, both surfaced by production data:
1. TTFS reported exactly 0 for every run with a region-tagged run ID.
Tagging schemes set the MSB of the ULID's 48-bit timestamp as a tag
bit, which runIdCreatedAt() decoded as part of the creation time —
placing the TTFS anchor millennia in the future so every sample
clamped to 0. The tag bit is now cleared before use, and durations
more negative than a plausible clock skew drop the sample entirely
instead of clamping, so a future anchor corruption surfaces as
missing data rather than an all-zero distribution.
2. Latency samples now carry the @workflow/core version that computed
them (eventData.sdkVersion, lifted into the v4 frame meta). The
transport user-agent identifies the HTTP client package, which
resolves independently of core in consumer lockfiles, so it cannot
attribute measurements to the runtime code that produced them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@VaguelySerious
VaguelySerious requested review from a team and ijjk as code ownersJuly 15, 2026 17:56
@vercel

vercelBot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 15, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8a7f1cf

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actionsBot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 8a7f1cf · Wed, 15 Jul 2026 19:57:24 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1356 (+5.3%)1714 🔴1792 🔴1911 🔴30
TTFShook + stream1539 (+7.3%)1959 🔴2055 🔴2155 🔴30
STSO1020 steps (1-20)299 (+8.9%)337 🔴381 🔴505 🔴19
STSO1020 steps (101-120)462 (+10%)496 🔴664 🔴758 🔴19
STSO1020 steps (1001-1020)971 (+16%)1029 🔴1235 🔴1291 🔴19
WOstream1356 (+5.3%)17141792191130
WOhook + stream1539 (+7.3%)19592055215530
SLstream4432 (-9.8%)5029 🔴5800 🔴6079 🔴30
SLhook + stream5086 (-0.9%)5598 🔴5685 🔴5920 🔴30
📜 Previous results (2)

0d300e8

Wed, 15 Jul 2026 19:33:17 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1285 (±0%)1842 🔴1927 🔴2211 🔴30
TTFShook + stream1722 (+20%)2088 🔴2228 🔴2457 🔴30
STSO1020 steps (1-20)376 (+37%)372 🔴679 🔴1205 🔴19
STSO1020 steps (101-120)454 (+8.2%)479 🔴623 🔴757 🔴19
STSO1020 steps (1001-1020)879 (+5.2%)946 🔴1016 🔴1053 🔴19
WOstream1285 (±0%)18421927221130
WOhook + stream1722 (+20%)20882228245730
SLstream4728 (-3.8%)5657 🔴5729 🔴6087 🔴30
SLhook + stream4884 (-4.8%)5494 🔴5649 🔴5830 🔴30

461aae4

Wed, 15 Jul 2026 18:24:40 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1507 (+17%)1791 🔴1840 🔴2486 🔴30
TTFShook + stream1703 (+19%)2090 🔴2139 🔴2332 🔴30
STSO1020 steps (1-20)308 (+12%)339 🔴468 🔴540 🔴19
STSO1020 steps (101-120)412 (-1.7%)423 🔴544 🔴546 🔴19
STSO1020 steps (1001-1020)971 (+16%)964 🔴1105 🔴2031 🔴19
WOstream1507 (+17%)17911840248630
WOhook + stream1703 (+19%)20902139233230
SLstream4887 (-0.5%)5499 🔴5767 🔴5984 🔴30
SLhook + stream4843 (-5.6%)5424 🔴5763 🔴5903 🔴30

Avg deltas compare against the most recent benchmark run on main at the time of this run.

Metrics — TTFS: time to first step body execution · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (time outside step bodies, client start → last step body exit) · SL: stream latency (first chunk write → visible to the reader)

Scenarios — stream: one step that streams chunks back to the client; no hooks, so the run stays in turbo mode · hook + stream: registers a hook before the same streaming step, which exits turbo mode · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges

🟢/🔴 mark percentiles within/above target. Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

TTFS/WO compare client vs deployment clocks and SL compares the step runner’s clock vs the client’s (NTP-synced in CI). WO ends at the last step body exit, the closest observable proxy for the final step-completion request.

@github-actions

github-actionsBot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
❌ ▲ Vercel Production145212301683
✅ 💻 Local Development161702191836
✅ 📦 Local Production161702191836
✅ 🐘 Local Postgres161702191836
✅ 🪟 Windows15300153
✅ 📋 Other89401771071
✅ vercel-multi-region270027
Total7377110648442

❌ Failed Tests

▲ Vercel Production (1 failed)

fastify (1 failed):

Details by Category

❌ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro126027
✅ example126027
✅ express126027
❌ fastify125127
✅ hono126027
✅ nextjs-turbopack15003
✅ nextjs-webpack15003
✅ nitro126027
✅ nuxt126027
✅ sveltekit14508
✅ vite126027
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15300
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128025
✅ e2e-local-dev-tanstack-start-128025
✅ e2e-local-postgres-nest-stable128025
✅ e2e-local-postgres-tanstack-start-128025
✅ e2e-local-prod-nest-stable128025
✅ e2e-local-prod-tanstack-start-128025
✅ e2e-vercel-prod-tanstack-start126027
✅ vercel-multi-region
AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: failure
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: success
  • Windows: success

Check the workflow run for details.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both fixes are well-motivated and the diagnosis writeup is excellent. I verified the mechanics independently:

  • ulid@3.0.1's decodeTime bound is TIME_MAX = 2^48 - 1, so a tagged timestamp (real time + 2^47) decodes without throwing and the subtract-after-guard in runIdCreatedAt is correct. Using subtraction rather than a bitwise mask also matters here since JS bitwise ops truncate at 32 bits. ✅
  • Drop-instead-of-clamp is the right failure mode: a future multi-bit tagging scheme that the MSB-clear doesn't fully handle would surface as missing samples via the MAX_CLOCK_SKEW_MS guard rather than an all-zero distribution. ✅
  • v4 wire compat: parseV4EventMeta in workflow-server picks known fields and ignores unknown meta keys, so older servers tolerate the new sdkVersion frame meta. ✅
  • version.ts is genversion-generated in build/typecheck/dev scripts, and release builds run after the changeset version bump, so the reported version is correct on published artifacts. ✅

One blocking issue on release ordering for the sdkVersion field (inline).

? { eventCount: tracking.eventCount }
: {}),
optimizations,
sdkVersion: coreVersion,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking (release ordering, not this code): core attaches sdkVersion to step-terminal eventData unconditionally, but workflow-server's persistence strip list (CLIENT_LATENCY_TELEMETRY_FIELDS in lib/data/events.ts) is ['ttfs','stso','stepCount','eventCount','optimizations'] and does not include sdkVersion, and snapshotClientLatencyTelemetry doesn't consume it either.

On the v4 wire this is safe (world-vercel moves it to frame meta; the server ignores unknown meta fields and never copies meta into eventData). But on the v2/v3 wire, eventData is sent raw, so sdkVersion would be persisted into the stored, user-visible event's eventData, violating the server's documented invariant that latency telemetry is "aggregate telemetry, not run state" and stays out of event reads and DynamoDB. The affected cohort is old @workflow/world-vercel + new @workflow/core resolved independently in a consumer lockfile, which is exactly the mixed-version scenario this PR's motivation describes (unless the old v3 client path zod-strips unknown eventData keys before POSTing; I couldn't rule that in or out from the current repo since the v3 implementation was replaced).

Since merging here auto-releases via changesets, can we land and deploy the server-side counterpart first (add sdkVersion to the strip list + pick it up in snapshotClientLatencyTelemetry/metric tags)? That both closes the leak and makes the field actually consumable, so the release order becomes server-then-client and this PR merges as-is.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry this wasn't ready for review yet, should have made it a draft PR. The core version stuff is something the AI add unilaterally, removed now

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed after the sdkVersion removal — my previous blocking item is resolved, and the remaining diff is the TTFS fix I already verified mechanically (ulid decodeTime accepts tagged timestamps since TIME_MAX = 2^48 - 1; subtraction rather than a 32-bit-truncating bitwise mask; drop-instead-of-clamp surfaces future anchor corruption as missing data instead of a zeroed distribution). Test coverage is thorough at all three layers (codec, compute, runtime e2e).

The EXECUTION_ADJACENCY addition (icn1 tolerating hnd1) is a reasonable geo-elastic tolerance — Seoul→Tokyo is nearer than the already-listed syd1.

One recommendation inline about delegating the tag-bit knowledge to @workflow/world-vercel/run-id's decode() — non-blocking since the current code is correct, but worth doing before merge while the file is hot.

try {
return decodeTime(ulidPart);
const time = decodeTime(ulidPart);
return time >= TIMESTAMP_TAG_BIT ? time - TIMESTAMP_TAG_BIT : time;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommendation (non-blocking): consider delegating the tag-bit handling to the scheme's own codec instead of hand-rolling the clear here:

import{decodeTime}from'ulid';import{decode}from'@workflow/world-vercel/run-id';// in the try block:returndecodeTime(decode(ulidPart).ulid);

decode() already returns the ULID with the tag bit cleared, throws on malformed input (your existing catch covers that), and @workflow/core already depends on @workflow/world-vercel, so this adds no new edge (run-id is a leaf module, no cycle).

The reason it matters: the tagging scheme carries a 5-bit version field, i.e. it explicitly anticipates layout evolution. If a future version moves bits around, decode() in world-vercel gets updated as the single source of truth and this anchor stays correct automatically, whereas a local 2^47 subtraction can silently diverge. The MAX_CLOCK_SKEW_MS drop guard you added would at least turn that divergence into missing data rather than bad data, which is why I'm not calling this blocking — but single-sourcing the codec removes the failure mode entirely.

If you'd rather keep core free of scheme knowledge as a matter of principle, the current MSB-clear is defensible too (a set MSB can never be a real timestamp under any scheme) — in that case maybe just soften the doc comment's implication that MSB-tagging is a general World.createRunId() convention, since today it's specifically world-vercel's.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 8a7f1cfrunIdCreatedAt now delegates to decode() from @workflow/world-vercel/run-id (verified: the dependency already exists in core's dependencies, ./run-id is an exported subpath, and world-vercel has no dependency back on core, so no cycle). The hand-rolled TIMESTAMP_TAG_BIT constant is gone; the doc comment now points at the codec as the single source of truth. Tests (34) and typecheck pass.

Review feedback: instead of hand-rolling the timestamp tag-bit clear
(2^47 subtraction), use decode() from @workflow/world-vercel/run-id,
which returns the ULID with the tag bit cleared and throws on malformed
input. The tagging scheme carries a 5-bit version field, so if the
layout ever evolves the codec stays the single source of truth and this
anchor tracks it automatically.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@VaguelySerious
VaguelySerious merged commit fd107b9 into mainJul 15, 2026
102 of 104 checks passed
@github-actionsgithub-actionsBot mentioned this pull request Jul 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for fd107b9 (AI decision).

This fixes a bug in the TTFS/STSO step-latency telemetry feature, which is main-only: the touched source files (packages/core/src/runtime/run-id-time.ts and step-latency.ts) and their tests do not exist on stable, and stable's runtime.test.ts contains no latency-telemetry tests to attach the runtime.test.ts change to. Since the corrected functionality is entirely absent from stable, there is nothing to backport.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

fd107b9c33db397b513ef134f458a1083bde7d98

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@VaguelySerious@TooTallNate
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [core] Fix time parsing for region-tagged run IDs by VaguelySerious · Pull Request #2943 · vercel/workflow · GitHub
Skip to content

[core] Fix time parsing for region-tagged run IDs - #2943

Merged
VaguelySerious merged 4 commits into
mainfrom
peter/ttfs-runid-tag-bit
Jul 15, 2026
Merged

[core] Fix time parsing for region-tagged run IDs#2943
VaguelySerious merged 4 commits into
mainfrom
peter/ttfs-runid-tag-bit

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented Jul 15, 2026

Copy link
Copy Markdown
Member

Summary

TTFS reported exactly 0 for every run with a region-tagged run ID (#1981 rollout). Run-ID tagging schemes (World.createRunId(), e.g. world-vercel's region-tagged IDs) set the MSB of the ULID's 48-bit timestamp as a tag bit. runIdCreatedAt() — the TTFS anchor — decoded it as part of the creation time, placing the anchor ~4,460 years in the future, so Math.max(0, end − anchor) clamped every sample to 0. In production, the entire TTFS distribution (p50 through max) for the affected cohort has been exactly 0 since Jul 14 ~08:30 UTC; untagged cohorts report real values throughout.

  • runIdCreatedAt() now clears the tag bit before use (a set MSB can never be a real creation time).
  • computeStepLatencyEventData() now drops samples whose raw duration is more negative than a plausible clock skew (60 s) instead of clamping — a future anchor corruption will surface as missing data, not an all-zero distribution silently dragging every percentile down.

Testing

  • run-id-time.test.ts: tagged-ULID decode (tag bit cleared).
  • step-latency.test.ts: corrupt-anchor samples dropped (TTFS-only and mixed TTFS/STSO cases); small-skew clamping unchanged.
  • runtime.test.ts: end-to-end latency drive with a region-tagged run ID asserts TTFS ≈ run age (the exact production regression shape).

🤖 Generated with Claude Code

Also included

The multi-region e2e lane failed deterministically on this PR with workflow executed in hnd1, tagged icn1. This is the geo-elastic execution locality the test's EXECUTION_ADJACENCY map already documents (kix1-tagged runs observed executing in hnd1/Tokyo) — production lifecycle metrics show icn1-tagged runs executing in hnd1 about half the time over the last 6h, with tagging/data placement remaining strictly icn1. Added hnd1 to icn1's tolerated execution set.

…version
Two fixes to the step latency telemetry, both surfaced by production data:
1. TTFS reported exactly 0 for every run with a region-tagged run ID.
Tagging schemes set the MSB of the ULID's 48-bit timestamp as a tag
bit, which runIdCreatedAt() decoded as part of the creation time —
placing the TTFS anchor millennia in the future so every sample
clamped to 0. The tag bit is now cleared before use, and durations
more negative than a plausible clock skew drop the sample entirely
instead of clamping, so a future anchor corruption surfaces as
missing data rather than an all-zero distribution.
2. Latency samples now carry the @workflow/core version that computed
them (eventData.sdkVersion, lifted into the v4 frame meta). The
transport user-agent identifies the HTTP client package, which
resolves independently of core in consumer lockfiles, so it cannot
attribute measurements to the runtime code that produced them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@VaguelySerious
VaguelySerious requested review from a team and ijjk as code ownersJuly 15, 2026 17:56
@vercel

vercelBot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 15, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8a7f1cf

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actionsBot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 8a7f1cf · Wed, 15 Jul 2026 19:57:24 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1356 (+5.3%)1714 🔴1792 🔴1911 🔴30
TTFShook + stream1539 (+7.3%)1959 🔴2055 🔴2155 🔴30
STSO1020 steps (1-20)299 (+8.9%)337 🔴381 🔴505 🔴19
STSO1020 steps (101-120)462 (+10%)496 🔴664 🔴758 🔴19
STSO1020 steps (1001-1020)971 (+16%)1029 🔴1235 🔴1291 🔴19
WOstream1356 (+5.3%)17141792191130
WOhook + stream1539 (+7.3%)19592055215530
SLstream4432 (-9.8%)5029 🔴5800 🔴6079 🔴30
SLhook + stream5086 (-0.9%)5598 🔴5685 🔴5920 🔴30
📜 Previous results (2)

0d300e8

Wed, 15 Jul 2026 19:33:17 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1285 (±0%)1842 🔴1927 🔴2211 🔴30
TTFShook + stream1722 (+20%)2088 🔴2228 🔴2457 🔴30
STSO1020 steps (1-20)376 (+37%)372 🔴679 🔴1205 🔴19
STSO1020 steps (101-120)454 (+8.2%)479 🔴623 🔴757 🔴19
STSO1020 steps (1001-1020)879 (+5.2%)946 🔴1016 🔴1053 🔴19
WOstream1285 (±0%)18421927221130
WOhook + stream1722 (+20%)20882228245730
SLstream4728 (-3.8%)5657 🔴5729 🔴6087 🔴30
SLhook + stream4884 (-4.8%)5494 🔴5649 🔴5830 🔴30

461aae4

Wed, 15 Jul 2026 18:24:40 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1507 (+17%)1791 🔴1840 🔴2486 🔴30
TTFShook + stream1703 (+19%)2090 🔴2139 🔴2332 🔴30
STSO1020 steps (1-20)308 (+12%)339 🔴468 🔴540 🔴19
STSO1020 steps (101-120)412 (-1.7%)423 🔴544 🔴546 🔴19
STSO1020 steps (1001-1020)971 (+16%)964 🔴1105 🔴2031 🔴19
WOstream1507 (+17%)17911840248630
WOhook + stream1703 (+19%)20902139233230
SLstream4887 (-0.5%)5499 🔴5767 🔴5984 🔴30
SLhook + stream4843 (-5.6%)5424 🔴5763 🔴5903 🔴30

Avg deltas compare against the most recent benchmark run on main at the time of this run.

Metrics — TTFS: time to first step body execution · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (time outside step bodies, client start → last step body exit) · SL: stream latency (first chunk write → visible to the reader)

Scenarios — stream: one step that streams chunks back to the client; no hooks, so the run stays in turbo mode · hook + stream: registers a hook before the same streaming step, which exits turbo mode · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges

🟢/🔴 mark percentiles within/above target. Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

TTFS/WO compare client vs deployment clocks and SL compares the step runner’s clock vs the client’s (NTP-synced in CI). WO ends at the last step body exit, the closest observable proxy for the final step-completion request.

@github-actions

github-actionsBot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
❌ ▲ Vercel Production145212301683
✅ 💻 Local Development161702191836
✅ 📦 Local Production161702191836
✅ 🐘 Local Postgres161702191836
✅ 🪟 Windows15300153
✅ 📋 Other89401771071
✅ vercel-multi-region270027
Total7377110648442

❌ Failed Tests

▲ Vercel Production (1 failed)

fastify (1 failed):

Details by Category

❌ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro126027
✅ example126027
✅ express126027
❌ fastify125127
✅ hono126027
✅ nextjs-turbopack15003
✅ nextjs-webpack15003
✅ nitro126027
✅ nuxt126027
✅ sveltekit14508
✅ vite126027
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15300
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128025
✅ e2e-local-dev-tanstack-start-128025
✅ e2e-local-postgres-nest-stable128025
✅ e2e-local-postgres-tanstack-start-128025
✅ e2e-local-prod-nest-stable128025
✅ e2e-local-prod-tanstack-start-128025
✅ e2e-vercel-prod-tanstack-start126027
✅ vercel-multi-region
AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: failure
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: success
  • Windows: success

Check the workflow run for details.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both fixes are well-motivated and the diagnosis writeup is excellent. I verified the mechanics independently:

  • ulid@3.0.1's decodeTime bound is TIME_MAX = 2^48 - 1, so a tagged timestamp (real time + 2^47) decodes without throwing and the subtract-after-guard in runIdCreatedAt is correct. Using subtraction rather than a bitwise mask also matters here since JS bitwise ops truncate at 32 bits. ✅
  • Drop-instead-of-clamp is the right failure mode: a future multi-bit tagging scheme that the MSB-clear doesn't fully handle would surface as missing samples via the MAX_CLOCK_SKEW_MS guard rather than an all-zero distribution. ✅
  • v4 wire compat: parseV4EventMeta in workflow-server picks known fields and ignores unknown meta keys, so older servers tolerate the new sdkVersion frame meta. ✅
  • version.ts is genversion-generated in build/typecheck/dev scripts, and release builds run after the changeset version bump, so the reported version is correct on published artifacts. ✅

One blocking issue on release ordering for the sdkVersion field (inline).

? { eventCount: tracking.eventCount }
: {}),
optimizations,
sdkVersion: coreVersion,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking (release ordering, not this code): core attaches sdkVersion to step-terminal eventData unconditionally, but workflow-server's persistence strip list (CLIENT_LATENCY_TELEMETRY_FIELDS in lib/data/events.ts) is ['ttfs','stso','stepCount','eventCount','optimizations'] and does not include sdkVersion, and snapshotClientLatencyTelemetry doesn't consume it either.

On the v4 wire this is safe (world-vercel moves it to frame meta; the server ignores unknown meta fields and never copies meta into eventData). But on the v2/v3 wire, eventData is sent raw, so sdkVersion would be persisted into the stored, user-visible event's eventData, violating the server's documented invariant that latency telemetry is "aggregate telemetry, not run state" and stays out of event reads and DynamoDB. The affected cohort is old @workflow/world-vercel + new @workflow/core resolved independently in a consumer lockfile, which is exactly the mixed-version scenario this PR's motivation describes (unless the old v3 client path zod-strips unknown eventData keys before POSTing; I couldn't rule that in or out from the current repo since the v3 implementation was replaced).

Since merging here auto-releases via changesets, can we land and deploy the server-side counterpart first (add sdkVersion to the strip list + pick it up in snapshotClientLatencyTelemetry/metric tags)? That both closes the leak and makes the field actually consumable, so the release order becomes server-then-client and this PR merges as-is.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry this wasn't ready for review yet, should have made it a draft PR. The core version stuff is something the AI add unilaterally, removed now

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed after the sdkVersion removal — my previous blocking item is resolved, and the remaining diff is the TTFS fix I already verified mechanically (ulid decodeTime accepts tagged timestamps since TIME_MAX = 2^48 - 1; subtraction rather than a 32-bit-truncating bitwise mask; drop-instead-of-clamp surfaces future anchor corruption as missing data instead of a zeroed distribution). Test coverage is thorough at all three layers (codec, compute, runtime e2e).

The EXECUTION_ADJACENCY addition (icn1 tolerating hnd1) is a reasonable geo-elastic tolerance — Seoul→Tokyo is nearer than the already-listed syd1.

One recommendation inline about delegating the tag-bit knowledge to @workflow/world-vercel/run-id's decode() — non-blocking since the current code is correct, but worth doing before merge while the file is hot.

try {
return decodeTime(ulidPart);
const time = decodeTime(ulidPart);
return time >= TIMESTAMP_TAG_BIT ? time - TIMESTAMP_TAG_BIT : time;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommendation (non-blocking): consider delegating the tag-bit handling to the scheme's own codec instead of hand-rolling the clear here:

import{decodeTime}from'ulid';import{decode}from'@workflow/world-vercel/run-id';// in the try block:returndecodeTime(decode(ulidPart).ulid);

decode() already returns the ULID with the tag bit cleared, throws on malformed input (your existing catch covers that), and @workflow/core already depends on @workflow/world-vercel, so this adds no new edge (run-id is a leaf module, no cycle).

The reason it matters: the tagging scheme carries a 5-bit version field, i.e. it explicitly anticipates layout evolution. If a future version moves bits around, decode() in world-vercel gets updated as the single source of truth and this anchor stays correct automatically, whereas a local 2^47 subtraction can silently diverge. The MAX_CLOCK_SKEW_MS drop guard you added would at least turn that divergence into missing data rather than bad data, which is why I'm not calling this blocking — but single-sourcing the codec removes the failure mode entirely.

If you'd rather keep core free of scheme knowledge as a matter of principle, the current MSB-clear is defensible too (a set MSB can never be a real timestamp under any scheme) — in that case maybe just soften the doc comment's implication that MSB-tagging is a general World.createRunId() convention, since today it's specifically world-vercel's.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 8a7f1cfrunIdCreatedAt now delegates to decode() from @workflow/world-vercel/run-id (verified: the dependency already exists in core's dependencies, ./run-id is an exported subpath, and world-vercel has no dependency back on core, so no cycle). The hand-rolled TIMESTAMP_TAG_BIT constant is gone; the doc comment now points at the codec as the single source of truth. Tests (34) and typecheck pass.

Review feedback: instead of hand-rolling the timestamp tag-bit clear
(2^47 subtraction), use decode() from @workflow/world-vercel/run-id,
which returns the ULID with the tag bit cleared and throws on malformed
input. The tagging scheme carries a 5-bit version field, so if the
layout ever evolves the codec stays the single source of truth and this
anchor tracks it automatically.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@VaguelySerious
VaguelySerious merged commit fd107b9 into mainJul 15, 2026
102 of 104 checks passed
@github-actionsgithub-actionsBot mentioned this pull request Jul 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for fd107b9 (AI decision).

This fixes a bug in the TTFS/STSO step-latency telemetry feature, which is main-only: the touched source files (packages/core/src/runtime/run-id-time.ts and step-latency.ts) and their tests do not exist on stable, and stable's runtime.test.ts contains no latency-telemetry tests to attach the runtime.test.ts change to. Since the corrected functionality is entirely absent from stable, there is nothing to backport.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

fd107b9c33db397b513ef134f458a1083bde7d98

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@VaguelySerious@TooTallNate
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); [core] Fix time parsing for region-tagged run IDs by VaguelySerious · Pull Request #2943 · vercel/workflow · GitHub
Skip to content

[core] Fix time parsing for region-tagged run IDs - #2943

Merged
VaguelySerious merged 4 commits into
mainfrom
peter/ttfs-runid-tag-bit
Jul 15, 2026
Merged

[core] Fix time parsing for region-tagged run IDs#2943
VaguelySerious merged 4 commits into
mainfrom
peter/ttfs-runid-tag-bit

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented Jul 15, 2026

Copy link
Copy Markdown
Member

Summary

TTFS reported exactly 0 for every run with a region-tagged run ID (#1981 rollout). Run-ID tagging schemes (World.createRunId(), e.g. world-vercel's region-tagged IDs) set the MSB of the ULID's 48-bit timestamp as a tag bit. runIdCreatedAt() — the TTFS anchor — decoded it as part of the creation time, placing the anchor ~4,460 years in the future, so Math.max(0, end − anchor) clamped every sample to 0. In production, the entire TTFS distribution (p50 through max) for the affected cohort has been exactly 0 since Jul 14 ~08:30 UTC; untagged cohorts report real values throughout.

  • runIdCreatedAt() now clears the tag bit before use (a set MSB can never be a real creation time).
  • computeStepLatencyEventData() now drops samples whose raw duration is more negative than a plausible clock skew (60 s) instead of clamping — a future anchor corruption will surface as missing data, not an all-zero distribution silently dragging every percentile down.

Testing

  • run-id-time.test.ts: tagged-ULID decode (tag bit cleared).
  • step-latency.test.ts: corrupt-anchor samples dropped (TTFS-only and mixed TTFS/STSO cases); small-skew clamping unchanged.
  • runtime.test.ts: end-to-end latency drive with a region-tagged run ID asserts TTFS ≈ run age (the exact production regression shape).

🤖 Generated with Claude Code

Also included

The multi-region e2e lane failed deterministically on this PR with workflow executed in hnd1, tagged icn1. This is the geo-elastic execution locality the test's EXECUTION_ADJACENCY map already documents (kix1-tagged runs observed executing in hnd1/Tokyo) — production lifecycle metrics show icn1-tagged runs executing in hnd1 about half the time over the last 6h, with tagging/data placement remaining strictly icn1. Added hnd1 to icn1's tolerated execution set.

…version
Two fixes to the step latency telemetry, both surfaced by production data:
1. TTFS reported exactly 0 for every run with a region-tagged run ID.
Tagging schemes set the MSB of the ULID's 48-bit timestamp as a tag
bit, which runIdCreatedAt() decoded as part of the creation time —
placing the TTFS anchor millennia in the future so every sample
clamped to 0. The tag bit is now cleared before use, and durations
more negative than a plausible clock skew drop the sample entirely
instead of clamping, so a future anchor corruption surfaces as
missing data rather than an all-zero distribution.
2. Latency samples now carry the @workflow/core version that computed
them (eventData.sdkVersion, lifted into the v4 frame meta). The
transport user-agent identifies the HTTP client package, which
resolves independently of core in consumer lockfiles, so it cannot
attribute measurements to the runtime code that produced them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@VaguelySerious
VaguelySerious requested review from a team and ijjk as code ownersJuly 15, 2026 17:56
@vercel

vercelBot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 15, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8a7f1cf

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actionsBot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 8a7f1cf · Wed, 15 Jul 2026 19:57:24 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1356 (+5.3%)1714 🔴1792 🔴1911 🔴30
TTFShook + stream1539 (+7.3%)1959 🔴2055 🔴2155 🔴30
STSO1020 steps (1-20)299 (+8.9%)337 🔴381 🔴505 🔴19
STSO1020 steps (101-120)462 (+10%)496 🔴664 🔴758 🔴19
STSO1020 steps (1001-1020)971 (+16%)1029 🔴1235 🔴1291 🔴19
WOstream1356 (+5.3%)17141792191130
WOhook + stream1539 (+7.3%)19592055215530
SLstream4432 (-9.8%)5029 🔴5800 🔴6079 🔴30
SLhook + stream5086 (-0.9%)5598 🔴5685 🔴5920 🔴30
📜 Previous results (2)

0d300e8

Wed, 15 Jul 2026 19:33:17 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1285 (±0%)1842 🔴1927 🔴2211 🔴30
TTFShook + stream1722 (+20%)2088 🔴2228 🔴2457 🔴30
STSO1020 steps (1-20)376 (+37%)372 🔴679 🔴1205 🔴19
STSO1020 steps (101-120)454 (+8.2%)479 🔴623 🔴757 🔴19
STSO1020 steps (1001-1020)879 (+5.2%)946 🔴1016 🔴1053 🔴19
WOstream1285 (±0%)18421927221130
WOhook + stream1722 (+20%)20882228245730
SLstream4728 (-3.8%)5657 🔴5729 🔴6087 🔴30
SLhook + stream4884 (-4.8%)5494 🔴5649 🔴5830 🔴30

461aae4

Wed, 15 Jul 2026 18:24:40 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1507 (+17%)1791 🔴1840 🔴2486 🔴30
TTFShook + stream1703 (+19%)2090 🔴2139 🔴2332 🔴30
STSO1020 steps (1-20)308 (+12%)339 🔴468 🔴540 🔴19
STSO1020 steps (101-120)412 (-1.7%)423 🔴544 🔴546 🔴19
STSO1020 steps (1001-1020)971 (+16%)964 🔴1105 🔴2031 🔴19
WOstream1507 (+17%)17911840248630
WOhook + stream1703 (+19%)20902139233230
SLstream4887 (-0.5%)5499 🔴5767 🔴5984 🔴30
SLhook + stream4843 (-5.6%)5424 🔴5763 🔴5903 🔴30

Avg deltas compare against the most recent benchmark run on main at the time of this run.

Metrics — TTFS: time to first step body execution · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (time outside step bodies, client start → last step body exit) · SL: stream latency (first chunk write → visible to the reader)

Scenarios — stream: one step that streams chunks back to the client; no hooks, so the run stays in turbo mode · hook + stream: registers a hook before the same streaming step, which exits turbo mode · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges

🟢/🔴 mark percentiles within/above target. Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

TTFS/WO compare client vs deployment clocks and SL compares the step runner’s clock vs the client’s (NTP-synced in CI). WO ends at the last step body exit, the closest observable proxy for the final step-completion request.

@github-actions

github-actionsBot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
❌ ▲ Vercel Production145212301683
✅ 💻 Local Development161702191836
✅ 📦 Local Production161702191836
✅ 🐘 Local Postgres161702191836
✅ 🪟 Windows15300153
✅ 📋 Other89401771071
✅ vercel-multi-region270027
Total7377110648442

❌ Failed Tests

▲ Vercel Production (1 failed)

fastify (1 failed):

Details by Category

❌ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro126027
✅ example126027
✅ express126027
❌ fastify125127
✅ hono126027
✅ nextjs-turbopack15003
✅ nextjs-webpack15003
✅ nitro126027
✅ nuxt126027
✅ sveltekit14508
✅ vite126027
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15300
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128025
✅ e2e-local-dev-tanstack-start-128025
✅ e2e-local-postgres-nest-stable128025
✅ e2e-local-postgres-tanstack-start-128025
✅ e2e-local-prod-nest-stable128025
✅ e2e-local-prod-tanstack-start-128025
✅ e2e-vercel-prod-tanstack-start126027
✅ vercel-multi-region
AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: failure
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: success
  • Windows: success

Check the workflow run for details.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both fixes are well-motivated and the diagnosis writeup is excellent. I verified the mechanics independently:

  • ulid@3.0.1's decodeTime bound is TIME_MAX = 2^48 - 1, so a tagged timestamp (real time + 2^47) decodes without throwing and the subtract-after-guard in runIdCreatedAt is correct. Using subtraction rather than a bitwise mask also matters here since JS bitwise ops truncate at 32 bits. ✅
  • Drop-instead-of-clamp is the right failure mode: a future multi-bit tagging scheme that the MSB-clear doesn't fully handle would surface as missing samples via the MAX_CLOCK_SKEW_MS guard rather than an all-zero distribution. ✅
  • v4 wire compat: parseV4EventMeta in workflow-server picks known fields and ignores unknown meta keys, so older servers tolerate the new sdkVersion frame meta. ✅
  • version.ts is genversion-generated in build/typecheck/dev scripts, and release builds run after the changeset version bump, so the reported version is correct on published artifacts. ✅

One blocking issue on release ordering for the sdkVersion field (inline).

? { eventCount: tracking.eventCount }
: {}),
optimizations,
sdkVersion: coreVersion,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking (release ordering, not this code): core attaches sdkVersion to step-terminal eventData unconditionally, but workflow-server's persistence strip list (CLIENT_LATENCY_TELEMETRY_FIELDS in lib/data/events.ts) is ['ttfs','stso','stepCount','eventCount','optimizations'] and does not include sdkVersion, and snapshotClientLatencyTelemetry doesn't consume it either.

On the v4 wire this is safe (world-vercel moves it to frame meta; the server ignores unknown meta fields and never copies meta into eventData). But on the v2/v3 wire, eventData is sent raw, so sdkVersion would be persisted into the stored, user-visible event's eventData, violating the server's documented invariant that latency telemetry is "aggregate telemetry, not run state" and stays out of event reads and DynamoDB. The affected cohort is old @workflow/world-vercel + new @workflow/core resolved independently in a consumer lockfile, which is exactly the mixed-version scenario this PR's motivation describes (unless the old v3 client path zod-strips unknown eventData keys before POSTing; I couldn't rule that in or out from the current repo since the v3 implementation was replaced).

Since merging here auto-releases via changesets, can we land and deploy the server-side counterpart first (add sdkVersion to the strip list + pick it up in snapshotClientLatencyTelemetry/metric tags)? That both closes the leak and makes the field actually consumable, so the release order becomes server-then-client and this PR merges as-is.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry this wasn't ready for review yet, should have made it a draft PR. The core version stuff is something the AI add unilaterally, removed now

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed after the sdkVersion removal — my previous blocking item is resolved, and the remaining diff is the TTFS fix I already verified mechanically (ulid decodeTime accepts tagged timestamps since TIME_MAX = 2^48 - 1; subtraction rather than a 32-bit-truncating bitwise mask; drop-instead-of-clamp surfaces future anchor corruption as missing data instead of a zeroed distribution). Test coverage is thorough at all three layers (codec, compute, runtime e2e).

The EXECUTION_ADJACENCY addition (icn1 tolerating hnd1) is a reasonable geo-elastic tolerance — Seoul→Tokyo is nearer than the already-listed syd1.

One recommendation inline about delegating the tag-bit knowledge to @workflow/world-vercel/run-id's decode() — non-blocking since the current code is correct, but worth doing before merge while the file is hot.

try {
return decodeTime(ulidPart);
const time = decodeTime(ulidPart);
return time >= TIMESTAMP_TAG_BIT ? time - TIMESTAMP_TAG_BIT : time;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommendation (non-blocking): consider delegating the tag-bit handling to the scheme's own codec instead of hand-rolling the clear here:

import{decodeTime}from'ulid';import{decode}from'@workflow/world-vercel/run-id';// in the try block:returndecodeTime(decode(ulidPart).ulid);

decode() already returns the ULID with the tag bit cleared, throws on malformed input (your existing catch covers that), and @workflow/core already depends on @workflow/world-vercel, so this adds no new edge (run-id is a leaf module, no cycle).

The reason it matters: the tagging scheme carries a 5-bit version field, i.e. it explicitly anticipates layout evolution. If a future version moves bits around, decode() in world-vercel gets updated as the single source of truth and this anchor stays correct automatically, whereas a local 2^47 subtraction can silently diverge. The MAX_CLOCK_SKEW_MS drop guard you added would at least turn that divergence into missing data rather than bad data, which is why I'm not calling this blocking — but single-sourcing the codec removes the failure mode entirely.

If you'd rather keep core free of scheme knowledge as a matter of principle, the current MSB-clear is defensible too (a set MSB can never be a real timestamp under any scheme) — in that case maybe just soften the doc comment's implication that MSB-tagging is a general World.createRunId() convention, since today it's specifically world-vercel's.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 8a7f1cfrunIdCreatedAt now delegates to decode() from @workflow/world-vercel/run-id (verified: the dependency already exists in core's dependencies, ./run-id is an exported subpath, and world-vercel has no dependency back on core, so no cycle). The hand-rolled TIMESTAMP_TAG_BIT constant is gone; the doc comment now points at the codec as the single source of truth. Tests (34) and typecheck pass.

Review feedback: instead of hand-rolling the timestamp tag-bit clear
(2^47 subtraction), use decode() from @workflow/world-vercel/run-id,
which returns the ULID with the tag bit cleared and throws on malformed
input. The tagging scheme carries a 5-bit version field, so if the
layout ever evolves the codec stays the single source of truth and this
anchor tracks it automatically.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@VaguelySerious
VaguelySerious merged commit fd107b9 into mainJul 15, 2026
102 of 104 checks passed
@github-actionsgithub-actionsBot mentioned this pull request Jul 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for fd107b9 (AI decision).

This fixes a bug in the TTFS/STSO step-latency telemetry feature, which is main-only: the touched source files (packages/core/src/runtime/run-id-time.ts and step-latency.ts) and their tests do not exist on stable, and stable's runtime.test.ts contains no latency-telemetry tests to attach the runtime.test.ts change to. Since the corrected functionality is entirely absent from stable, there is nothing to backport.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

fd107b9c33db397b513ef134f458a1083bde7d98

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@VaguelySerious@TooTallNate