From 80a90fc3e809dc1b0fe93c0eafbcf41bdc0ba104 Mon Sep 17 00:00:00 2001 From: Ravi Patel Date: Sat, 17 Oct 2026 17:21:35 -0400 Subject: [PATCH] fix(webapp): prevent IEEE 754 precision loss on OTLP nanosecond conversions Fixes #3292 Root Cause: Converting epoch milliseconds to nanoseconds via `BigInt(ms * 1_000_000)` performs multiplication in IEEE 754 float representation before casting to BigInt. Because epoch ms * 1e6 is ~1.7e18 and exceeds Number.MAX_SAFE_INTEGER (~9e15), the lowest bits lose precision, creating +-256ns non-deterministic drift in span ordering and duration metrics. Resolution: Cast epoch ms to BigInt before multiplying by BigInt(1_000_000) across all webapp event repository and run engine call sites. --- .../v3/eventRepository/common.server.test.ts | 25 +++++++++++++++++++ .../app/v3/eventRepository/common.server.ts | 4 +-- .../app/v3/eventRepository/index.server.ts | 2 +- .../webapp/app/v3/runEngineHandlers.server.ts | 2 +- 4 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 apps/webapp/app/v3/eventRepository/common.server.test.ts diff --git a/apps/webapp/app/v3/eventRepository/common.server.test.ts b/apps/webapp/app/v3/eventRepository/common.server.test.ts new file mode 100644 index 00000000000..f2be2b290c5 --- /dev/null +++ b/apps/webapp/app/v3/eventRepository/common.server.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { getNowInNanoseconds, calculateDurationFromStart } from "./common.server"; + +describe("OTLP nanosecond precision", () => { + it("guarantees exact integer arithmetic without float truncation", () => { + const sampleMs = 1729200000123; + const sampleDate = new Date(sampleMs); + const expectedNano = BigInt(sampleMs) * 1_000_000n; + + // Buggy float math produces 1729200000123000064n (+64ns error) + const buggyNano = BigInt(sampleMs * 1_000_000); + expect(buggyNano).not.toBe(expectedNano); + + // Fixed calculation produces exact 1729200000123000000n + const fixedNano = BigInt(sampleDate.getTime()) * BigInt(1_000_000); + expect(fixedNano).toBe(expectedNano); + }); + + it("preserves exact duration calculation", () => { + const start = 1729200000000n * 1_000_000n; + const end = new Date(1729200000500); // 500ms later + const duration = calculateDurationFromStart(end, start); + expect(duration).toBe(500_000_000); + }); +}); diff --git a/apps/webapp/app/v3/eventRepository/common.server.ts b/apps/webapp/app/v3/eventRepository/common.server.ts index 346e2ab6419..3dce9202eca 100644 --- a/apps/webapp/app/v3/eventRepository/common.server.ts +++ b/apps/webapp/app/v3/eventRepository/common.server.ts @@ -26,7 +26,7 @@ export function extractContextFromCarrier(carrier: Record) { } export function getNowInNanoseconds(): bigint { - return BigInt(new Date().getTime() * 1_000_000); + return BigInt(new Date().getTime()) * BigInt(1_000_000); } export function getDateFromNanoseconds(nanoseconds: bigint): Date { @@ -40,7 +40,7 @@ export function calculateDurationFromStart( ) { const $endtime = typeof endTime === "string" ? new Date(endTime) : endTime; - const duration = Number(BigInt($endtime.getTime() * 1_000_000) - startTime); + const duration = Number(BigInt($endtime.getTime()) * BigInt(1_000_000) - startTime); if (minimumDuration && duration < minimumDuration) { return minimumDuration; diff --git a/apps/webapp/app/v3/eventRepository/index.server.ts b/apps/webapp/app/v3/eventRepository/index.server.ts index c599f4e6b96..670ec60f014 100644 --- a/apps/webapp/app/v3/eventRepository/index.server.ts +++ b/apps/webapp/app/v3/eventRepository/index.server.ts @@ -208,7 +208,7 @@ async function recordRunEvent( runId: foundRun.friendlyId, ...attributes, }, - startTime: BigInt((startTime?.getTime() ?? Date.now()) * 1_000_000), + startTime: BigInt(startTime?.getTime() ?? Date.now()) * BigInt(1_000_000), ...optionsRest, }); diff --git a/apps/webapp/app/v3/runEngineHandlers.server.ts b/apps/webapp/app/v3/runEngineHandlers.server.ts index ee4ef2e278d..08e3194bfc7 100644 --- a/apps/webapp/app/v3/runEngineHandlers.server.ts +++ b/apps/webapp/app/v3/runEngineHandlers.server.ts @@ -557,7 +557,7 @@ export function registerRunEngineEventBusHandlers() { ); await eventRepository.recordEvent(retryMessage, { - startTime: BigInt(time.getTime() * 1000000), + startTime: BigInt(time.getTime()) * BigInt(1_000_000), taskSlug: run.taskIdentifier, environment, attributes: {