diff --git a/packages/destination-actions/src/destinations/customerio/__tests__/utils.test.ts b/packages/destination-actions/src/destinations/customerio/__tests__/utils.test.ts index 88aab5d388..2446a3ba99 100644 --- a/packages/destination-actions/src/destinations/customerio/__tests__/utils.test.ts +++ b/packages/destination-actions/src/destinations/customerio/__tests__/utils.test.ts @@ -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', () => { @@ -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') + }) }) diff --git a/packages/destination-actions/src/destinations/customerio/utils.ts b/packages/destination-actions/src/destinations/customerio/utils.ts index 94718c56a0..514c439cbc 100644 --- a/packages/destination-actions/src/destinations/customerio/utils.ts +++ b/packages/destination-actions/src/destinations/customerio/utils.ts @@ -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: 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 @@ -47,7 +53,7 @@ export const convertValidTimestamp = (value: Value): Value | nu return value } - const maybeDate = dayjs.utc(value) + const maybeDate = dayjs.utc(normalizeIsoFractionalSeconds(value)) if (maybeDate.isValid()) { return maybeDate.unix() @@ -66,11 +72,15 @@ export const convertAttributeTimestamps = (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 } }