Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { convertValidTimestamp, resolveIdentifiers, isIsoDate } from '../utils'
import { convertAttributeTimestamps, convertValidTimestamp, resolveIdentifiers, isIsoDate } from '../utils'

describe('isIsoDate', () => {
it('should return true for valid ISO date with fractional seconds from 1-9 digits', () => {
Expand Down Expand Up @@ -84,4 +84,49 @@ describe('convertValidTimestamp', () => {
it('should leave decimal unix timestamps unchanged', () => {
expect(convertValidTimestamp('1712345678.123')).toBe('1712345678.123')
})

it('should convert a 7-digit fractional second ISO timestamp to unix', () => {
expect(convertValidTimestamp('2024-08-14T20:36:48.6527521Z')).toBe(1723667808)
})

it('should convert a 9-digit fractional second ISO timestamp to unix', () => {
expect(convertValidTimestamp('2024-08-14T20:36:48.652752100Z')).toBe(1723667808)
})

it('should leave non-date strings unchanged', () => {
expect(convertValidTimestamp('not-a-date')).toBe('not-a-date')
})
})

describe('convertAttributeTimestamps — sub-millisecond fractional seconds (STRATCONN-4121)', () => {
const realParse = Date.parse
afterEach(() => jest.restoreAllMocks())

it('converts a 7-digit fractional second timestamp when Date.parse rejects long fractions (prod runtime)', () => {
jest.spyOn(Date, 'parse').mockImplementation((s: string) => {
const m = /\.(\d+)/.exec(s)
return m && m[1].length > 5 ? NaN : realParse(s)
})
const result = convertAttributeTimestamps({ createdat: '2024-08-14T20:36:48.6527521Z' })
expect(result.createdat).toBe(1723667808)
})

it('converts a 9-digit fractional second timestamp when Date.parse rejects long fractions (prod runtime)', () => {
jest.spyOn(Date, 'parse').mockImplementation((s: string) => {
const m = /\.(\d+)/.exec(s)
return m && m[1].length > 5 ? NaN : realParse(s)
})
const result = convertAttributeTimestamps({ ts: '2024-08-14T20:36:48.652752100Z' })
expect(result.ts).toBe(1723667808)
})

it('still converts a standard 3-digit millisecond timestamp', () => {
const result = convertAttributeTimestamps({ createdat: '2024-08-14T20:36:48.652Z' })
expect(result.createdat).toBe(1723667808)
})

it('leaves non-date strings unchanged', () => {
const result = convertAttributeTimestamps({ name: 'Acme Corp' })
expect(result.name).toBe('Acme Corp')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ export enum AccountRegion {
EU = 'EU 🇪🇺'
}

// Normalize ISO timestamp fractional seconds to 3 digits so dayjs parses reliably.
// dayjs only handles millisecond precision; sub-millisecond digits cause silent
// misparse. Unix timestamps are second-level anyway so no precision is lost.
const normalizeIsoFractionalSeconds = (value: string): string =>
value.replace(/(\.\d{3})\d+(Z|[+-]\d{2}:?\d{2}|$)/, '$1$2')

export const convertValidTimestamp = <Value = unknown>(value: Value): Value | number => {
// Timestamps may be on a `string` field, so check if the string is only
// digits (optionally with a fractional part). If it is, ignore it since
Expand All @@ -47,7 +53,7 @@ export const convertValidTimestamp = <Value = unknown>(value: Value): Value | nu
return value
}

const maybeDate = dayjs.utc(value)
const maybeDate = dayjs.utc(normalizeIsoFractionalSeconds(value))

if (maybeDate.isValid()) {
return maybeDate.unix()
Expand All @@ -66,11 +72,15 @@ export const convertAttributeTimestamps = <Payload extends {}>(payload: Payload)
const value = payload[key]

if (typeof value === 'string') {
// Parse only ISO 8601 date formats in strict mode
const maybeDate = dayjs(value)

if (isIsoDate(value)) {
;(clone[key] as unknown) = maybeDate.unix()
// Normalize sub-millisecond fractional seconds BEFORE the isIsoDate gate.
// In Segment's prod runtime, Date.parse returns NaN for >5 fractional digits,
// so isIsoDate(value) would return false and normalization would never run.
// Normalizing first ensures the gate sees a valid parseable string.
// See: STRATCONN-4121, PR #3923 review feedback from joe-ayoub-segment.
const normalized = normalizeIsoFractionalSeconds(value)

if (isIsoDate(normalized)) {
;(clone[key] as unknown) = dayjs(normalized).unix()
return
}
}
Expand Down
Loading