From 8e1a122e12aefe0d13c702a3c8ef10aebe2697ab Mon Sep 17 00:00:00 2001 From: Daniel Graham Date: Wed, 12 Aug 2026 14:40:36 -0700 Subject: [PATCH 1/3] more tweaks to hackathon project --- test-server/configurator-extension/README.md | 26 ++- .../configurator-extension/background.js | 179 +++++++++++----- .../configurator-extension/manifest.json | 5 +- test-server/configurator/blades.js | 26 +++ test-server/configurator/components.jsx | 74 ++++++- test-server/configurator/default-state.js | 13 +- test-server/configurator/extension-bridge.js | 23 ++ test-server/configurator/main.jsx | 143 +++++++------ .../configurator/runner-extension-panel.jsx | 71 +++++++ test-server/configurator/snippet.js | 2 +- test-server/extension-archive.js | 196 ++++++++++++++++++ vite.config.js | 4 + 12 files changed, 624 insertions(+), 138 deletions(-) create mode 100644 test-server/configurator/blades.js create mode 100644 test-server/configurator/runner-extension-panel.jsx create mode 100644 test-server/extension-archive.js diff --git a/test-server/configurator-extension/README.md b/test-server/configurator-extension/README.md index 61d1942f69..7d9368be4e 100644 --- a/test-server/configurator-extension/README.md +++ b/test-server/configurator-extension/README.md @@ -6,7 +6,16 @@ configurator can generate the code, but only an extension can put it on someone ## Setting it up -The SDK bundles aren't checked in. Build them once, then vendor them in: +It isn't in the Chrome Web Store, and a `.crx` can't be dragged into Chrome any more, so either route ends +at Load unpacked on a folder. + +**From the test server.** `/configurator-extension.zip` is this directory, vendored bundles and all, zipped +on request by `test-server/extension-archive.js`. Download it from the configurator's install panel or +directly, unzip it, and load the `configurator-extension` folder it leaves behind. Nothing to build, and +the archive always matches the checkout that served it. `vite build` emits the same file, so a hosted copy +of the configurator offers the same download. + +**From this checkout.** The SDK bundles aren't checked in. Build them once, then vendor them in: ```bash pnpm --dir packages/analytics-browser build @@ -14,9 +23,9 @@ pnpm --dir packages/plugin-session-replay-browser build node test-server/configurator-extension/sync-vendor.mjs ``` -Load `test-server/configurator-extension` at `chrome://extensions` with developer mode on, and start the -test server with `pnpm dev`. After editing any file here, hit the extension's reload icon — that also -clears which tabs were being instrumented. +Then load `test-server/configurator-extension` itself at `chrome://extensions` with developer mode on, and +start the test server with `pnpm dev`. This is the one to use while working on the extension: after editing +any file here, hit its reload icon — that also clears which tabs were being instrumented. ## Using it @@ -34,9 +43,12 @@ from the start. `configurator-bridge.js` sits on the configurator page and relays `window.postMessage` requests to `background.js`. Its `matches` in the manifest cover the hosts the test server uses — `localhost` and -`127.0.0.1` over http for `pnpm dev`, `local.website.com` over https for `pnpm dev:ssh` — and nothing -else. Ports aren't part of a match pattern, so any port is covered, but serving the configurator from a -host that isn't listed is why the page would report no extension. It's scoped to the configurator's own +`127.0.0.1` over http for `pnpm dev`, `local.website.com` over https for `pnpm dev:ssh` — plus the Netlify +site the `pnpm build:configurator` artifact is shared from, and nothing else. Ports aren't part of a match +pattern, so any port is covered, but serving the configurator from a host that isn't listed is why the page +would report no extension. Hosted origins are listed one at a time rather than as `https://*.netlify.app/`: +a wildcard there would offer the relay to every site on a shared domain, and the relay leads to a service +worker that can inject the SDK into any tab. It's scoped to the configurator's own path rather than a host wildcard so the relay isn't offered to every site, given how much the extension is allowed to do. Going through the page rather than `chrome.runtime.sendMessage` means the page needs no extension ID and no `externally_connectable` entry, and it lets the extension announce itself so the diff --git a/test-server/configurator-extension/background.js b/test-server/configurator-extension/background.js index bf3b54d5b5..26c9300665 100644 --- a/test-server/configurator-extension/background.js +++ b/test-server/configurator-extension/background.js @@ -49,13 +49,47 @@ async function instrumentedTabs() { return tabs; } +// A tab is not a thing that stays put: it can be closed, and Chrome can swap it for a prerendered one +// mid-navigation. Every call below that names a tab id can therefore find nothing there, and tabs, action +// and scripting all say so the same way — "No tab with id: 1234.", a message that says nothing about what +// was being attempted and so is worth catching rather than showing. +function isMissingTab(error) { + return /No tab with id/.test(error?.message ?? ''); +} + +// For the calls that only decorate a tab: a tab that has gone away has no badge or tooltip worth setting, +// and failing to set one is not worth abandoning a run over. +async function ignoreMissingTab(pending) { + try { + await pending; + } catch (error) { + if (!isMissingTab(error)) { + throw error; + } + } +} + +// An async listener that rejects has nowhere to put the error: it becomes an uncaught rejection in the +// service worker's console and on the extension's card at chrome://extensions, with nothing to say what was +// being attempted. Named here so anything that does go wrong arrives with its context attached. +function guard(name, handler) { + return (...args) => + handler(...args).catch((error) => { + // The tab this was about is gone, which onRemoved has already tidied up after. + if (isMissingTab(error)) { + return; + } + console.error(`[amplitude-configurator] ${name} failed`, error); + }); +} + // Both transitions carry the CSP rule with them, so no path can mark a tab and forget to clear the way for // what the SDK is about to do — or leave a tab unprotected after instrumentation stops. async function instrument(tabId, payload) { const tabs = await instrumentedTabs(); await chrome.storage.session.set({ [TABS_KEY]: { ...tabs, [tabId]: payload } }); await relaxCsp(tabId); - await chrome.action.setBadgeText({ tabId, text: 'on' }); + await ignoreMissingTab(chrome.action.setBadgeText({ tabId, text: 'on' })); } async function forget(tabId) { @@ -76,41 +110,46 @@ function handOver(payload, csp) { }; } -chrome.webNavigation.onCommitted.addListener(async ({ tabId, frameId, url }) => { - if (frameId !== 0 || !url.startsWith('http')) { - return; - } - const payload = (await instrumentedTabs())[tabId]; - if (!payload) { - return; - } - // Read before injecting: by the time a navigation commits the response headers have arrived, which is - // where the policy the page was sent is still visible. - const csp = cspReport(tabId, payload); - if (csp) { - await chrome.action.setTitle({ tabId, title: csp.summary }); - } - const target = { tabId }; - const inject = (options) => - chrome.scripting.executeScript({ target, world: 'MAIN', injectImmediately: true, ...options }); - try { - await inject({ func: handOver, args: [payload, csp] }); - await inject({ files: [SDK_BUNDLE] }); - if (payload.sessionReplay) { - // Its own call: a plugin bundle that won't load shouldn't stop analytics from running, and - // inject.js reports the gap when the global it expects isn't there. - try { - await inject({ files: [SESSION_REPLAY_BUNDLE] }); - } catch (error) { - console.warn('[amplitude-configurator] session replay bundle failed to load', error); +chrome.webNavigation.onCommitted.addListener( + guard('injection', async ({ tabId, frameId, url }) => { + if (frameId !== 0 || !url.startsWith('http')) { + return; + } + const payload = (await instrumentedTabs())[tabId]; + if (!payload) { + return; + } + // Read before injecting: by the time a navigation commits the response headers have arrived, which is + // where the policy the page was sent is still visible. + const csp = cspReport(tabId, payload); + if (csp) { + await ignoreMissingTab(chrome.action.setTitle({ tabId, title: csp.summary })); + } + const target = { tabId }; + const inject = (options) => + chrome.scripting.executeScript({ target, world: 'MAIN', injectImmediately: true, ...options }); + try { + await inject({ func: handOver, args: [payload, csp] }); + await inject({ files: [SDK_BUNDLE] }); + if (payload.sessionReplay) { + // Its own call: a plugin bundle that won't load shouldn't stop analytics from running, and + // inject.js reports the gap when the global it expects isn't there. + try { + await inject({ files: [SESSION_REPLAY_BUNDLE] }); + } catch (error) { + console.warn('[amplitude-configurator] session replay bundle failed to load', error); + } + } + await inject({ files: ['inject.js'] }); + } catch (error) { + if (isMissingTab(error)) { + throw error; } + console.error('[amplitude-configurator] injection failed', error); + await ignoreMissingTab(chrome.action.setBadgeText({ tabId, text: 'err' })); } - await inject({ files: ['inject.js'] }); - } catch (error) { - console.error('[amplitude-configurator] injection failed', error); - await chrome.action.setBadgeText({ tabId, text: 'err' }); - } -}); + }), +); function describe(payload) { const parts = ['analytics']; @@ -138,11 +177,27 @@ async function runOnUrl(payload) { throw new Error('Only http and https URLs can be instrumented.'); } // The tab opens blank so it can be marked for instrumentation before it commits anything; navigating - // afterwards is what makes the ordering reliable. - const tab = await chrome.tabs.create({ url: 'about:blank', active: true }); - await instrument(tab.id, payload); - await chrome.storage.session.set({ [LAST_PAYLOAD_KEY]: payload }); - await chrome.tabs.update(tab.id, { url: url.toString() }); + // afterwards is what makes the ordering reliable. It also means there is a moment where the run depends + // on a tab nobody is looking at yet, and anything that closes it — a click, a tab-tidying extension, + // Chrome swapping in a prerender — leaves the steps below with nothing to work on. + let tab; + try { + tab = await chrome.tabs.create({ url: 'about:blank', active: true }); + await instrument(tab.id, payload); + await chrome.storage.session.set({ [LAST_PAYLOAD_KEY]: payload }); + await chrome.tabs.update(tab.id, { url: url.toString() }); + } catch (error) { + if (!isMissingTab(error)) { + throw error; + } + if (tab) { + // The mark and the CSP rule are both keyed by tab id, and Chrome reuses ids, so leaving them behind + // would take the policy off whichever tab inherits this one's. + await forget(tab.id); + } + // The id is named because it is the one thing that ties this back to what the browser did with the tab. + throw new Error(`Tab ${tab?.id} was opened for this run and went away before it could be navigated.`); + } return { message: describe(payload) }; } @@ -155,19 +210,35 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { }); // The toolbar button instruments the tab you're looking at, with whatever the configurator sent last. -chrome.action.onClicked.addListener(async (tab) => { - if (!tab.url?.startsWith('http')) { - return; - } - if ((await instrumentedTabs())[tab.id]) { - await forget(tab.id); - await chrome.action.setBadgeText({ tabId: tab.id, text: '' }); - } else { - const { [LAST_PAYLOAD_KEY]: lastPayload } = await chrome.storage.session.get(LAST_PAYLOAD_KEY); - await instrument(tab.id, lastPayload ?? FALLBACK_PAYLOAD); - } - // The injection only happens on the next commit, which is also the only way to catch a page early. - await chrome.tabs.reload(tab.id); -}); +chrome.action.onClicked.addListener( + guard('toolbar button', async (tab) => { + if (!tab.url?.startsWith('http')) { + return; + } + if ((await instrumentedTabs())[tab.id]) { + await forget(tab.id); + await ignoreMissingTab(chrome.action.setBadgeText({ tabId: tab.id, text: '' })); + } else { + const { [LAST_PAYLOAD_KEY]: lastPayload } = await chrome.storage.session.get(LAST_PAYLOAD_KEY); + await instrument(tab.id, lastPayload ?? FALLBACK_PAYLOAD); + } + // The injection only happens on the next commit, which is also the only way to catch a page early. + await chrome.tabs.reload(tab.id); + }), +); -chrome.tabs.onRemoved.addListener((tabId) => forget(tabId)); +chrome.tabs.onRemoved.addListener(guard('cleanup', (tabId) => forget(tabId))); + +// Chrome can finish a navigation in a different tab than it started in: a prerendered page arrives in a tab +// of its own and takes the old one's place, which destroys the id everything here is keyed by. Moving the +// mark and the CSP rule across keeps the run alive, and keeps a rule from outliving the tab it was for. +chrome.tabs.onReplaced.addListener( + guard('tab replacement', async (addedTabId, removedTabId) => { + const payload = (await instrumentedTabs())[removedTabId]; + if (!payload) { + return; + } + await forget(removedTabId); + await instrument(addedTabId, payload); + }), +); diff --git a/test-server/configurator-extension/manifest.json b/test-server/configurator-extension/manifest.json index 26da5e7f09..9693c598c0 100644 --- a/test-server/configurator-extension/manifest.json +++ b/test-server/configurator-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Amplitude Configurator Runner (spike)", - "version": "0.0.3", + "version": "0.0.5", "description": "Runs a configuration built in the Amplitude configurator on any site, by injecting the Browser SDK into it.", "permissions": ["declarativeNetRequestWithHostAccess", "scripting", "storage", "webNavigation", "webRequest"], "host_permissions": ["*://*/*"], @@ -20,7 +20,8 @@ "http://127.0.0.1/configurator.html*", "https://127.0.0.1/configurator.html*", "http://local.website.com/configurator.html*", - "https://local.website.com/configurator.html*" + "https://local.website.com/configurator.html*", + "https://bespoke-melomakarona-ec9cbf.netlify.app/configurator.html*" ], "js": ["configurator-bridge.js"], "run_at": "document_end" diff --git a/test-server/configurator/blades.js b/test-server/configurator/blades.js new file mode 100644 index 0000000000..d00064325c --- /dev/null +++ b/test-server/configurator/blades.js @@ -0,0 +1,26 @@ +// The product offerings a client can be built for — "blades" internally, and the SDKs initAll() wires +// up in packages/unified. Each key is both the state key holding whether that blade is switched on and +// what gates its section of the form, so nothing is configured for a product that isn't in use. +// +// Analytics is `required` because the others are plugins on the analytics SDK and take their identity +// and session from it: there is no client here that doesn't init it. Its checkbox is shown so the set +// reads as the whole product line, but it can't be switched off, and nothing about it is held in the +// state the link carries. +export const BLADES = [ + { + key: 'analytics', + label: 'Analytics', + required: true, + description: 'Always on: the other blades are plugins on the analytics SDK and build on its identity and session.', + }, + { + key: 'sessionReplay', + label: 'Session Replay', + description: 'Installs the session replay plugin alongside the analytics SDK.', + }, + { + key: 'engagement', + label: 'Guides and Surveys', + description: 'Installs the Guides and Surveys plugin alongside the analytics SDK.', + }, +]; diff --git a/test-server/configurator/components.jsx b/test-server/configurator/components.jsx index 917b03bf55..745905e1be 100644 --- a/test-server/configurator/components.jsx +++ b/test-server/configurator/components.jsx @@ -1,6 +1,8 @@ import React from 'react'; -// Prism's default build already registers the javascript grammar, so no component import is needed. +// Prism's default build already registers the javascript and markup grammars, so only the shell one the +// extension's setup commands are shown in has to be pulled in. import Prism from 'prismjs'; +import 'prismjs/components/prism-bash'; import './syntax-theme.css'; const styles = { @@ -24,10 +26,15 @@ const styles = { resize: 'vertical', }, select: { padding: '5px 8px', font: 'inherit' }, + inlineCheckbox: { display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }, hint: { color: '#888', fontSize: 12 }, subGroup: { padding: '0 0 0 16px', borderLeft: '2px solid #e2e2e2', margin: '0 0 12px 4px' }, subGroupTitle: { fontSize: 12, color: '#666', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: 0.4 }, - button: { font: 'inherit', fontSize: 13, padding: '4px 10px', cursor: 'pointer' }, + // flex: none so a long note beside a button in a toolbar row can't squeeze its label onto two lines. + button: { font: 'inherit', fontSize: 13, padding: '4px 10px', cursor: 'pointer', flex: 'none' }, + disabledButton: { cursor: 'not-allowed', color: '#999' }, + // Takes the button's place as the flex item, so wrapping one changes nothing about the row. + buttonTooltip: { display: 'inline-flex', flex: 'none' }, card: { border: '1px solid #ddd', borderRadius: 6, background: '#fff', padding: '10px 12px 2px', marginBottom: 10 }, cardHeader: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }, cardTitle: { fontSize: 13, fontWeight: 600, margin: 0 }, @@ -192,6 +199,49 @@ export function CheckboxField({ id, label, checked, onChange, labelWidth, hint, ); } +// Several checkboxes on one row under a single label, for a set that reads as one choice rather than as +// unrelated switches. `role` and `aria-labelledby` stand in for a fieldset and legend, whose default +// rendering doesn't match the label-and-control rows around it. +export function CheckboxGroup({ id, label, labelWidth, description, options, values, onChange }) { + const labelStyle = { + ...styles.label, + ...(labelWidth ? { width: labelWidth, flex: 'none' } : null), + ...(description ? styles.documentedLabel : null), + }; + return ( +
+ + {label} + + {options.map((option) => ( + + ))} +
+ ); +} + // Left uncontrolled so the browser owns the open/closed state; the panel re-renders on every // keystroke elsewhere on the page and a controlled `open` would fight that. export function Panel({ title, description, badge, defaultOpen = false, children }) { @@ -209,12 +259,26 @@ export function Panel({ title, description, badge, defaultOpen = false, children ); } -export function Button({ onClick, children }) { - return ( - ); + return title ? ( + + {button} + + ) : ( + button + ); } export function Note({ children }) { diff --git a/test-server/configurator/default-state.js b/test-server/configurator/default-state.js index c7fbd69e74..1fa5ee689b 100644 --- a/test-server/configurator/default-state.js +++ b/test-server/configurator/default-state.js @@ -5,19 +5,24 @@ import { createDefaultValues } from './fields.js'; import { SESSION_REPLAY_OPTIONS } from './session-replay-options.js'; // Everything the form holds, in one shape, so it can be diffed against a saved link and restored from -// one. These are also exactly the arguments buildSnippet() and buildRuntimeConfig() take, which is why -// the run page can rebuild a configuration from nothing but the link the form produced. +// one. The configuration among it is exactly the arguments buildSnippet() and buildRuntimeConfig() take, +// which is why the run page can rebuild a configuration from nothing but the link the form produced. export function createDefaultState() { return { apiKey: '', format: 'esm', + // The site "Run on URL" injects into. No SDK reads it — it rides along so that reopening a link + // doesn't cost the site the configuration was last tried on. + targetUrl: '', + // Which of the optional blades are in use, keyed as BLADES describes; analytics is always on. The + // options below belong to one blade each and are only reachable while it is switched on. + sessionReplay: false, + engagement: false, configOptions: createDefaultValues(CONFIG_OPTIONS), autocapture: true, autocaptureOptions: createDefaultAutocaptureOptions(), autocaptureSubOptions: createDefaultSubOptions(), - sessionReplay: false, sessionReplayOptions: createDefaultValues(SESSION_REPLAY_OPTIONS), - engagement: false, engagementOptions: createDefaultValues(ENGAGEMENT_OPTIONS), }; } diff --git a/test-server/configurator/extension-bridge.js b/test-server/configurator/extension-bridge.js index a1e86388f0..b10902d152 100644 --- a/test-server/configurator/extension-bridge.js +++ b/test-server/configurator/extension-bridge.js @@ -5,6 +5,8 @@ // script sits on this page and relays messages to its service worker, which means this page needs no // extension ID and the extension needs no externally_connectable entry. It also means the extension can // announce itself, so the button can tell "not installed" from "not responding". +import { useEffect, useState } from 'react'; + const REQUEST_SOURCE = 'amplitude-configurator'; const RESPONSE_SOURCE = 'amplitude-configurator-extension'; @@ -17,6 +19,27 @@ export function extensionVersion() { return document.documentElement.dataset[MARKER]; } +// The version as something the page can render, for the parts of the UI that are there or not there +// depending on whether the extension is. The bridge writes its marker at document_end, which is after +// this page's own module has run, so the first render can't see it however well the extension is +// installed; by load it is there, and nothing can add it later, since content scripts only inject as a +// page loads. +export function useExtensionVersion() { + const [version, setVersion] = useState(extensionVersion); + + useEffect(() => { + const check = () => setVersion(extensionVersion()); + if (document.readyState === 'complete') { + check(); + return undefined; + } + window.addEventListener('load', check); + return () => window.removeEventListener('load', check); + }, []); + + return version; +} + // A configuration crosses into the extension as JSON, which has nowhere to put a RegExp. The matching // reviver lives in the extension's inject.js — the two have to agree on this shape. export function toJsonSafe(value) { diff --git a/test-server/configurator/main.jsx b/test-server/configurator/main.jsx index 6394a0ca03..117cd0b9f1 100644 --- a/test-server/configurator/main.jsx +++ b/test-server/configurator/main.jsx @@ -1,15 +1,17 @@ import React, { useEffect, useState } from 'react'; import { createRoot } from 'react-dom/client'; -import { Button, CheckboxField, CodeBlock, SelectField, TextField } from './components.jsx'; +import { Button, CheckboxField, CheckboxGroup, CodeBlock, SelectField, TextField } from './components.jsx'; import { AutocapturePanel } from './autocapture-panel.jsx'; import { SectionPanels } from './section-panels.jsx'; +import { BLADES } from './blades.js'; import { CONFIG_SECTIONS, SHARED_CONFIG_OPTIONS, TOP_LEVEL_CONFIG_OPTIONS } from './config-options.js'; import { ENGAGEMENT_SECTIONS } from './engagement-options.js'; import { SESSION_REPLAY_SECTIONS, TOP_LEVEL_SESSION_REPLAY_OPTIONS } from './session-replay-options.js'; import { createDefaultState } from './default-state.js'; import { OptionField } from './option-field.jsx'; +import { RunnerExtensionPanel } from './runner-extension-panel.jsx'; import { decodeStateFromUrl, encodeStateToUrl, hasSavedState } from './share-link.js'; -import { extensionVersion, requestRun, toJsonSafe } from './extension-bridge.js'; +import { requestRun, toJsonSafe, useExtensionVersion } from './extension-bridge.js'; import { buildRuntimeConfig } from './runtime-config.js'; import { buildSnippet, PLACEHOLDER_API_KEY, SNIPPET_FORMATS, snippetLanguage } from './snippet.js'; @@ -24,7 +26,8 @@ const styles = { // Sticks alongside the config column, which is far taller once a few panels are open. outputColumn: { flex: '1 1 400px', minWidth: 0, position: 'sticky', top: 24 }, sectionHeading: { fontSize: 16, margin: '0 0 8px' }, - // The API key belongs to both sections below, so it sits above the first rule. + // The API key, the shared options and the blade picker belong to every blade, so they sit above the + // first rule. formSection: { borderTop: '1px solid #e5e5e5', paddingTop: 16, marginTop: 20 }, toolbar: { display: 'flex', alignItems: 'center', gap: 10, margin: '0 0 20px' }, toolbarNote: { color: '#888', fontSize: 12 }, @@ -42,20 +45,31 @@ const RUN_PAGE = '/configurator-run.html'; function Configurator({ initialState }) { const [apiKey, setApiKey] = useState(initialState.apiKey); const [format, setFormat] = useState(initialState.format); + // Which of the optional blades are switched on, read out of the state by the keys BLADES names so the + // picker and the sections it gates can't drift apart. Analytics is required, so it isn't in here and + // its section is always rendered. Each blade's own options live on below whether it is on or not, so + // switching one off and back on doesn't cost the configuration that was built for it. + const [blades, setBlades] = useState(() => + Object.fromEntries(BLADES.filter((blade) => !blade.required).map(({ key }) => [key, initialState[key]])), + ); const [configOptions, setConfigOptions] = useState(initialState.configOptions); const [autocapture, setAutocapture] = useState(initialState.autocapture); // Kept when autocapture is switched off so the selections come back on re-enable. const [autocaptureOptions, setAutocaptureOptions] = useState(initialState.autocaptureOptions); const [autocaptureSubOptions, setAutocaptureSubOptions] = useState(initialState.autocaptureSubOptions); - const [sessionReplay, setSessionReplay] = useState(initialState.sessionReplay); const [sessionReplayOptions, setSessionReplayOptions] = useState(initialState.sessionReplayOptions); - const [engagement, setEngagement] = useState(initialState.engagement); const [engagementOptions, setEngagementOptions] = useState(initialState.engagementOptions); const [savedState, setSavedState] = useState(null); const [copied, setCopied] = useState(false); const [blockedRunUrl, setBlockedRunUrl] = useState(null); - const [targetUrl, setTargetUrl] = useState(''); + const [targetUrl, setTargetUrl] = useState(initialState.targetUrl); const [targetNote, setTargetNote] = useState(null); + // Running on another site is the extension's to do, so without it that button has nothing behind it. + const runnerVersion = useExtensionVersion(); + + const setBlade = (key, value) => { + setBlades((previous) => ({ ...previous, [key]: value })); + }; const setConfigOption = (key, value) => { setConfigOptions((previous) => ({ ...previous, [key]: value })); @@ -80,13 +94,13 @@ function Configurator({ initialState }) { const state = { apiKey, format, + targetUrl, + ...blades, configOptions, autocapture, autocaptureOptions, autocaptureSubOptions, - sessionReplay, sessionReplayOptions, - engagement, engagementOptions, }; @@ -125,17 +139,11 @@ function Configurator({ initialState }) { // Running on a site this page has no access to takes the extension: it opens the tab and injects the // SDK into it. The configuration is materialised here, where the schemas live, and travels as JSON. + // Nothing here checks that the extension is present, because the button is disabled until it is. const runOnUrl = async () => { - const version = extensionVersion(); - if (!version) { - // Naming the origin matters: the usual cause is that the extension's bridge doesn't list the host - // this page is being served from, and the second is an extension that was edited but not reloaded. - setTargetNote( - `No runner extension detected on ${window.location.origin}. Load test-server/configurator-extension at ` + - 'chrome://extensions, press Reload on it if it was already loaded, then reload this page.', - ); - return; - } + // The address bar is where this form keeps everything else, so the site being tested is kept there + // too: reloading, or coming back to the link later, reopens with it still filled in. + window.history.replaceState(null, '', await encodeStateToUrl(state, DEFAULT_STATE)); const { apiKey: key, analytics, sessionReplay: replay, engagement: guides } = buildRuntimeConfig(state); setTargetNote('Opening…'); try { @@ -148,7 +156,9 @@ function Configurator({ initialState }) { }); setTargetNote(result.message); } catch (error) { - setTargetNote(error.message); + // Named with the version that produced it: the extension is loaded unpacked from a working copy, so + // "it still says the same thing" is as likely to be a copy that was never reloaded as a real failure. + setTargetNote(`${error.message} (runner ${runnerVersion})`); } }; @@ -194,12 +204,24 @@ function Configurator({ initialState }) { spellCheck={false} style={styles.targetInput} /> - + {targetNote ?? 'Runs this configuration on another site, through the runner extension.'} + +
))} +

Analytics

@@ -261,60 +292,42 @@ function Configurator({ initialState }) { />
-
-

Session Replay

- - - {sessionReplay ? ( - <> - {TOP_LEVEL_SESSION_REPLAY_OPTIONS.map((field) => ( - setSessionReplayOption(field.key, value)} - labelWidth={TOP_LABEL_WIDTH} - /> - ))} - - +

Session Replay

+ + {TOP_LEVEL_SESSION_REPLAY_OPTIONS.map((field) => ( + setSessionReplayOption(field.key, value)} + labelWidth={TOP_LABEL_WIDTH} /> - - ) : null} -
+ ))} -
-

Guides and Surveys

+ +
+ ) : null} + + {blades.engagement ? ( +
+

Guides and Surveys

- - {engagement ? ( - ) : null} -
+ + ) : null}
diff --git a/test-server/configurator/runner-extension-panel.jsx b/test-server/configurator/runner-extension-panel.jsx new file mode 100644 index 0000000000..d1a22edff9 --- /dev/null +++ b/test-server/configurator/runner-extension-panel.jsx @@ -0,0 +1,71 @@ +// How to get hold of the extension "Run on URL" needs. It isn't in the Chrome Web Store, so there is no +// install link to point at: the steps are the download this server builds, and Load unpacked. They mirror +// test-server/configurator-extension/README.md, which is the fuller account. +import React from 'react'; +import { CodeBlock, Panel } from './components.jsx'; + +const EXTENSION_DIRECTORY = 'test-server/configurator-extension'; + +const REPOSITORY_URL = `https://github.com/amplitude/Amplitude-TypeScript/tree/main/${EXTENSION_DIRECTORY}`; + +// Built by test-server/extension-archive.js, which owns this path, out of the extension directory as it +// stands in whatever checkout is serving this page. +const ARCHIVE_URL = '/configurator-extension.zip'; + +// The folder the archive holds, which is also the folder to load from a checkout, so the last two steps +// read the same either way. +const UNPACKED_FOLDER = 'configurator-extension'; + +// The bundles the extension injects aren't checked in. The archive carries them already; a checkout has +// to build them before Chrome will accept the folder. +const SETUP_COMMANDS = `pnpm --dir packages/analytics-browser build +pnpm --dir packages/plugin-session-replay-browser build +node ${EXTENSION_DIRECTORY}/sync-vendor.mjs`; + +const styles = { + wrapper: { maxWidth: 760, margin: '0 0 20px' }, + note: { color: '#888', fontSize: 12, margin: '0 0 10px' }, + steps: { margin: '0 0 10px', paddingLeft: 20, fontSize: 13, color: '#444', lineHeight: 1.6 }, + step: { marginBottom: 6 }, + commands: { margin: '8px 0 4px' }, +}; + +export function RunnerExtensionPanel({ version }) { + return ( +
+ +

+ The extension isn't in the Chrome Web Store, so Chrome will only take it as an unpacked folder. This + server builds that folder into an archive for you, SDK bundles included. +

+
    +
  1. + Download{' '} + + configurator-extension.zip + + , built from the checkout serving this page, so it matches the SDK it configures. +
  2. +
  3. + Unzip it. That leaves one {UNPACKED_FOLDER} folder — keep it somewhere it can stay, since + Chrome loads the extension from where it sits rather than copying it. +
  4. +
  5. + Open chrome://extensions — paste it into the address bar, since Chrome won't follow a link + there — and turn on Developer mode. +
  6. +
  7. + Click Load unpacked and choose that {UNPACKED_FOLDER} folder. +
  8. +
  9. + Reload this page. The extension attaches to it as it loads, so a page open from before the install + can't see it. +
  10. +
+
+
+ ); +} diff --git a/test-server/configurator/snippet.js b/test-server/configurator/snippet.js index 359368173b..1ca12a203e 100644 --- a/test-server/configurator/snippet.js +++ b/test-server/configurator/snippet.js @@ -188,7 +188,7 @@ function autocaptureEntry({ autocapture, autocaptureOptions, autocaptureSubOptio // initAll() spreads its shared options over the analytics ones, so one of these nested under // `analytics` would be overwritten with undefined. They have to be hoisted. This is the same set the -// form renders above the per-SDK sections. +// form renders above the per-blade sections. const UNIFIED_SHARED_KEYS = SHARED_CONFIG_OPTIONS.map((field) => field.key); function unifiedEntries(state) { diff --git a/test-server/extension-archive.js b/test-server/extension-archive.js new file mode 100644 index 0000000000..dee47fa042 --- /dev/null +++ b/test-server/extension-archive.js @@ -0,0 +1,196 @@ +// Serves the configurator's runner extension as a zip, so installing it doesn't take a checkout of this +// repository or a build of the SDK. +// +// The archive isn't an installable package. Chrome only accepts an unpacked folder from outside the Web +// Store — a .crx can't be dragged in any more — so this is a way to get the files: download, unzip, hand +// the folder to "Load unpacked". Everything is nested under one directory named after the source folder, +// so every unzipping tool produces the same single folder to point at. +// +// It's built from the working tree per request rather than checked in, which keeps it in step with the +// extension and with the SDK bundles vendored into it. `vite build` emits the same bytes as a static +// asset, since a hosted copy of the configurator has no middleware to build it on demand. +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { deflateRawSync } from 'node:zlib'; + +// Also hardcoded by the configurator's runner-extension-panel.jsx, which links to it. It can't import +// this module for the constant: this one reads the filesystem. +const ARCHIVE_NAME = 'configurator-extension.zip'; + +const ARCHIVE_URL = `/${ARCHIVE_NAME}`; + +// The single directory every entry sits under, named after the folder in the repository so the install +// steps read the same whether the folder came from a checkout or from here. +const ROOT_DIRECTORY = 'configurator-extension'; + +// Only meaningful in a checkout: .gitignore covers the vendored bundles, which are already inside the +// archive, and the script that produces them has nothing left to do once they are. +const EXCLUDED = new Set(['.gitignore', 'sync-vendor.mjs']); + +const VENDOR_DIRECTORY = 'vendor/'; + +const SYNC_COMMAND = 'node test-server/configurator-extension/sync-vendor.mjs'; + +// CRC-32 (IEEE 802.3), which every zip entry carries twice: once in its local header and once in the +// central directory. +const CRC_TABLE = Int32Array.from({ length: 256 }, (_unused, index) => { + let value = index; + for (let bit = 0; bit < 8; bit += 1) { + value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; + } + return value; +}); + +function crc32(bytes) { + let crc = -1; + for (const byte of bytes) { + crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ byte) & 0xff]; + } + return (crc ^ -1) >>> 0; +} + +// A fixed 1980-01-01 in the DOS encoding zip entries use, rather than each file's mtime: nothing reads +// these timestamps, and a constant makes the archive byte-identical between builds of the same files. +const DOS_TIME = 0; +const DOS_DATE = (1 << 5) | 1; + +// A regular file, 0644, in the high half of the external attributes field, so unzipping on a Unix system +// doesn't produce files with no permissions at all. Shifted back to unsigned, since << is signed. +const UNIX_FILE_MODE = (0o100644 << 16) >>> 0; + +// Paths of every file to archive, relative to the extension directory, depth first and sorted so the +// archive doesn't depend on the order the filesystem hands entries back. +function collectFiles(root, prefix = '') { + const entries = readdirSync(path.join(root, prefix), { withFileTypes: true }).sort((left, right) => + left.name.localeCompare(right.name), + ); + return entries.flatMap((entry) => { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + if (EXCLUDED.has(relative)) { + return []; + } + return entry.isDirectory() ? collectFiles(root, relative) : [relative]; + }); +} + +// No data descriptors and no zip64: the whole archive is assembled in memory, so every size and checksum +// is known before its header is written, and a few hundred kilobytes of minified JS is nowhere near the +// 4 GB the classic format tops out at. Directory entries are left out — the paths imply them. +function zip(files) { + const localParts = []; + const centralParts = []; + let offset = 0; + + for (const { name, bytes } of files) { + const compressed = deflateRawSync(bytes, { level: 9 }); + const nameBytes = Buffer.from(name, 'utf8'); + const checksum = crc32(bytes); + + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); // version needed to extract: 2.0, for deflate + local.writeUInt16LE(0, 6); // no flags: sizes are known up front and the names are ASCII + local.writeUInt16LE(8, 8); // deflate + local.writeUInt16LE(DOS_TIME, 10); + local.writeUInt16LE(DOS_DATE, 12); + local.writeUInt32LE(checksum, 14); + local.writeUInt32LE(compressed.length, 18); + local.writeUInt32LE(bytes.length, 22); + local.writeUInt16LE(nameBytes.length, 26); + local.writeUInt16LE(0, 28); // no extra field + localParts.push(local, nameBytes, compressed); + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); // version made by + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0, 8); + central.writeUInt16LE(8, 10); + central.writeUInt16LE(DOS_TIME, 12); + central.writeUInt16LE(DOS_DATE, 14); + central.writeUInt32LE(checksum, 16); + central.writeUInt32LE(compressed.length, 20); + central.writeUInt32LE(bytes.length, 24); + central.writeUInt16LE(nameBytes.length, 28); + central.writeUInt16LE(0, 30); // no extra field + central.writeUInt16LE(0, 32); // no comment + central.writeUInt16LE(0, 34); // single disk + central.writeUInt16LE(0, 36); // internal attributes + central.writeUInt32LE(UNIX_FILE_MODE, 38); + central.writeUInt32LE(offset, 42); + centralParts.push(central, nameBytes); + + offset += local.length + nameBytes.length + compressed.length; + } + + const directory = Buffer.concat(centralParts); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(0, 4); // this disk + end.writeUInt16LE(0, 6); // the disk the central directory starts on + end.writeUInt16LE(files.length, 8); + end.writeUInt16LE(files.length, 10); + end.writeUInt32LE(directory.length, 12); + end.writeUInt32LE(offset, 16); + end.writeUInt16LE(0, 20); // no archive comment + + return Buffer.concat([...localParts, directory, end]); +} + +function buildArchive(extensionDir) { + if (!existsSync(extensionDir)) { + throw new Error(`There is no extension at ${extensionDir}.`); + } + const names = collectFiles(extensionDir); + // An extension without the bundles it injects loads and then fails on the first run, which is a much + // worse thing to hand someone than no download at all. + if (!names.some((name) => name.startsWith(VENDOR_DIRECTORY))) { + throw new Error(`The extension has no vendored SDK bundles. Build them, then: ${SYNC_COMMAND}`); + } + return zip( + names.map((name) => ({ + name: `${ROOT_DIRECTORY}/${name}`, + bytes: readFileSync(path.join(extensionDir, name)), + })), + ); +} + +export function createExtensionArchive(extensionDir) { + const serve = (req, res, next) => { + if (!req.url || req.url.split('?')[0] !== ARCHIVE_URL) { + next(); + return; + } + try { + const archive = buildArchive(extensionDir); + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename="${ARCHIVE_NAME}"`); + // Rebuilt per request, so an edit to the extension is in the next download rather than in whatever + // the browser kept. + res.setHeader('Cache-Control', 'no-store'); + res.end(archive); + } catch (error) { + // Plain text and a 503: this is read by whoever clicked the link, and the fix is a command. + res.statusCode = 503; + res.setHeader('Content-Type', 'text/plain'); + res.end(`${error.message}\n`); + } + }; + + return { + name: 'configurator-extension-archive', + configureServer(server) { + server.middlewares.use(serve); + }, + configurePreviewServer(server) { + server.middlewares.use(serve); + }, + generateBundle() { + try { + this.emitFile({ type: 'asset', fileName: ARCHIVE_NAME, source: buildArchive(extensionDir) }); + } catch (error) { + this.warn(`${error.message} This build has no ${ARCHIVE_NAME}.`); + } + }, + }; +} diff --git a/vite.config.js b/vite.config.js index 2383ccd30a..1cf5523be9 100644 --- a/vite.config.js +++ b/vite.config.js @@ -5,9 +5,11 @@ import path from 'path'; import fs from 'fs'; import glob from 'fast-glob'; import { createMockApi } from './test-server/mock-api.js'; +import { createExtensionArchive } from './test-server/extension-archive.js'; const packagesDir = path.resolve(__dirname, 'packages'); const testServerDir = path.resolve(__dirname, 'test-server'); +const extensionDir = path.resolve(testServerDir, 'configurator-extension'); const ignorePkg = (pkgName) => { return pkgName.startsWith('.') || @@ -160,5 +162,7 @@ export default defineConfig({ fileListingPlugin(), spaRoutingPlugin(), createMockApi(), + // Offers the configurator's runner extension as a download, since it isn't in the Chrome Web Store. + createExtensionArchive(extensionDir), ], }); From 067ea3079fce16b79e9dc92b592336e9ab02b664 Mon Sep 17 00:00:00 2001 From: Daniel Graham Date: Wed, 12 Aug 2026 16:14:43 -0700 Subject: [PATCH 2/3] mock referrer --- test-server/configurator-extension/README.md | 22 ++++ .../configurator-extension/background.js | 105 +++++++++++++++++- .../configurator-bridge.js | 15 ++- test-server/configurator-extension/inject.js | 7 ++ .../configurator-extension/manifest.json | 2 +- test-server/configurator/default-state.js | 8 ++ test-server/configurator/extension-bridge.js | 8 +- test-server/configurator/main.jsx | 98 +++++++++++----- .../configurator/runner-extension-panel.jsx | 48 +++++++- test-server/extension-archive.js | 24 +++- 10 files changed, 299 insertions(+), 38 deletions(-) diff --git a/test-server/configurator-extension/README.md b/test-server/configurator-extension/README.md index 7d9368be4e..13abed14a8 100644 --- a/test-server/configurator-extension/README.md +++ b/test-server/configurator-extension/README.md @@ -34,6 +34,12 @@ to the "Run on URL" button, and click it. The page hands the configuration to th that URL in a new tab and initialises the SDK there. The note beside the button says what was installed, and the tab's console logs every event the SDK builds. +Filling in "Mock Referrer" alongside that URL makes `document.referrer` read whatever you put there, so +attribution can be tried without having to arrive from the referring site. "Clean Session", which is on +unless you turn it off, deletes Amplitude's stored state for the site first, so every run starts with a new +device ID, a new session and no prior campaign — which is what makes a referrer worth mocking in the first +place. Untick it to pick up where the last run left off. + **On the tab you're looking at.** Click the toolbar button to instrument the current tab with whatever the configurator sent last, or a debug-everything default if it hasn't sent anything yet. The badge reads `on`, and clicking again switches it off. Either way the tab reloads, since that's the only way to catch a page @@ -95,6 +101,22 @@ page uses, and travels as JSON. Regexes have nowhere to live in JSON, so `toJson `base::IsStringUTF8`, which rejects Unicode non-characters, and the session replay bundle contains four literal U+FFFE characters. Chrome rejects the whole file with "It isn't UTF-8 encoded", which is why `sync-vendor.mjs` escapes them on the way in rather than a plain `cp`. +- **Both landing options are spent on the first page of a run.** A mocked referrer and a cleared session + describe arriving at a site rather than being on one, so `takePayload()` hands them to the commit that + opens the run and takes them off the stored payload. Clearing on every commit would hand out a new device + ID and session on every page and no session would last more than one pageview; a referrer mocked again + would keep claiming the visitor came from elsewhere when they came from the previous page. Pages after + the first therefore report their real referrer and keep the session that was just started. +- **A mocked referrer is only the JS view.** `handOver` shadows `document.referrer` with an own property, + which is what the campaign parser and the page-URL enrichment plugin read. The `Referer` header the page + was actually fetched with is untouched, so anything server-side still sees the truth — and modifying that + header wouldn't help, because Chrome derives `document.referrer` from the navigation's referrer rather + than from a header a `declarativeNetRequest` rule rewrote. +- **Clearing the session takes every Amplitude key with it.** It sweeps by prefix — `AMP_` and the legacy + lowercase `amp_` — across cookies, `localStorage` and `sessionStorage`, so it also clears the state of the + site's *own* Amplitude instance if it has one, in your browser only. Nothing else the site stores is + touched, and IndexedDB is left alone: session replay's recorded events live there, and a new session ID + makes them moot anyway. - **Where events go.** The API key comes from the configurator, and events land in whatever project owns it. Use a scratch project, not a customer's production key. - **The page's own Amplitude.** The bundle merges itself onto an existing `window.amplitude`, which would diff --git a/test-server/configurator-extension/background.js b/test-server/configurator-extension/background.js index 26c9300665..c8e88add85 100644 --- a/test-server/configurator-extension/background.js +++ b/test-server/configurator-extension/background.js @@ -19,6 +19,12 @@ const SESSION_REPLAY_BUNDLE = 'vendor/plugin-session-replay-browser-min.js'; const TABS_KEY = 'instrumentedTabs'; const LAST_PAYLOAD_KEY = 'lastPayload'; +// Everything the SDKs key their storage by, from AMPLITUDE_PREFIX in analytics-core's +// types/constants.ts: `AMP_` holds the device ID, session ID and user ID, `AMP_MKTG_` the +// last campaign, and `AMP_unsent_`, `AMP_remote_config_`, `AMP_SR_START_`, `AMP_PAGE_VIEW` the rest. The +// lowercase form is getOldCookieName()'s, still read by the cookie migration on init. +const AMPLITUDE_STORAGE_PREFIXES = ['AMP_', 'amp_']; + // What the configurator sends when no API key has been typed in — PLACEHOLDER_API_KEY in its snippet.js. const PLACEHOLDER_API_KEY = 'YOUR_API_KEY'; @@ -99,9 +105,45 @@ async function forget(tabId) { await restoreCsp(tabId); } +// The payload this commit runs with — and, as a side effect, the payload every later commit in the tab will +// run with. +// +// Two of its options describe arriving at a site rather than being on one, so they belong to the page a run +// opens with and to no other. Clearing again would hand out a new device ID and session on every page, and +// there would be no session left to watch. A referrer mocked again would keep insisting the visitor came +// from somewhere else when they in fact came from the previous page of the site, which is a story no real +// second pageview tells. Both are therefore spent here: read for this commit, then taken off what is stored. +// +// Session storage rather than a variable because the service worker is routinely torn down between marking a +// tab and the navigation it opened, which would otherwise make "first commit" mean "first since the worker +// last woke up". +async function takePayload(tabId) { + const tabs = await instrumentedTabs(); + const payload = tabs[tabId]; + if (!payload) { + return undefined; + } + const { clearSession, mockReferrer, ...rest } = payload; + if (clearSession || mockReferrer) { + await chrome.storage.session.set({ [TABS_KEY]: { ...tabs, [tabId]: rest } }); + } + return payload; +} + // Runs in the page before the SDK bundle: saves what the page had under window.amplitude, since the // bundle is about to write over it, and leaves the configuration where inject.js will look for it. function handOver(payload, csp) { + // document.referrer is a configurable accessor inherited from Document.prototype, so an own property + // shadows it for the page and for the SDK's campaign parser alike, and only for this document — the next + // page the tab commits gets a payload with no referrer in it. Only the JS view moves: the request that + // fetched this page carried whatever Referer the browser chose, and nothing here can change that after + // the fact. + if (payload.mockReferrer) { + Object.defineProperty(document, 'referrer', { + configurable: true, + get: () => payload.mockReferrer, + }); + } window.__amplitudeConfigurator = { hadGlobal: 'amplitude' in window, properties: window.amplitude ? { ...window.amplitude } : undefined, @@ -110,12 +152,62 @@ function handOver(payload, csp) { }; } +// Runs in the page before the SDK bundle, so what init() finds is an origin the SDK has never seen: no +// device ID, no session, no stored campaign. Only Amplitude's own keys go, since the site's login and the +// rest of its storage are what make it worth testing on. +function clearStoredSession(prefixes) { + const isAmplitude = (key) => prefixes.some((prefix) => key.startsWith(prefix)); + const removed = []; + + // document.cookie yields names and values and never the domain a cookie was set on, while the SDK writes + // to the highest domain it can — so each name is expired against every suffix of this hostname as well as + // host-only. Suffixes that may not hold cookies, like a public one, are refused rather than mis-set, and + // path=/ is what the SDK writes. + const labels = location.hostname.split('.'); + const domains = ['', ...labels.map((_, index) => `.${labels.slice(index).join('.')}`)]; + for (const pair of document.cookie ? document.cookie.split('; ') : []) { + const separator = pair.indexOf('='); + const name = (separator === -1 ? pair : pair.slice(0, separator)).trim(); + if (!name || !isAmplitude(name)) { + continue; + } + for (const domain of domains) { + document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:01 GMT; path=/${domain && `; domain=${domain}`}`; + } + removed.push(`cookie ${name}`); + } + + for (const [label, store] of [ + ['localStorage', localStorage], + ['sessionStorage', sessionStorage], + ]) { + // Reading either throws outright where the site's cookie policy forbids it, which says nothing about + // the other or about the cookies above. + try { + for (const key of Object.keys(store).filter(isAmplitude)) { + store.removeItem(key); + removed.push(`${label} ${key}`); + } + } catch (error) { + console.warn(`[amplitude-configurator] ${label} could not be read, so nothing was cleared from it`, error); + } + } + + // Named individually: "the session was cleared" and "the session was already empty" lead to different + // places when a run doesn't look the way it was expected to. + if (removed.length) { + console.log(`[amplitude-configurator] cleared ${removed.length} stored Amplitude entries`, removed); + } else { + console.log('[amplitude-configurator] no stored Amplitude state to clear on this origin'); + } +} + chrome.webNavigation.onCommitted.addListener( guard('injection', async ({ tabId, frameId, url }) => { if (frameId !== 0 || !url.startsWith('http')) { return; } - const payload = (await instrumentedTabs())[tabId]; + const payload = await takePayload(tabId); if (!payload) { return; } @@ -130,6 +222,9 @@ chrome.webNavigation.onCommitted.addListener( chrome.scripting.executeScript({ target, world: 'MAIN', injectImmediately: true, ...options }); try { await inject({ func: handOver, args: [payload, csp] }); + if (payload.clearSession) { + await inject({ func: clearStoredSession, args: [AMPLITUDE_STORAGE_PREFIXES] }); + } await inject({ files: [SDK_BUNDLE] }); if (payload.sessionReplay) { // Its own call: a plugin bundle that won't load shouldn't stop analytics from running, and @@ -165,6 +260,14 @@ function describe(payload) { // stands in the way, so this is now only a matter of the runner learning to load it. message += ' Guides and Surveys was left out: the runner does not load its CDN bundle yet.'; } + if (payload.mockReferrer) { + message += ` document.referrer reads ${payload.mockReferrer} on that first page, and the truth after it.`; + } + if (payload.clearSession) { + message += + " Amplitude's stored cookies and web storage are cleared first, so the run starts with a new device ID" + + ' and session.'; + } if (payload.apiKey === PLACEHOLDER_API_KEY) { message += ' No API key is set, so events are built but rejected.'; } diff --git a/test-server/configurator-extension/configurator-bridge.js b/test-server/configurator-extension/configurator-bridge.js index 3c43a8fa75..909d4195a7 100644 --- a/test-server/configurator-extension/configurator-bridge.js +++ b/test-server/configurator-extension/configurator-bridge.js @@ -14,11 +14,20 @@ window.addEventListener('message', (event) => { return; } const { id, action, payload } = event.data; - chrome.runtime.sendMessage({ action, payload }, (response) => { - const error = chrome.runtime.lastError?.message ?? response?.error; + const respond = (error, response) => window.postMessage( { source: RESPONSE_SOURCE, id, error, result: error ? undefined : response }, window.location.origin, ); - }); + try { + chrome.runtime.sendMessage({ action, payload }, (response) => { + respond(chrome.runtime.lastError?.message ?? response?.error, response); + }); + } catch { + // Reloading the extension orphans the content scripts already sitting on open pages: this one keeps + // running, but its chrome.runtime is gone and sendMessage throws rather than reaching anything. Saying + // so beats letting the page wait out its timeout and conclude the extension is broken, which is the + // opposite of what happened — it's this copy of the page that's stale. + respond('This page predates the last extension reload and can no longer reach it. Reload this page.'); + } }); diff --git a/test-server/configurator-extension/inject.js b/test-server/configurator-extension/inject.js index f6b36d1402..6e3c17a1a5 100644 --- a/test-server/configurator-extension/inject.js +++ b/test-server/configurator-extension/inject.js @@ -40,6 +40,13 @@ const config = revive(payload.analytics); const replayOptions = payload.sessionReplay ? revive(payload.sessionReplay) : null; + // Read back rather than echoed from the payload, so this also reports an override that didn't take. A + // mocked referrer changes what attribution reports and nothing else on the page gives it away, which + // makes it worth a line of its own before anything reads it. + if (payload.mockReferrer) { + console.log(`[amplitude-configurator] document.referrer is mocked as ${document.referrer}`); + } + // Every event the SDK builds, whether or not it can be uploaded. The postMessage is the seam a // devtools panel would read. const eventLogPlugin = { diff --git a/test-server/configurator-extension/manifest.json b/test-server/configurator-extension/manifest.json index 9693c598c0..09c52e54bb 100644 --- a/test-server/configurator-extension/manifest.json +++ b/test-server/configurator-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Amplitude Configurator Runner (spike)", - "version": "0.0.5", + "version": "0.0.8", "description": "Runs a configuration built in the Amplitude configurator on any site, by injecting the Browser SDK into it.", "permissions": ["declarativeNetRequestWithHostAccess", "scripting", "storage", "webNavigation", "webRequest"], "host_permissions": ["*://*/*"], diff --git a/test-server/configurator/default-state.js b/test-server/configurator/default-state.js index 1fa5ee689b..b8fb933d64 100644 --- a/test-server/configurator/default-state.js +++ b/test-server/configurator/default-state.js @@ -14,6 +14,14 @@ export function createDefaultState() { // The site "Run on URL" injects into. No SDK reads it — it rides along so that reopening a link // doesn't cost the site the configuration was last tried on. targetUrl: '', + // Stands in for document.referrer on the instrumented tab, so attribution can be tried without + // having to arrive from the referring site. Only the runner extension can honour it. + mockReferrer: '', + // Takes Amplitude's stored state off the site before the run starts, so it begins as a visitor the + // SDK has never seen. On by default: a run that inherits the last one's device ID and session is the + // harder thing to reason about, and it's what makes attribution look like it isn't working. Only the + // runner extension can honour it. + clearSession: true, // Which of the optional blades are in use, keyed as BLADES describes; analytics is always on. The // options below belong to one blade each and are only reachable while it is switched on. sessionReplay: false, diff --git a/test-server/configurator/extension-bridge.js b/test-server/configurator/extension-bridge.js index b10902d152..3f32a9388f 100644 --- a/test-server/configurator/extension-bridge.js +++ b/test-server/configurator/extension-bridge.js @@ -74,7 +74,13 @@ export function requestRun(payload) { } }; const timer = setTimeout( - () => finish(reject, new Error('The extension did not respond. Try reloading it at chrome://extensions.')), + // Reloading the extension is only half of it: that orphans this page's content script, so the page + // has to be reloaded after to be talking to the copy that was just loaded. + () => + finish( + reject, + new Error('The extension did not respond. Reload it at chrome://extensions, then reload this page.'), + ), RESPONSE_TIMEOUT, ); window.addEventListener('message', onMessage); diff --git a/test-server/configurator/main.jsx b/test-server/configurator/main.jsx index 117cd0b9f1..80bc2c4692 100644 --- a/test-server/configurator/main.jsx +++ b/test-server/configurator/main.jsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; import { createRoot } from 'react-dom/client'; -import { Button, CheckboxField, CheckboxGroup, CodeBlock, SelectField, TextField } from './components.jsx'; +import { Button, CheckboxField, CheckboxGroup, CodeBlock, Panel, SelectField, TextField } from './components.jsx'; import { AutocapturePanel } from './autocapture-panel.jsx'; import { SectionPanels } from './section-panels.jsx'; import { BLADES } from './blades.js'; @@ -31,12 +31,18 @@ const styles = { formSection: { borderTop: '1px solid #e5e5e5', paddingTop: 16, marginTop: 20 }, toolbar: { display: 'flex', alignItems: 'center', gap: 10, margin: '0 0 20px' }, toolbarNote: { color: '#888', fontSize: 12 }, - targetInput: { width: 300, padding: '6px 8px', font: 'inherit' }, + // Matches the width the install panel below it sits at, so the two read as one column. + runPanel: { maxWidth: 760 }, + runActions: { display: 'flex', alignItems: 'center', gap: 10, margin: '0 0 12px' }, }; // Lines up the labels of the handful of fields sitting above the panels. const TOP_LABEL_WIDTH = 100; +// Wider than the fields below, because "Mock Referrer" is longer than anything the SDK's own options are +// called. +const RUN_LABEL_WIDTH = 110; + const DEFAULT_STATE = createDefaultState(); // Sibling page that initialises the SDK with whatever the link carries. @@ -63,6 +69,8 @@ function Configurator({ initialState }) { const [copied, setCopied] = useState(false); const [blockedRunUrl, setBlockedRunUrl] = useState(null); const [targetUrl, setTargetUrl] = useState(initialState.targetUrl); + const [mockReferrer, setMockReferrer] = useState(initialState.mockReferrer); + const [clearSession, setClearSession] = useState(initialState.clearSession); const [targetNote, setTargetNote] = useState(null); // Running on another site is the extension's to do, so without it that button has nothing behind it. const runnerVersion = useExtensionVersion(); @@ -95,6 +103,8 @@ function Configurator({ initialState }) { apiKey, format, targetUrl, + mockReferrer, + clearSession, ...blades, configOptions, autocapture, @@ -149,6 +159,10 @@ function Configurator({ initialState }) { try { const result = await requestRun({ url: targetUrl.trim(), + // Left off entirely when blank, so the extension can tell "don't touch document.referrer" from + // "make it read empty", which is what arriving with no referrer at all looks like. + mockReferrer: mockReferrer.trim() || undefined, + clearSession, apiKey: key || PLACEHOLDER_API_KEY, analytics: toJsonSafe(analytics), sessionReplay: toJsonSafe(replay), @@ -193,31 +207,63 @@ function Configurator({ initialState }) { )}
-
- setTargetUrl(event.target.value)} - placeholder="https://example.com" - autoComplete="off" - spellCheck={false} - style={styles.targetInput} - /> - - - {targetNote ?? 'Runs this configuration on another site, through the runner extension.'} - + + + +
+ + {/* Only after a click: what this panel is for is said in its description, and repeating it here + would leave nowhere for the answer to appear. */} + {targetNote ? {targetNote} : null} +
+
diff --git a/test-server/configurator/runner-extension-panel.jsx b/test-server/configurator/runner-extension-panel.jsx index d1a22edff9..7e0343bc7a 100644 --- a/test-server/configurator/runner-extension-panel.jsx +++ b/test-server/configurator/runner-extension-panel.jsx @@ -1,7 +1,7 @@ // How to get hold of the extension "Run on URL" needs. It isn't in the Chrome Web Store, so there is no // install link to point at: the steps are the download this server builds, and Load unpacked. They mirror // test-server/configurator-extension/README.md, which is the fuller account. -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { CodeBlock, Panel } from './components.jsx'; const EXTENSION_DIRECTORY = 'test-server/configurator-extension'; @@ -12,6 +12,10 @@ const REPOSITORY_URL = `https://github.com/amplitude/Amplitude-TypeScript/tree/m // stands in whatever checkout is serving this page. const ARCHIVE_URL = '/configurator-extension.zip'; +// What this server's copy of the extension says its version is, served by test-server/extension-archive.js +// alongside the archive itself. +const VERSION_URL = '/configurator-extension-version.json'; + // The folder the archive holds, which is also the folder to load from a checkout, so the last two steps // read the same either way. const UNPACKED_FOLDER = 'configurator-extension'; @@ -28,15 +32,49 @@ const styles = { steps: { margin: '0 0 10px', paddingLeft: 20, fontSize: 13, color: '#444', lineHeight: 1.6 }, step: { marginBottom: 6 }, commands: { margin: '8px 0 4px' }, + stale: { color: '#a15c00', fontSize: 13, margin: '0 0 10px', fontWeight: 600 }, }; +// Undefined until it has been read, and stays undefined where nothing serves it — a hosted copy built +// before this existed, say — which the caller reads as "nothing to compare". +function useShippedVersion() { + const [version, setVersion] = useState(undefined); + + useEffect(() => { + void fetch(VERSION_URL) + .then((response) => (response.ok ? response.json() : null)) + .then((body) => setVersion(body?.version)) + .catch(() => setVersion(undefined)); + }, []); + + return version; +} + export function RunnerExtensionPanel({ version }) { + const shipped = useShippedVersion(); + // Only ever true when both are known. The page can't reach the extension's folder, so this comparison is + // the only thing that can tell a stale copy from a current one, and a stale copy is indistinguishable + // from a broken one otherwise: both simply fail to answer. + const isStale = Boolean(version && shipped && version !== shipped); + const staleNote = isStale + ? `The installed runner is ${version}, and this server ships ${shipped}. Reload it at chrome://extensions, then` + + ' reload this page — in that order, since reloading the extension is what leaves this page talking to the' + + ' copy it replaced.' + : null; + + let badge = 'not detected on this page'; + if (isStale) { + badge = `installed · ${version} · out of date`; + } else if (version) { + badge = `installed · ${version}`; + } + return (
- + {/* Outside the panel rather than inside it: the panel is collapsed until someone opens it, and a stale + copy is exactly the thing nobody thinks to go looking for. */} + {staleNote ?

{staleNote}

: null} +

The extension isn't in the Chrome Web Store, so Chrome will only take it as an unpacked folder. This server builds that folder into an archive for you, SDK bundles included. diff --git a/test-server/extension-archive.js b/test-server/extension-archive.js index dee47fa042..a21cf505e7 100644 --- a/test-server/extension-archive.js +++ b/test-server/extension-archive.js @@ -19,6 +19,20 @@ const ARCHIVE_NAME = 'configurator-extension.zip'; const ARCHIVE_URL = `/${ARCHIVE_NAME}`; +// The version this server would hand out, for the configurator to hold against the one the installed +// extension reports. An unpacked extension is a folder Chrome loaded once and won't look at again until it +// is reloaded, and the page it talks to is pinned to whichever copy was loaded when the page opened, so the +// two drift apart constantly during development with nothing to say that they have. It is served rather +// than read from the folder by the page because a hosted copy of the configurator has no folder to read. +const VERSION_NAME = 'configurator-extension-version.json'; + +const VERSION_URL = `/${VERSION_NAME}`; + +function shippedVersion(extensionDir) { + const manifest = JSON.parse(readFileSync(path.join(extensionDir, 'manifest.json'), 'utf8')); + return JSON.stringify({ version: manifest.version }); +} + // The single directory every entry sits under, named after the folder in the repository so the install // steps read the same whether the folder came from a checkout or from here. const ROOT_DIRECTORY = 'configurator-extension'; @@ -157,7 +171,14 @@ function buildArchive(extensionDir) { export function createExtensionArchive(extensionDir) { const serve = (req, res, next) => { - if (!req.url || req.url.split('?')[0] !== ARCHIVE_URL) { + const url = req.url?.split('?')[0]; + if (url === VERSION_URL) { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'no-store'); + res.end(shippedVersion(extensionDir)); + return; + } + if (url !== ARCHIVE_URL) { next(); return; } @@ -186,6 +207,7 @@ export function createExtensionArchive(extensionDir) { server.middlewares.use(serve); }, generateBundle() { + this.emitFile({ type: 'asset', fileName: VERSION_NAME, source: shippedVersion(extensionDir) }); try { this.emitFile({ type: 'asset', fileName: ARCHIVE_NAME, source: buildArchive(extensionDir) }); } catch (error) { From 9b728c0637494e281317753ba112765eb27369f0 Mon Sep 17 00:00:00 2001 From: Daniel Graham Date: Mon, 17 Aug 2026 10:46:09 -0700 Subject: [PATCH 3/3] chore: add source maps to configurator --- test-server/configurator-extension/.gitignore | 2 +- .../configurator-extension/manifest.json | 8 ++++- .../configurator-extension/sync-vendor.mjs | 30 +++++++++++++------ 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/test-server/configurator-extension/.gitignore b/test-server/configurator-extension/.gitignore index 0ffad36151..a65f73e3f3 100644 --- a/test-server/configurator-extension/.gitignore +++ b/test-server/configurator-extension/.gitignore @@ -1,2 +1,2 @@ -# Copied in from packages/analytics-browser/lib/scripts after a build; see README.md. +# Bundles and their .map files, copied in by sync-vendor.mjs after a build; see README.md. vendor/ diff --git a/test-server/configurator-extension/manifest.json b/test-server/configurator-extension/manifest.json index 09c52e54bb..b7301169fe 100644 --- a/test-server/configurator-extension/manifest.json +++ b/test-server/configurator-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Amplitude Configurator Runner (spike)", - "version": "0.0.8", + "version": "0.0.9", "description": "Runs a configuration built in the Amplitude configurator on any site, by injecting the Browser SDK into it.", "permissions": ["declarativeNetRequestWithHostAccess", "scripting", "storage", "webNavigation", "webRequest"], "host_permissions": ["*://*/*"], @@ -12,6 +12,12 @@ "action": { "default_title": "Run the Amplitude SDK on this tab" }, + "web_accessible_resources": [ + { + "resources": ["vendor/*.map"], + "matches": [""] + } + ], "content_scripts": [ { "matches": [ diff --git a/test-server/configurator-extension/sync-vendor.mjs b/test-server/configurator-extension/sync-vendor.mjs index 34a945a49b..d0351ed8b2 100644 --- a/test-server/configurator-extension/sync-vendor.mjs +++ b/test-server/configurator-extension/sync-vendor.mjs @@ -1,18 +1,22 @@ // Copies the SDK bundles this extension injects out of packages/, and works around Chromium's stricter -// idea of UTF-8 on the way. +// idea of UTF-8 on the way. Adjacent `.map` files come along so DevTools can unminify the injected +// scripts — the bundles already end in `//# sourceMappingURL=.map`. // // Chromium loads content script files through base::IsStringUTF8, which rejects Unicode non-characters // as well as malformed sequences. The session replay bundle carries four literal U+FFFE characters — // PostCSS comparing a string's first character against a byte order mark — so Chrome refuses the whole // file with "It isn't UTF-8 encoded". Escaping those code points is semantically identical inside the -// string and regex literals they appear in, and leaves the rest of the bundle untouched. +// string and regex literals they appear in, and leaves the rest of the bundle untouched. The map is +// left alone: it isn't loaded as a content script, and rewriting the escaped offsets isn't worth it +// for four characters. // // Run from the repository root: node test-server/configurator-extension/sync-vendor.mjs -import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; const HERE = path.dirname(new URL(import.meta.url).pathname); const PACKAGES = path.resolve(HERE, '../../packages'); +const VENDOR = path.join(HERE, 'vendor'); const BUNDLES = [ 'analytics-browser/lib/scripts/amplitude-min.js', @@ -22,10 +26,11 @@ const BUNDLES = [ // Non-characters: U+FDD0–U+FDEF and the last two code points of every plane. const REJECTED = /[\uFDD0-\uFDEF\uFFFE\uFFFF]/g; -await mkdir(path.join(HERE, 'vendor'), { recursive: true }); +await mkdir(VENDOR, { recursive: true }); for (const bundle of BUNDLES) { const source = path.join(PACKAGES, bundle); + const name = path.basename(bundle); let code; try { code = await readFile(source, 'utf8'); @@ -39,9 +44,16 @@ for (const bundle of BUNDLES) { escaped += 1; return `\\u${character.codePointAt(0).toString(16)}`; }); - const destination = path.join(HERE, 'vendor', path.basename(bundle)); - await writeFile(destination, output); - console.log( - `${path.basename(bundle)}: ${output.length} chars${escaped ? `, escaped ${escaped} non-characters` : ''}`, - ); + await writeFile(path.join(VENDOR, name), output); + console.log(`${name}: ${output.length} chars${escaped ? `, escaped ${escaped} non-characters` : ''}`); + + const mapName = `${name}.map`; + const mapSource = `${source}.map`; + try { + await copyFile(mapSource, path.join(VENDOR, mapName)); + console.log(`${mapName}: copied`); + } catch { + console.error(`Missing ${bundle}.map. Build the package first, then run this again.`); + process.exitCode = 1; + } }