From 0d09165368b3362e928ee54f75346c5f6fff8233 Mon Sep 17 00:00:00 2001 From: Sam Macbeth Date: Wed, 12 Feb 2025 15:39:48 +0100 Subject: [PATCH 1/3] Send enrollment dates in ET, and align conversion windows with ET days. --- .../background/components/abn-experiments.js | 34 +++++++++++++++++-- unit-test/background/abn-framework.js | 27 ++++++++++++++- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/shared/js/background/components/abn-experiments.js b/shared/js/background/components/abn-experiments.js index ddb3f4523..1fa5d3a83 100644 --- a/shared/js/background/components/abn-experiments.js +++ b/shared/js/background/components/abn-experiments.js @@ -88,10 +88,11 @@ export default class AbnExperimentMetrics { markExperimentEnrolled(featureName, subFeatureName, metrics) { const cohort = this.remoteConfig.getCohort(featureName, subFeatureName); if (cohort && !cohort.enrolledAt) { - cohort.enrolledAt = Date.now(); + // We set the enrollment timestamp as the start of the current day in ET, so that all conversion windows align with ET date changes. + cohort.enrolledAt = startOfDayET(Date.now()); cohort.metrics = (metrics || generateRetentionMetrics()).map((m) => ({ ...m, counter: 0, sent: false })); sendPixelRequest(`experiment_enroll_${subFeatureName}_${cohort.name}`, { - enrollmentDate: new Date(cohort.enrolledAt).toISOString().slice(0, 10), + enrollmentDate: getDateStringET(cohort.enrolledAt), }); // updated stored cohort metadata this.remoteConfig.setCohort(featureName, subFeatureName, cohort); @@ -120,7 +121,7 @@ export default class AbnExperimentMetrics { if (!cohort?.metrics || !cohort?.enrolledAt) { return; } - const enrollmentDate = new Date(cohort.enrolledAt).toISOString().slice(0, 10); + const enrollmentDate = getDateStringET(cohort.enrolledAt); const daysSinceEnrollment = Math.floor(((timestamp || Date.now()) - cohort.enrolledAt) / (1000 * 60 * 60 * 24)); // Find metrics for this experiment that match at this point in time. // i.e. we are within the conversion window, and haven't sent the pixel yet. @@ -155,3 +156,30 @@ export default class AbnExperimentMetrics { }); } } + +/** + * Given a timestamp or date, returns a string in ISO (YYYY-MM-DD) format corresponding for the date + * at that instant in Eastern Time (UTC-5). + * @param {number} timestamp + * @returns {string} Date string in ISO (YYYY-MM-DD) format. + */ +export function getDateStringET(timestamp) { + // toLocaleDateString can return the date in our chosen timezone (in this case ET), however the output is also localized. + // We can use the 'sv' locale to get a date format that matches ISO (YYYY-MM-DD) + return new Date(timestamp).toLocaleDateString('sv', { timeZone: '-05:00' }); +} + +/** + * Calculates the unix timestamp for the start of the current day in Eastern Time (UTC-5) from the provided + * timestamp. + * @param {number} now + * @returns {number} Timestamp for midnight on the current day in ET. + */ +export function startOfDayET(now = Date.now()) { + const d = new Date(now); + // Before 05:00 UTC, move back one day + if (d.getUTCHours() < 5) { + d.setUTCDate(d.getUTCDate() - 1); + } + return d.setUTCHours(5, 0, 0, 0); +} diff --git a/unit-test/background/abn-framework.js b/unit-test/background/abn-framework.js index e94f7528e..36b0e6308 100644 --- a/unit-test/background/abn-framework.js +++ b/unit-test/background/abn-framework.js @@ -2,7 +2,7 @@ import { ParamsValidator } from '@duckduckgo/pixel-schema/src/params_validator.m import messageHandlers from '../../shared/js/background/message-handlers'; import RemoteConfig, { choseCohort } from '../../shared/js/background/components/remote-config'; -import AbnExperimentMetrics from '../../shared/js/background/components/abn-experiments'; +import AbnExperimentMetrics, { getDateStringET, startOfDayET } from '../../shared/js/background/components/abn-experiments'; import load from '../../shared/js/background/load'; import commonParams from '../../pixel-definitions/common_params.json'; import commonSuffixes from '../../pixel-definitions/common_suffixes.json'; @@ -351,3 +351,28 @@ describe('ABN pixels', () => { expect(pixelRequests.pop()).toContain('conversionWindowDays=5-7&value=4'); }); }); + +describe('startOfDayET', () => { + it('always returns a timestamp in the past', () => { + expect(startOfDayET()).toBeLessThan(Date.now()); + }); + + it('returns a timestamp corresponding to the start of the current day in ET', () => { + const ts = new Date('2025-02-01T06:00:00'); + expect(new Date(startOfDayET(ts))).toEqual(new Date('Feb 1 2025 00:00:00 UTC-0500')); + ts.setUTCHours(23); + expect(new Date(startOfDayET(ts))).toEqual(new Date('Feb 1 2025 00:00:00 UTC-0500')); + }); + + it('handles day rollover', () => { + const ts = new Date('2025-02-01T03:00:00'); + expect(new Date(startOfDayET(ts))).toEqual(new Date('Jan 31 2025 00:00:00 UTC-0500')); + }); +}); + +describe('getDateStringET', () => { + it('returns a unix timestamp as the date in ET at that instant', () => { + expect(getDateStringET(new Date('2025-02-01T06:00:00'))).toEqual('2025-02-01'); + expect(getDateStringET(new Date('2025-02-01T02:00:00'))).toEqual('2025-01-31'); + }); +}); From 4abb2a5947b263e7c4bbc7612092285b8ef9a875 Mon Sep 17 00:00:00 2001 From: Sam Macbeth Date: Thu, 13 Feb 2025 11:31:42 +0100 Subject: [PATCH 2/3] Delay app_use metric triggering to prevent potential race --- shared/js/background/metrics.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/js/background/metrics.js b/shared/js/background/metrics.js index 37edd75c8..276924737 100644 --- a/shared/js/background/metrics.js +++ b/shared/js/background/metrics.js @@ -16,7 +16,7 @@ export class AppUseMetric { // trigger on construction: happens whenever the service worker is spun up, which should correlate with browser activity. // Note: we don't care about overtriggering here, as the experiment framework will handle deduplication. // This metric will only correlate with when the browser is open, and not necessarily if it is being 'used'. - abnMetrics.remoteConfig.ready.then(() => abnMetrics.onMetricTriggered('app_use')); + abnMetrics.remoteConfig.allLoadingFinished.then(() => setTimeout(() => abnMetrics.onMetricTriggered('app_use'), 5000)); } } From e03c13439bd013abd865e12ad7859b70a384fb03 Mon Sep 17 00:00:00 2001 From: Sam Macbeth Date: Mon, 17 Feb 2025 10:26:00 +0100 Subject: [PATCH 3/3] Update naming to be explicit about dates being EST. --- .../background/components/abn-experiments.js | 16 ++++++++-------- unit-test/background/abn-framework.js | 18 +++++++++--------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/shared/js/background/components/abn-experiments.js b/shared/js/background/components/abn-experiments.js index 1fa5d3a83..68efd5827 100644 --- a/shared/js/background/components/abn-experiments.js +++ b/shared/js/background/components/abn-experiments.js @@ -89,10 +89,10 @@ export default class AbnExperimentMetrics { const cohort = this.remoteConfig.getCohort(featureName, subFeatureName); if (cohort && !cohort.enrolledAt) { // We set the enrollment timestamp as the start of the current day in ET, so that all conversion windows align with ET date changes. - cohort.enrolledAt = startOfDayET(Date.now()); + cohort.enrolledAt = startOfDayEST(Date.now()); cohort.metrics = (metrics || generateRetentionMetrics()).map((m) => ({ ...m, counter: 0, sent: false })); sendPixelRequest(`experiment_enroll_${subFeatureName}_${cohort.name}`, { - enrollmentDate: getDateStringET(cohort.enrolledAt), + enrollmentDate: getDateStringEST(cohort.enrolledAt), }); // updated stored cohort metadata this.remoteConfig.setCohort(featureName, subFeatureName, cohort); @@ -121,7 +121,7 @@ export default class AbnExperimentMetrics { if (!cohort?.metrics || !cohort?.enrolledAt) { return; } - const enrollmentDate = getDateStringET(cohort.enrolledAt); + const enrollmentDate = getDateStringEST(cohort.enrolledAt); const daysSinceEnrollment = Math.floor(((timestamp || Date.now()) - cohort.enrolledAt) / (1000 * 60 * 60 * 24)); // Find metrics for this experiment that match at this point in time. // i.e. we are within the conversion window, and haven't sent the pixel yet. @@ -159,23 +159,23 @@ export default class AbnExperimentMetrics { /** * Given a timestamp or date, returns a string in ISO (YYYY-MM-DD) format corresponding for the date - * at that instant in Eastern Time (UTC-5). + * at that instant in Eastern Summer Time (UTC-5). * @param {number} timestamp * @returns {string} Date string in ISO (YYYY-MM-DD) format. */ -export function getDateStringET(timestamp) { +export function getDateStringEST(timestamp) { // toLocaleDateString can return the date in our chosen timezone (in this case ET), however the output is also localized. // We can use the 'sv' locale to get a date format that matches ISO (YYYY-MM-DD) return new Date(timestamp).toLocaleDateString('sv', { timeZone: '-05:00' }); } /** - * Calculates the unix timestamp for the start of the current day in Eastern Time (UTC-5) from the provided + * Calculates the unix timestamp for the start of the current day in Eastern Summer Time (UTC-5) from the provided * timestamp. * @param {number} now - * @returns {number} Timestamp for midnight on the current day in ET. + * @returns {number} Timestamp for midnight on the current day in EST. */ -export function startOfDayET(now = Date.now()) { +export function startOfDayEST(now = Date.now()) { const d = new Date(now); // Before 05:00 UTC, move back one day if (d.getUTCHours() < 5) { diff --git a/unit-test/background/abn-framework.js b/unit-test/background/abn-framework.js index 36b0e6308..fa1121a91 100644 --- a/unit-test/background/abn-framework.js +++ b/unit-test/background/abn-framework.js @@ -2,7 +2,7 @@ import { ParamsValidator } from '@duckduckgo/pixel-schema/src/params_validator.m import messageHandlers from '../../shared/js/background/message-handlers'; import RemoteConfig, { choseCohort } from '../../shared/js/background/components/remote-config'; -import AbnExperimentMetrics, { getDateStringET, startOfDayET } from '../../shared/js/background/components/abn-experiments'; +import AbnExperimentMetrics, { getDateStringEST, startOfDayEST } from '../../shared/js/background/components/abn-experiments'; import load from '../../shared/js/background/load'; import commonParams from '../../pixel-definitions/common_params.json'; import commonSuffixes from '../../pixel-definitions/common_suffixes.json'; @@ -352,27 +352,27 @@ describe('ABN pixels', () => { }); }); -describe('startOfDayET', () => { +describe('startOfDayEST', () => { it('always returns a timestamp in the past', () => { - expect(startOfDayET()).toBeLessThan(Date.now()); + expect(startOfDayEST()).toBeLessThan(Date.now()); }); it('returns a timestamp corresponding to the start of the current day in ET', () => { const ts = new Date('2025-02-01T06:00:00'); - expect(new Date(startOfDayET(ts))).toEqual(new Date('Feb 1 2025 00:00:00 UTC-0500')); + expect(new Date(startOfDayEST(ts))).toEqual(new Date('Feb 1 2025 00:00:00 UTC-0500')); ts.setUTCHours(23); - expect(new Date(startOfDayET(ts))).toEqual(new Date('Feb 1 2025 00:00:00 UTC-0500')); + expect(new Date(startOfDayEST(ts))).toEqual(new Date('Feb 1 2025 00:00:00 UTC-0500')); }); it('handles day rollover', () => { const ts = new Date('2025-02-01T03:00:00'); - expect(new Date(startOfDayET(ts))).toEqual(new Date('Jan 31 2025 00:00:00 UTC-0500')); + expect(new Date(startOfDayEST(ts))).toEqual(new Date('Jan 31 2025 00:00:00 UTC-0500')); }); }); -describe('getDateStringET', () => { +describe('getDateStringEST', () => { it('returns a unix timestamp as the date in ET at that instant', () => { - expect(getDateStringET(new Date('2025-02-01T06:00:00'))).toEqual('2025-02-01'); - expect(getDateStringET(new Date('2025-02-01T02:00:00'))).toEqual('2025-01-31'); + expect(getDateStringEST(new Date('2025-02-01T06:00:00'))).toEqual('2025-02-01'); + expect(getDateStringEST(new Date('2025-02-01T02:00:00'))).toEqual('2025-01-31'); }); });