From c25e2a673895a5f5d9f20571bb2eb6e81ebb837a Mon Sep 17 00:00:00 2001 From: Daniel Graham Date: Fri, 27 Feb 2026 15:01:40 -0800 Subject: [PATCH 1/7] feat(analytics-browser): add Form Abandoned --- packages/analytics-browser/src/constants.ts | 1 + .../src/plugins/form-interaction-tracking.ts | 24 +++++++++++-- .../plugins/form-interaction-tracking.test.ts | 34 +++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/packages/analytics-browser/src/constants.ts b/packages/analytics-browser/src/constants.ts index da6c9d46e3..de200e6d18 100644 --- a/packages/analytics-browser/src/constants.ts +++ b/packages/analytics-browser/src/constants.ts @@ -5,6 +5,7 @@ export const DEFAULT_EVENT_PREFIX = '[Amplitude]'; export const DEFAULT_PAGE_VIEW_EVENT = `${DEFAULT_EVENT_PREFIX} Page Viewed`; export const DEFAULT_FORM_START_EVENT = `${DEFAULT_EVENT_PREFIX} Form Started`; export const DEFAULT_FORM_SUBMIT_EVENT = `${DEFAULT_EVENT_PREFIX} Form Submitted`; +export const DEFAULT_FORM_ABANDONED_EVENT = `${DEFAULT_EVENT_PREFIX} Form Abandoned`; export const DEFAULT_FILE_DOWNLOAD_EVENT = `${DEFAULT_EVENT_PREFIX} File Downloaded`; export const DEFAULT_SESSION_START_EVENT = 'session_start'; export const DEFAULT_SESSION_END_EVENT = 'session_end'; diff --git a/packages/analytics-browser/src/plugins/form-interaction-tracking.ts b/packages/analytics-browser/src/plugins/form-interaction-tracking.ts index 99269fc594..1dfe9be053 100644 --- a/packages/analytics-browser/src/plugins/form-interaction-tracking.ts +++ b/packages/analytics-browser/src/plugins/form-interaction-tracking.ts @@ -1,6 +1,7 @@ import { DEFAULT_FORM_START_EVENT, DEFAULT_FORM_SUBMIT_EVENT, + DEFAULT_FORM_ABANDONED_EVENT, FORM_ID, FORM_NAME, FORM_DESTINATION, @@ -73,16 +74,35 @@ export const formInteractionTracking = (): EnrichmentPlugin => { let hasFormChanged = false; + const win = getGlobalScope(); + + const trackFormAbandoned = () => { + if (hasFormChanged) { + const formDestination = extractFormAction(form); + amplitude.track(DEFAULT_FORM_ABANDONED_EVENT, { + [FORM_ID]: stringOrUndefined(form.id), + [FORM_NAME]: stringOrUndefined(form.name), + [FORM_DESTINATION]: formDestination, + }); + } + }; + + // TODO: check "frustrationInteractions.shouldTrackEventResolver" + /* istanbul ignore next */ + win?.addEventListener('pagehide', trackFormAbandoned); + /* istanbul ignore next */ + win?.addEventListener('beforeunload', trackFormAbandoned); + addEventListener(form, 'change', () => { - const formDestination = extractFormAction(form); if (!hasFormChanged) { + hasFormChanged = true; + const formDestination = extractFormAction(form); amplitude.track(DEFAULT_FORM_START_EVENT, { [FORM_ID]: stringOrUndefined(form.id), [FORM_NAME]: stringOrUndefined(form.name), [FORM_DESTINATION]: formDestination, }); } - hasFormChanged = true; }); addEventListener(form, 'submit', (event: Event) => { diff --git a/packages/analytics-browser/test/plugins/form-interaction-tracking.test.ts b/packages/analytics-browser/test/plugins/form-interaction-tracking.test.ts index 3393e1a8ee..04d4443556 100644 --- a/packages/analytics-browser/test/plugins/form-interaction-tracking.test.ts +++ b/packages/analytics-browser/test/plugins/form-interaction-tracking.test.ts @@ -541,4 +541,38 @@ describe('formInteractionTracking', () => { }); }); }); + + describe('shouldTrackAbandoned', () => { + beforeEach(async () => { + const config = createConfigurationMock(); + const plugin = formInteractionTracking(); + await plugin.setup?.(config, amplitude); + window.dispatchEvent(new Event('load')); + }); + + test('should track form abandoned when form started then abandoned (pagehide)', async () => { + document.getElementById('my-form-id')?.dispatchEvent(new Event('change')); + window.dispatchEvent(new Event('pagehide')); + expect(amplitude.track).toHaveBeenNthCalledWith(2, '[Amplitude] Form Abandoned', { + [FORM_ID]: 'my-form-id', + [FORM_NAME]: 'my-form-name', + [FORM_DESTINATION]: 'http://localhost/submit', + }); + }); + + test('should track form abandoned when form started then abandoned (beforeunload)', async () => { + document.getElementById('my-form-id')?.dispatchEvent(new Event('change')); + window.dispatchEvent(new Event('beforeunload')); + expect(amplitude.track).toHaveBeenNthCalledWith(2, '[Amplitude] Form Abandoned', { + [FORM_ID]: 'my-form-id', + [FORM_NAME]: 'my-form-name', + [FORM_DESTINATION]: 'http://localhost/submit', + }); + }); + + test('should not track form abandoned when form is not started', async () => { + window.dispatchEvent(new Event('pagehide')); + expect(amplitude.track).not.toHaveBeenCalled(); + }); + }); }); From 8564a111e11092005707344962ed02ed7419d1c3 Mon Sep 17 00:00:00 2001 From: Daniel Graham Date: Fri, 27 Feb 2026 15:01:58 -0800 Subject: [PATCH 2/7] feat(analytics-browser): add Form Abandoned --- .../e2e/form-interactions.spec.ts | 42 +++++++++++++++++ .../form-interactions/form-interactions.html | 45 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 packages/analytics-browser/e2e/form-interactions.spec.ts create mode 100644 test-server/form-interactions/form-interactions.html diff --git a/packages/analytics-browser/e2e/form-interactions.spec.ts b/packages/analytics-browser/e2e/form-interactions.spec.ts new file mode 100644 index 0000000000..49b6beee78 --- /dev/null +++ b/packages/analytics-browser/e2e/form-interactions.spec.ts @@ -0,0 +1,42 @@ +import { test, expect } from '@playwright/test'; + +const FORM_STARTED_EVENT = '[Amplitude] Form Started'; +const FORM_ABANDONED_EVENT = '[Amplitude] Form Abandoned'; + +test.describe('Form Interactions Page', () => { + let requests: any[] = []; + + test.beforeEach(async ({ page }) => { + requests = []; + await page.route('https://api2.amplitude.com/2/httpapi', async (route) => { + const request = route.request(); + const postData = request.postData(); + if (postData) { + const data = JSON.parse(postData); + requests.push(data); + } + console.log('added request'); + await route.continue(); + }); + }); + + test('should track form started events', async ({ page }) => { + await page.goto('/form-interactions/form-interactions.html'); + await page.waitForLoadState('networkidle'); + + // Trigger form started by changing a form field (first interaction) and then navigate away to trigger pagehide/beforeunload → form abandoned + await page.fill('#name', 'Test User'); + await page.fill('#email', 'test@example.com'); + // mock dispatch pagehide event + await page.evaluate(() => window.dispatchEvent(new Event('beforeunload'))); + await page.waitForTimeout(2000); + + // Wait for the form started event to be sent + await expect(async () => { + const allEvents = requests.flatMap((r) => r.events || []) + .filter((e: any) => e.event_type !== '$identify'); + expect(allEvents.length).toBe(1); + expect(allEvents.some((e: any) => e.event_type === FORM_STARTED_EVENT)).toBe(true); + }).toPass({ timeout: 5000 }); + }); +}); diff --git a/test-server/form-interactions/form-interactions.html b/test-server/form-interactions/form-interactions.html new file mode 100644 index 0000000000..3d390951d4 --- /dev/null +++ b/test-server/form-interactions/form-interactions.html @@ -0,0 +1,45 @@ + + + + + + Form Interactions Test + + +

Form Interactions Test

+

This page initializes Amplitude with autocapture.formInteractions enabled.

+ +
+

+ + +

+

+ + +

+

+ + +

+

+ +

+
+ + + + From c10a323c488ef30f4aefa2efe15f07c8d4116c7f Mon Sep 17 00:00:00 2001 From: Daniel Graham Date: Fri, 27 Feb 2026 15:08:12 -0800 Subject: [PATCH 3/7] fix: PW test fix --- packages/analytics-browser/e2e/form-interactions.spec.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/analytics-browser/e2e/form-interactions.spec.ts b/packages/analytics-browser/e2e/form-interactions.spec.ts index 49b6beee78..b8aafb7ba0 100644 --- a/packages/analytics-browser/e2e/form-interactions.spec.ts +++ b/packages/analytics-browser/e2e/form-interactions.spec.ts @@ -1,7 +1,7 @@ +/* eslint-disable @typescript-eslint/no-unsafe-return */ import { test, expect } from '@playwright/test'; const FORM_STARTED_EVENT = '[Amplitude] Form Started'; -const FORM_ABANDONED_EVENT = '[Amplitude] Form Abandoned'; test.describe('Form Interactions Page', () => { let requests: any[] = []; @@ -33,8 +33,7 @@ test.describe('Form Interactions Page', () => { // Wait for the form started event to be sent await expect(async () => { - const allEvents = requests.flatMap((r) => r.events || []) - .filter((e: any) => e.event_type !== '$identify'); + const allEvents = requests.flatMap((r) => r.events || []).filter((e: any) => e.event_type !== '$identify'); expect(allEvents.length).toBe(1); expect(allEvents.some((e: any) => e.event_type === FORM_STARTED_EVENT)).toBe(true); }).toPass({ timeout: 5000 }); From c6c00caef8ea4314d00fb0563ecd53f7f2d079bc Mon Sep 17 00:00:00 2001 From: Daniel Graham Date: Fri, 27 Feb 2026 15:10:29 -0800 Subject: [PATCH 4/7] again --- packages/analytics-browser/e2e/form-interactions.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/analytics-browser/e2e/form-interactions.spec.ts b/packages/analytics-browser/e2e/form-interactions.spec.ts index b8aafb7ba0..b33044e152 100644 --- a/packages/analytics-browser/e2e/form-interactions.spec.ts +++ b/packages/analytics-browser/e2e/form-interactions.spec.ts @@ -34,7 +34,6 @@ test.describe('Form Interactions Page', () => { // Wait for the form started event to be sent await expect(async () => { const allEvents = requests.flatMap((r) => r.events || []).filter((e: any) => e.event_type !== '$identify'); - expect(allEvents.length).toBe(1); expect(allEvents.some((e: any) => e.event_type === FORM_STARTED_EVENT)).toBe(true); }).toPass({ timeout: 5000 }); }); From d38c5244cb995d9df81a6a911c19a0dc1e7c179d Mon Sep 17 00:00:00 2001 From: Daniel Graham Date: Fri, 27 Feb 2026 15:25:45 -0800 Subject: [PATCH 5/7] feat: gate abandoned behind experimental flag --- .../src/plugins/form-interaction-tracking.ts | 9 +++++---- .../test/plugins/form-interaction-tracking.test.ts | 8 +++++++- packages/analytics-core/src/types/form-interactions.ts | 7 +++++++ test-server/form-interactions/form-interactions.html | 4 +++- 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/analytics-browser/src/plugins/form-interaction-tracking.ts b/packages/analytics-browser/src/plugins/form-interaction-tracking.ts index 1dfe9be053..2aa7754209 100644 --- a/packages/analytics-browser/src/plugins/form-interaction-tracking.ts +++ b/packages/analytics-browser/src/plugins/form-interaction-tracking.ts @@ -88,10 +88,11 @@ export const formInteractionTracking = (): EnrichmentPlugin => { }; // TODO: check "frustrationInteractions.shouldTrackEventResolver" - /* istanbul ignore next */ - win?.addEventListener('pagehide', trackFormAbandoned); - /* istanbul ignore next */ - win?.addEventListener('beforeunload', trackFormAbandoned); + /* istanbul ignore if */ + if (formInteractionsConfig?.shouldTrackAbandoned) { + win?.addEventListener('pagehide', trackFormAbandoned); + win?.addEventListener('beforeunload', trackFormAbandoned); + } addEventListener(form, 'change', () => { if (!hasFormChanged) { diff --git a/packages/analytics-browser/test/plugins/form-interaction-tracking.test.ts b/packages/analytics-browser/test/plugins/form-interaction-tracking.test.ts index 04d4443556..a4401c5a83 100644 --- a/packages/analytics-browser/test/plugins/form-interaction-tracking.test.ts +++ b/packages/analytics-browser/test/plugins/form-interaction-tracking.test.ts @@ -544,7 +544,13 @@ describe('formInteractionTracking', () => { describe('shouldTrackAbandoned', () => { beforeEach(async () => { - const config = createConfigurationMock(); + const config = createConfigurationMock({ + defaultTracking: { + formInteractions: { + shouldTrackAbandoned: true, + }, + }, + }); const plugin = formInteractionTracking(); await plugin.setup?.(config, amplitude); window.dispatchEvent(new Event('load')); diff --git a/packages/analytics-core/src/types/form-interactions.ts b/packages/analytics-core/src/types/form-interactions.ts index 2bf04909f7..76cb22a261 100644 --- a/packages/analytics-core/src/types/form-interactions.ts +++ b/packages/analytics-core/src/types/form-interactions.ts @@ -22,4 +22,11 @@ export interface FormInteractionsOptions { * ``` */ shouldTrackSubmit?: (event: SubmitEvent) => boolean; + + /** + * Whether to track `[Amplitude] Form Abandoned` event + * @defaultValue `true` + * @experimental This feature is experimental and may not be stable + */ + shouldTrackAbandoned?: boolean; } diff --git a/test-server/form-interactions/form-interactions.html b/test-server/form-interactions/form-interactions.html index 3d390951d4..b2c5a78b25 100644 --- a/test-server/form-interactions/form-interactions.html +++ b/test-server/form-interactions/form-interactions.html @@ -37,7 +37,9 @@

Form Interactions Test

autocapture: { pageViews: false, sessions: false, - formInteractions: true, + formInteractions: { + shouldTrackAbandoned: true, + }, }, }); From d11caac46dd1fb350909a97fc7431cb4c5883ec2 Mon Sep 17 00:00:00 2001 From: Daniel Graham Date: Fri, 27 Feb 2026 15:58:19 -0800 Subject: [PATCH 6/7] pr refinements --- .../e2e/form-interactions.spec.ts | 1 - .../src/plugins/form-interaction-tracking.ts | 33 +++++++++++-------- .../plugins/form-interaction-tracking.test.ts | 7 ++++ 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/packages/analytics-browser/e2e/form-interactions.spec.ts b/packages/analytics-browser/e2e/form-interactions.spec.ts index b33044e152..e2d060ee71 100644 --- a/packages/analytics-browser/e2e/form-interactions.spec.ts +++ b/packages/analytics-browser/e2e/form-interactions.spec.ts @@ -15,7 +15,6 @@ test.describe('Form Interactions Page', () => { const data = JSON.parse(postData); requests.push(data); } - console.log('added request'); await route.continue(); }); }); diff --git a/packages/analytics-browser/src/plugins/form-interaction-tracking.ts b/packages/analytics-browser/src/plugins/form-interaction-tracking.ts index 2aa7754209..19bf957177 100644 --- a/packages/analytics-browser/src/plugins/form-interaction-tracking.ts +++ b/packages/analytics-browser/src/plugins/form-interaction-tracking.ts @@ -16,9 +16,13 @@ import { } from '@amplitude/analytics-core'; import { getFormInteractionsConfig } from '../default-tracking'; +type ElementOrWindow = Element | Pick; + +type EventType = 'change' | 'submit' | 'pagehide' | 'beforeunload'; + interface EventListener { - element: Element; - type: 'change' | 'submit'; + elementOrWindow: ElementOrWindow; + type: EventType; handler: (event: Event) => void; } @@ -26,19 +30,19 @@ export const formInteractionTracking = (): EnrichmentPlugin => { let observer: MutationObserver | undefined; let eventListeners: EventListener[] = []; - const addEventListener = (element: Element, type: 'change' | 'submit', handler: (event: Event) => void) => { - element.addEventListener(type, handler); + const addEventListener = (elementOrWindow: ElementOrWindow, type: EventType, handler: (event: Event) => void) => { + elementOrWindow.addEventListener(type, handler); eventListeners.push({ - element, + elementOrWindow, type, handler, }); }; const removeClickListeners = () => { - eventListeners.forEach(({ element, type, handler }) => { + eventListeners.forEach(({ elementOrWindow, type, handler }) => { /* istanbul ignore next */ - element?.removeEventListener(type, handler); + elementOrWindow?.removeEventListener(type, handler); }); eventListeners = []; }; @@ -73,11 +77,11 @@ export const formInteractionTracking = (): EnrichmentPlugin => { addedFormNodes.add(form); let hasFormChanged = false; - - const win = getGlobalScope(); + let hasFormAbandoned = false; const trackFormAbandoned = () => { - if (hasFormChanged) { + if (hasFormChanged && !hasFormAbandoned) { + hasFormAbandoned = true; const formDestination = extractFormAction(form); amplitude.track(DEFAULT_FORM_ABANDONED_EVENT, { [FORM_ID]: stringOrUndefined(form.id), @@ -87,11 +91,12 @@ export const formInteractionTracking = (): EnrichmentPlugin => { } }; - // TODO: check "frustrationInteractions.shouldTrackEventResolver" + const win = getGlobalScope() as Pick; + /* istanbul ignore if */ - if (formInteractionsConfig?.shouldTrackAbandoned) { - win?.addEventListener('pagehide', trackFormAbandoned); - win?.addEventListener('beforeunload', trackFormAbandoned); + if (formInteractionsConfig?.shouldTrackAbandoned && win) { + addEventListener(win, 'pagehide', trackFormAbandoned); + addEventListener(win, 'beforeunload', trackFormAbandoned); } addEventListener(form, 'change', () => { diff --git a/packages/analytics-browser/test/plugins/form-interaction-tracking.test.ts b/packages/analytics-browser/test/plugins/form-interaction-tracking.test.ts index a4401c5a83..a66fa8a2ef 100644 --- a/packages/analytics-browser/test/plugins/form-interaction-tracking.test.ts +++ b/packages/analytics-browser/test/plugins/form-interaction-tracking.test.ts @@ -576,6 +576,13 @@ describe('formInteractionTracking', () => { }); }); + test('should track form abandoned when form started then abandoned (multiple times)', async () => { + document.getElementById('my-form-id')?.dispatchEvent(new Event('change')); + window.dispatchEvent(new Event('beforeunload')); + window.dispatchEvent(new Event('pagehide')); + expect(amplitude.track).toHaveBeenCalledTimes(2); + }); + test('should not track form abandoned when form is not started', async () => { window.dispatchEvent(new Event('pagehide')); expect(amplitude.track).not.toHaveBeenCalled(); From 4829e1cb929af40d21c8cbb3d3eaa79724fd920f Mon Sep 17 00:00:00 2001 From: Daniel Graham Date: Mon, 2 Mar 2026 12:11:10 -0800 Subject: [PATCH 7/7] again --- .../analytics-browser/src/plugins/form-interaction-tracking.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/analytics-browser/src/plugins/form-interaction-tracking.ts b/packages/analytics-browser/src/plugins/form-interaction-tracking.ts index 19bf957177..bf63fa1490 100644 --- a/packages/analytics-browser/src/plugins/form-interaction-tracking.ts +++ b/packages/analytics-browser/src/plugins/form-interaction-tracking.ts @@ -89,6 +89,8 @@ export const formInteractionTracking = (): EnrichmentPlugin => { [FORM_DESTINATION]: formDestination, }); } + // left the page indicating + hasFormChanged = false; }; const win = getGlobalScope() as Pick;