diff --git a/test-server/configurator-extension/.gitignore b/test-server/configurator-extension/.gitignore
new file mode 100644
index 0000000000..0ffad36151
--- /dev/null
+++ b/test-server/configurator-extension/.gitignore
@@ -0,0 +1,2 @@
+# Copied in from packages/analytics-browser/lib/scripts after a build; see README.md.
+vendor/
diff --git a/test-server/configurator-extension/README.md b/test-server/configurator-extension/README.md
new file mode 100644
index 0000000000..61d1942f69
--- /dev/null
+++ b/test-server/configurator-extension/README.md
@@ -0,0 +1,90 @@
+# Configurator runner extension
+
+Runs a configuration built in the configurator on a site that doesn't have Amplitude installed, by
+injecting the Browser SDK into it. It exists because a web page can't instrument another origin: the
+configurator can generate the code, but only an extension can put it on someone else's site.
+
+## Setting it up
+
+The SDK bundles aren't checked in. Build them once, then vendor them in:
+
+```bash
+pnpm --dir packages/analytics-browser build
+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.
+
+## Using it
+
+**From the configurator.** Open `/configurator.html`, build a configuration, put a URL in the field next
+to the "Run on URL" button, and click it. The page hands the configuration to this extension, which opens
+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.
+
+**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
+from the start.
+
+## How the pieces fit
+
+`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
+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
+button can tell "not installed" from "not responding".
+
+`background.js` marks a tab, then injects on `webNavigation.onCommitted`: the configuration, the SDK
+bundle, session replay's bundle if it's configured, and finally `inject.js`, which wires them together.
+All of it goes into the page's main world, because the SDK has to share globals with the page — in an
+isolated world its patches to `fetch`, `XMLHttpRequest` and `history` would apply to nothing, and network
+and SPA page-view tracking would quietly capture nothing while clicks kept working.
+
+`csp.js` owns the site's Content-Security-Policy: `instrument()` removes it for that one tab and `forget()`
+puts it back, so no path can mark a tab without clearing the way or leave a tab unprotected afterwards. It
+also records what the policy said, which is the part worth having — see below.
+
+The configuration is materialised on the configurator side by `runtime-config.js`, the same code the run
+page uses, and travels as JSON. Regexes have nowhere to live in JSON, so `toJsonSafe()` turns them into
+`{ __regex, __flags }` markers and the reviver in `inject.js` turns them back.
+
+## Things worth knowing
+
+- **Host permissions are `*://*/*`.** That's what "any URL a customer gives us" means. A version anyone
+ else installs should ask per-site with `optional_host_permissions` and a prompt instead.
+- **The site's CSP is removed for the tab, and reported.** Injecting bundles works on a page that forbids
+ inline scripts, because injected files are exempt — but once the SDK runs, its requests are the page's
+ requests, so a strict `connect-src` or `script-src` blocks event uploads, remote config and the CDN
+ bundles. A `declarativeNetRequest` session rule removes the `content-security-policy` header, scoped to
+ the instrumented tab by the `tabIds` condition, which only session-scoped rules support. Note that a
+ second policy could not have loosened the first: CSP enforces every policy it is given, so appending only
+ narrows, and a declarative rule can't read the existing value to rewrite it. Removal is the only option
+ the browser offers.
+- **What the policy would have blocked is the finding, not the obstacle.** A customer whose CSP stops
+ Amplitude wants to know exactly that, so the original policy is compared against what the configuration
+ will actually reach for and reported in the instrumented page's console and the toolbar button's tooltip.
+ The comparison is a heuristic, not a CSP implementation: it follows the `default-src` fallback chain and
+ matches host, wildcard and scheme sources, but ignores ports, paths and scheme upgrades.
+- **Only header-delivered policies can be removed.** A policy in a `` tag never crosses
+ the network and is in force before anything here could see it, which makes `target-page.html` a harsher
+ case than most real sites. Removal is also per-tab: another tab on the same URL keeps its policy.
+- **Guides and Surveys isn't injected.** It fetches its bundle from the CDN per project, and the runner
+ doesn't load it yet. The site's CSP is no longer the reason — with the header removed, this is only a
+ matter of `inject.js` learning to add the script tag the run page already uses.
+- **Chromium is stricter about UTF-8 than UTF-8 is.** Content script files go through
+ `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`.
+- **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
+ leave the site's `init` and `track` pointing at the injected instance. `background.js` snapshots the
+ global first and `inject.js` puts it back, keeping its own client to itself.
diff --git a/test-server/configurator-extension/background.js b/test-server/configurator-extension/background.js
new file mode 100644
index 0000000000..bf3b54d5b5
--- /dev/null
+++ b/test-server/configurator-extension/background.js
@@ -0,0 +1,173 @@
+// Decides which tabs get instrumented, and injects the SDK into them.
+//
+// Injection happens on webNavigation.onCommitted rather than through a registered content script,
+// because a registered script can't be handed anything: this has to carry a configuration, and
+// executeScript(func, args) is the only injection that takes arguments. The cost is timing — onCommitted
+// with injectImmediately lands before the page's own scripts, but a little later than a document_start
+// registration would. If catching the very first request ever matters more than being configurable,
+// register instead and bake the configuration into a file.
+//
+// Two things stay true either way, and both matter:
+// world: 'MAIN' the SDK shares globals with the page, so it sees the page's own fetch, XHR and
+// history. In an isolated world those patches would apply to nothing.
+// top frame only for now, so a page full of iframes doesn't get an instance per frame.
+import { cspReport, relaxCsp, restoreCsp } from './csp.js';
+
+const SDK_BUNDLE = 'vendor/amplitude-min.js';
+const SESSION_REPLAY_BUNDLE = 'vendor/plugin-session-replay-browser-min.js';
+
+const TABS_KEY = 'instrumentedTabs';
+const LAST_PAYLOAD_KEY = 'lastPayload';
+
+// 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';
+
+// What the toolbar button uses before the configurator has sent anything.
+const FALLBACK_PAYLOAD = {
+ apiKey: 'REPLACE_WITH_A_SCRATCH_PROJECT_KEY',
+ analytics: {
+ logLevel: 4,
+ autocapture: {
+ attribution: true,
+ fileDownloads: true,
+ formInteractions: true,
+ pageViews: true,
+ sessions: true,
+ elementInteractions: true,
+ frustrationInteractions: true,
+ // The default only captures 500-599, which makes a healthy site look like nothing is working.
+ networkTracking: { captureRules: [{ hosts: ['*'], statusCodeRange: '200-599' }] },
+ webVitals: true,
+ },
+ },
+ sessionReplay: null,
+ engagement: null,
+};
+
+async function instrumentedTabs() {
+ const { [TABS_KEY]: tabs = {} } = await chrome.storage.session.get(TABS_KEY);
+ return tabs;
+}
+
+// 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' });
+}
+
+async function forget(tabId) {
+ const tabs = await instrumentedTabs();
+ delete tabs[tabId];
+ await chrome.storage.session.set({ [TABS_KEY]: tabs });
+ await restoreCsp(tabId);
+}
+
+// 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) {
+ window.__amplitudeConfigurator = {
+ hadGlobal: 'amplitude' in window,
+ properties: window.amplitude ? { ...window.amplitude } : undefined,
+ 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);
+ }
+ }
+ 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'];
+ if (payload.sessionReplay) {
+ parts.push('session replay');
+ }
+ let message = `Opened ${payload.url} with ${parts.join(' and ')}.`;
+ // Said before the navigation happens, so it can only promise the behaviour; what the policy actually
+ // said reaches the page console, which is where the rest of the run is read anyway.
+ message += " That tab's Content-Security-Policy is removed, and its console reports what it would have blocked.";
+ if (payload.engagement) {
+ // Its bundle is fetched from the CDN at runtime rather than packaged here. A strict CSP no longer
+ // 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.apiKey === PLACEHOLDER_API_KEY) {
+ message += ' No API key is set, so events are built but rejected.';
+ }
+ return message;
+}
+
+async function runOnUrl(payload) {
+ const url = new URL(payload.url);
+ if (!/^https?:$/.test(url.protocol)) {
+ 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() });
+ return { message: describe(payload) };
+}
+
+chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
+ if (message?.action !== 'run-on-url') {
+ return false;
+ }
+ runOnUrl(message.payload).then(sendResponse, (error) => sendResponse({ error: error.message }));
+ return true;
+});
+
+// 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.tabs.onRemoved.addListener((tabId) => forget(tabId));
diff --git a/test-server/configurator-extension/configurator-bridge.js b/test-server/configurator-extension/configurator-bridge.js
new file mode 100644
index 0000000000..3c43a8fa75
--- /dev/null
+++ b/test-server/configurator-extension/configurator-bridge.js
@@ -0,0 +1,24 @@
+// Sits on the configurator page and relays its requests to the service worker.
+//
+// This runs in an isolated world, so window.postMessage is the only channel it shares with the page —
+// which is the point: the page gets to reach the extension without knowing its ID, and the marker below
+// lets the page tell that the extension is there at all.
+const REQUEST_SOURCE = 'amplitude-configurator';
+const RESPONSE_SOURCE = 'amplitude-configurator-extension';
+
+document.documentElement.dataset.amplitudeConfigurator = chrome.runtime.getManifest().version;
+
+window.addEventListener('message', (event) => {
+ // Only messages this page sent to itself: anything cross-window is somebody else's business.
+ if (event.source !== window || event.data?.source !== REQUEST_SOURCE) {
+ return;
+ }
+ const { id, action, payload } = event.data;
+ chrome.runtime.sendMessage({ action, payload }, (response) => {
+ const error = chrome.runtime.lastError?.message ?? response?.error;
+ window.postMessage(
+ { source: RESPONSE_SOURCE, id, error, result: error ? undefined : response },
+ window.location.origin,
+ );
+ });
+});
diff --git a/test-server/configurator-extension/csp.js b/test-server/configurator-extension/csp.js
new file mode 100644
index 0000000000..896174e7ce
--- /dev/null
+++ b/test-server/configurator-extension/csp.js
@@ -0,0 +1,194 @@
+// Takes the site's Content-Security-Policy off the tab being instrumented, and reports what it said.
+//
+// Script *files* injected with chrome.scripting are exempt from the page's CSP, which is why the SDK
+// bundles load at all. Nothing the SDK does afterwards is exempt: the event uploads, the remote
+// configuration fetch, the CDN bundles and session replay's blob: worker are ordinary page requests, so a
+// strict connect-src or script-src stops them. Removing the header is the only offer the browser makes
+// here — CSP enforces every policy it is handed, so a second policy can only narrow the first, and a
+// declarative rule can't read the existing value in order to rewrite it.
+//
+// Removing it silently would throw away the most useful thing a run can tell you, so the original is
+// recorded and handed to the page: a customer whose CSP blocks Amplitude wants to hear which directive did
+// it. Only header-delivered policies can be touched — one in a never crosses the network
+// and is already in force by the time any of this could see it.
+
+const CSP_HEADERS = ['content-security-policy', 'content-security-policy-report-only'];
+
+// Hosts by server zone, from the SDK's own constants: analytics-core's types/constants.ts and
+// remote-config.ts, and session-replay-browser's constants.ts.
+const ZONES = {
+ US: {
+ events: 'https://api2.amplitude.com',
+ remoteConfig: 'https://sr-client-cfg.amplitude.com',
+ replay: 'https://api-sr.amplitude.com',
+ },
+ EU: {
+ events: 'https://api.eu.amplitude.com',
+ remoteConfig: 'https://sr-client-cfg.eu.amplitude.com',
+ replay: 'https://api-sr.eu.amplitude.com',
+ },
+};
+
+// Mirrors shouldFetchRemoteConfig() in analytics-browser/src/config.ts, which is the source of truth: on
+// unless something turns it off, and the nested setting wins.
+function fetchesRemoteConfig(analytics) {
+ if (analytics.remoteConfig?.fetchRemoteConfig === true) {
+ return true;
+ }
+ return analytics.remoteConfig?.fetchRemoteConfig !== false && analytics.fetchRemoteConfig !== false;
+}
+
+// Only what this configuration will actually reach for: a policy that would stop session replay is worth
+// nobody's attention if session replay is off.
+function requirementsFor({ analytics = {}, sessionReplay, engagement }) {
+ const zone = ZONES[analytics.serverZone] ?? ZONES.US;
+ const events = analytics.serverUrl ? new URL(analytics.serverUrl).origin : zone.events;
+ const requirements = [{ directive: 'connect-src', target: events, reason: 'event uploads' }];
+ if (fetchesRemoteConfig(analytics)) {
+ requirements.push({ directive: 'connect-src', target: zone.remoteConfig, reason: 'remote configuration' });
+ }
+ if (sessionReplay) {
+ requirements.push(
+ { directive: 'connect-src', target: zone.replay, reason: 'session replay uploads' },
+ { directive: 'worker-src', target: 'blob:', reason: "session replay's compression worker" },
+ );
+ }
+ if (engagement) {
+ // The only piece still fetched from the CDN at runtime rather than injected from this extension.
+ requirements.push({
+ directive: 'script-src',
+ target: 'https://cdn.amplitude.com',
+ reason: 'the Guides and Surveys bundle',
+ });
+ }
+ return requirements;
+}
+
+// Where each directive looks when it isn't named in the policy, per the CSP fallback chain.
+const FALLBACKS = {
+ 'connect-src': ['default-src'],
+ 'script-src': ['default-src'],
+ 'worker-src': ['child-src', 'script-src', 'default-src'],
+};
+
+// Tabs whose rule is in place. Rehydrated from the rules themselves, since they are the state that
+// survives the service worker being torn down between a run and the navigation it opened.
+const relaxed = new Set();
+chrome.declarativeNetRequest
+ .getSessionRules()
+ .then((rules) => rules.forEach(({ id }) => relaxed.add(id)))
+ .catch(() => undefined);
+
+// Only the policy of a tab under instrumentation is worth keeping, and only until the tab goes away.
+const policies = new Map();
+
+// An observer, not a modifier: what it sees is the response as it arrived, before the rule below takes the
+// header off. That ordering is what makes reporting the original policy possible at all.
+chrome.webRequest.onHeadersReceived.addListener(
+ ({ tabId, responseHeaders }) => {
+ if (!relaxed.has(tabId)) {
+ return;
+ }
+ const header = responseHeaders?.find(({ name }) => name.toLowerCase() === CSP_HEADERS[0]);
+ policies.set(tabId, header?.value);
+ },
+ { urls: [''], types: ['main_frame'] },
+ ['responseHeaders'],
+);
+
+// Tab id doubles as the rule id. Session scope is not a preference: the tabIds condition is only supported
+// there, and a rule outliving the browser would leave a stranger's tab unprotected.
+export async function relaxCsp(tabId) {
+ await chrome.declarativeNetRequest.updateSessionRules({
+ removeRuleIds: [tabId],
+ addRules: [
+ {
+ id: tabId,
+ priority: 1,
+ action: {
+ type: 'modifyHeaders',
+ responseHeaders: CSP_HEADERS.map((header) => ({ header, operation: 'remove' })),
+ },
+ condition: { tabIds: [tabId], resourceTypes: ['main_frame', 'sub_frame'] },
+ },
+ ],
+ });
+ relaxed.add(tabId);
+}
+
+export async function restoreCsp(tabId) {
+ relaxed.delete(tabId);
+ policies.delete(tabId);
+ await chrome.declarativeNetRequest.updateSessionRules({ removeRuleIds: [tabId] });
+}
+
+// First occurrence wins, which is how CSP reads a repeated directive.
+function parsePolicy(policy) {
+ const directives = new Map();
+ for (const part of policy.split(';')) {
+ const [name, ...sources] = part.trim().split(/\s+/);
+ if (name && !directives.has(name.toLowerCase())) {
+ directives.set(
+ name.toLowerCase(),
+ sources.map((source) => source.toLowerCase()),
+ );
+ }
+ }
+ return directives;
+}
+
+function sourcesFor(directives, directive) {
+ for (const name of [directive, ...(FALLBACKS[directive] ?? [])]) {
+ if (directives.has(name)) {
+ return directives.get(name);
+ }
+ }
+ return undefined;
+}
+
+// Close enough to be useful, and not a CSP implementation: enough of the grammar to tell an origin that is
+// plainly allowed from one that is plainly not.
+function permits(sources, target) {
+ if (sources.includes("'none'")) {
+ return false;
+ }
+ // A scheme has to be named outright — * covers network schemes only, not blob: or data:.
+ if (target.endsWith(':')) {
+ return sources.includes(target);
+ }
+ const { protocol, host } = new URL(target);
+ return sources.some((source) => {
+ if (source === '*' || source === protocol) {
+ return true;
+ }
+ // A host-source may carry a scheme, a port and a path, none of which change which host it names. Ports
+ // and paths are ignored rather than compared, and so is the scheme: an http source matches https under
+ // the spec's upgrade rules, and a policy naming http for an Amplitude endpoint isn't worth modelling.
+ const named = source.replace(/^[a-z][a-z0-9+.-]*:\/\//, '').replace(/[:/].*$/, '');
+ return named === host || (named.startsWith('*.') && host.endsWith(named.slice(1)));
+ });
+}
+
+// What the page was sent, and which of the SDK's requests it would have refused. Absent directives mean
+// silence rather than permission only where the fallback chain runs out, so a policy naming neither the
+// directive nor default-src allows the request.
+export function cspReport(tabId, payload) {
+ const policy = policies.get(tabId);
+ if (!policy) {
+ return null;
+ }
+ const directives = parsePolicy(policy);
+ const blocked = requirementsFor(payload).filter(({ directive, target }) => {
+ const sources = sourcesFor(directives, directive);
+ return sources !== undefined && !permits(sources, target);
+ });
+ return { policy, blocked, summary: summarise(blocked) };
+}
+
+function summarise(blocked) {
+ if (blocked.length === 0) {
+ return 'The site sent a Content-Security-Policy that allows what the SDK needs.';
+ }
+ const listed = blocked.map(({ reason, directive }) => `${reason} (${directive})`).join(', ');
+ return `The site's Content-Security-Policy would have blocked ${listed}.`;
+}
diff --git a/test-server/configurator-extension/inject.js b/test-server/configurator-extension/inject.js
new file mode 100644
index 0000000000..f6b36d1402
--- /dev/null
+++ b/test-server/configurator-extension/inject.js
@@ -0,0 +1,81 @@
+// Runs in the page's own world, after the SDK bundle, with the configuration left for it by
+// background.js.
+(() => {
+ const handover = window.__amplitudeConfigurator;
+ delete window.__amplitudeConfigurator;
+ if (!handover?.payload) {
+ console.warn('[amplitude-configurator] nothing to run: no configuration was handed over');
+ return;
+ }
+
+ // The configuration arrived as JSON, so regexes came through as markers. The encoder is toJsonSafe()
+ // in the configurator's extension-bridge.js — the two have to agree on this shape.
+ const revive = (value) => {
+ if (Array.isArray(value)) {
+ return value.map(revive);
+ }
+ if (value !== null && typeof value === 'object') {
+ if (typeof value.__regex === 'string') {
+ return new RegExp(value.__regex, value.__flags);
+ }
+ return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, revive(nested)]));
+ }
+ return value;
+ };
+
+ // The policy was taken off this tab before the page loaded, so what follows is not what the site would do
+ // on its own. Whether that mattered is worth saying out loud either way.
+ if (handover.csp?.blocked.length) {
+ console.warn(
+ `[amplitude-configurator] ${handover.csp.summary} It was removed for this tab, so this run says nothing` +
+ ' about whether the site can run Amplitude as it stands.',
+ handover.csp.policy,
+ );
+ } else if (handover.csp) {
+ console.log(`[amplitude-configurator] ${handover.csp.summary}`, handover.csp.policy);
+ }
+
+ const { payload } = handover;
+ const { apiKey } = payload;
+ const config = revive(payload.analytics);
+ const replayOptions = payload.sessionReplay ? revive(payload.sessionReplay) : null;
+
+ // Every event the SDK builds, whether or not it can be uploaded. The postMessage is the seam a
+ // devtools panel would read.
+ const eventLogPlugin = {
+ name: 'configurator-event-log',
+ type: 'enrichment',
+ setup: async () => undefined,
+ execute: async (event) => {
+ console.log('[amplitude-configurator]', event.event_type, event);
+ window.postMessage({ source: 'amplitude-configurator-events', event }, window.location.origin);
+ return event;
+ },
+ };
+
+ const client = window.amplitude.createInstance('configuratorRun');
+
+ // Give the page its global back before doing anything else. The bundle merged itself onto whatever was
+ // there, which on a site already running Amplitude means its init and track now point at the instance
+ // the bundle brought with it.
+ if (handover.hadGlobal) {
+ Object.assign(window.amplitude, handover.properties);
+ } else {
+ delete window.amplitude;
+ }
+
+ client.add(eventLogPlugin);
+ client.init(apiKey, config);
+
+ if (replayOptions) {
+ if (window.sessionReplay?.plugin) {
+ client.add(window.sessionReplay.plugin(replayOptions));
+ } else {
+ console.warn('[amplitude-configurator] session replay was configured but its bundle is missing');
+ }
+ }
+
+ // Left behind deliberately: this is a debugging tool, and the console is where it gets debugged from.
+ window.__amplitudeConfiguratorClient = client;
+ console.log('[amplitude-configurator] initialised', { apiKey, config, sessionReplay: replayOptions });
+})();
diff --git a/test-server/configurator-extension/manifest.json b/test-server/configurator-extension/manifest.json
new file mode 100644
index 0000000000..26da5e7f09
--- /dev/null
+++ b/test-server/configurator-extension/manifest.json
@@ -0,0 +1,29 @@
+{
+ "manifest_version": 3,
+ "name": "Amplitude Configurator Runner (spike)",
+ "version": "0.0.3",
+ "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": ["*://*/*"],
+ "background": {
+ "service_worker": "background.js",
+ "type": "module"
+ },
+ "action": {
+ "default_title": "Run the Amplitude SDK on this tab"
+ },
+ "content_scripts": [
+ {
+ "matches": [
+ "http://localhost/configurator.html*",
+ "https://localhost/configurator.html*",
+ "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*"
+ ],
+ "js": ["configurator-bridge.js"],
+ "run_at": "document_end"
+ }
+ ]
+}
diff --git a/test-server/configurator-extension/sync-vendor.mjs b/test-server/configurator-extension/sync-vendor.mjs
new file mode 100644
index 0000000000..34a945a49b
--- /dev/null
+++ b/test-server/configurator-extension/sync-vendor.mjs
@@ -0,0 +1,47 @@
+// Copies the SDK bundles this extension injects out of packages/, and works around Chromium's stricter
+// idea of UTF-8 on the way.
+//
+// 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.
+//
+// Run from the repository root: node test-server/configurator-extension/sync-vendor.mjs
+import { 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 BUNDLES = [
+ 'analytics-browser/lib/scripts/amplitude-min.js',
+ 'plugin-session-replay-browser/lib/scripts/plugin-session-replay-browser-min.js',
+];
+
+// 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 });
+
+for (const bundle of BUNDLES) {
+ const source = path.join(PACKAGES, bundle);
+ let code;
+ try {
+ code = await readFile(source, 'utf8');
+ } catch {
+ console.error(`Missing ${bundle}. Build the package first, then run this again.`);
+ process.exitCode = 1;
+ continue;
+ }
+ let escaped = 0;
+ const output = code.replace(REJECTED, (character) => {
+ 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` : ''}`,
+ );
+}
diff --git a/test-server/configurator-extension/target-page.html b/test-server/configurator-extension/target-page.html
new file mode 100644
index 0000000000..7a872cc1da
--- /dev/null
+++ b/test-server/configurator-extension/target-page.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+ Target page (extension spike)
+
+
+
Target page
+
+ A stand-in for a site under test. Nothing here loads Amplitude; the extension is expected to bring it. The page's
+ CSP allows only same-origin scripts and no inline ones.
+
+
+
+
+
+
+
+
diff --git a/test-server/configurator-extension/target-page.js b/test-server/configurator-extension/target-page.js
new file mode 100644
index 0000000000..94bd30b44f
--- /dev/null
+++ b/test-server/configurator-extension/target-page.js
@@ -0,0 +1,15 @@
+// The site's own script: it makes a request and changes the URL the way a real page would, so the
+// injected SDK has the page's fetch and history to observe rather than only its DOM.
+document.getElementById('fetch-button').addEventListener('click', () => {
+ fetch('/api/test').catch(() => undefined);
+});
+
+let navigations = 0;
+document.getElementById('navigate-button').addEventListener('click', () => {
+ navigations += 1;
+ history.pushState({}, '', `${location.pathname}#page-${navigations}`);
+});
+
+document.getElementById('a-form').addEventListener('submit', (event) => {
+ event.preventDefault();
+});
diff --git a/test-server/configurator/extension-bridge.js b/test-server/configurator/extension-bridge.js
new file mode 100644
index 0000000000..a1e86388f0
--- /dev/null
+++ b/test-server/configurator/extension-bridge.js
@@ -0,0 +1,60 @@
+// Talks to the runner extension, which is the only thing that can instrument a site this page has no
+// access to.
+//
+// The channel is window.postMessage rather than chrome.runtime.sendMessage: the extension's content
+// 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".
+const REQUEST_SOURCE = 'amplitude-configurator';
+const RESPONSE_SOURCE = 'amplitude-configurator-extension';
+
+// Set by the extension's bridge content script once it has loaded.
+const MARKER = 'amplitudeConfigurator';
+
+const RESPONSE_TIMEOUT = 5000;
+
+export function extensionVersion() {
+ return document.documentElement.dataset[MARKER];
+}
+
+// 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) {
+ if (value instanceof RegExp) {
+ return { __regex: value.source, __flags: value.flags };
+ }
+ if (Array.isArray(value)) {
+ return value.map(toJsonSafe);
+ }
+ if (value !== null && typeof value === 'object') {
+ return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, toJsonSafe(nested)]));
+ }
+ return value;
+}
+
+export function requestRun(payload) {
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
+ return new Promise((resolve, reject) => {
+ const finish = (settle, value) => {
+ clearTimeout(timer);
+ window.removeEventListener('message', onMessage);
+ settle(value);
+ };
+ const onMessage = (event) => {
+ if (event.source !== window || event.data?.source !== RESPONSE_SOURCE || event.data.id !== id) {
+ return;
+ }
+ if (event.data.error) {
+ finish(reject, new Error(event.data.error));
+ } else {
+ finish(resolve, event.data.result);
+ }
+ };
+ const timer = setTimeout(
+ () => finish(reject, new Error('The extension did not respond. Try reloading it at chrome://extensions.')),
+ RESPONSE_TIMEOUT,
+ );
+ window.addEventListener('message', onMessage);
+ window.postMessage({ source: REQUEST_SOURCE, id, action: 'run-on-url', payload }, window.location.origin);
+ });
+}
diff --git a/test-server/configurator/main.jsx b/test-server/configurator/main.jsx
index f16ff50c9c..6394a0ca03 100644
--- a/test-server/configurator/main.jsx
+++ b/test-server/configurator/main.jsx
@@ -9,7 +9,9 @@ import { SESSION_REPLAY_SECTIONS, TOP_LEVEL_SESSION_REPLAY_OPTIONS } from './ses
import { createDefaultState } from './default-state.js';
import { OptionField } from './option-field.jsx';
import { decodeStateFromUrl, encodeStateToUrl, hasSavedState } from './share-link.js';
-import { buildSnippet, SNIPPET_FORMATS, snippetLanguage } from './snippet.js';
+import { extensionVersion, requestRun, toJsonSafe } from './extension-bridge.js';
+import { buildRuntimeConfig } from './runtime-config.js';
+import { buildSnippet, PLACEHOLDER_API_KEY, SNIPPET_FORMATS, snippetLanguage } from './snippet.js';
const styles = {
page: { fontFamily: 'system-ui, sans-serif', maxWidth: 1400, margin: '48px auto', padding: '0 16px' },
@@ -26,6 +28,7 @@ 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' },
};
// Lines up the labels of the handful of fields sitting above the panels.
@@ -51,6 +54,8 @@ function Configurator({ initialState }) {
const [savedState, setSavedState] = useState(null);
const [copied, setCopied] = useState(false);
const [blockedRunUrl, setBlockedRunUrl] = useState(null);
+ const [targetUrl, setTargetUrl] = useState('');
+ const [targetNote, setTargetNote] = useState(null);
const setConfigOption = (key, value) => {
setConfigOptions((previous) => ({ ...previous, [key]: value }));
@@ -118,6 +123,35 @@ 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.
+ 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;
+ }
+ const { apiKey: key, analytics, sessionReplay: replay, engagement: guides } = buildRuntimeConfig(state);
+ setTargetNote('Opening…');
+ try {
+ const result = await requestRun({
+ url: targetUrl.trim(),
+ apiKey: key || PLACEHOLDER_API_KEY,
+ analytics: toJsonSafe(analytics),
+ sessionReplay: toJsonSafe(replay),
+ engagement: guides,
+ });
+ setTargetNote(result.message);
+ } catch (error) {
+ setTargetNote(error.message);
+ }
+ };
+
let linkNote =
'Copy this configuration as a link to bookmark or share, or run it in a new tab with the SDK initialised.';
if (isSaved) {
@@ -149,6 +183,23 @@ 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.'}
+
+