Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .cursor/skills/amplitude-typescript-repo/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
---
name: amplitude-typescript-repo
description: Contributes to the Amplitude TypeScript analytics SDK monorepo (pnpm workspaces, Lerna, Nx). Covers PR prep, local test-server workflow, and customer-site manual tests via e2e/manual-test.js. Use when changing packages under packages/, running repo scripts, preparing PRs, testing a live site against a local bundle, or when the user mentions Amplitude-TypeScript, browser/node SDKs, manual-test, or customer-site validation.
---

# Amplitude TypeScript monorepo

## Layout

- **Package manager**: `pnpm` with workspaces (`packages/*`).
- **Build orchestration**: Lerna (`pnpm build`, `pnpm test`, `pnpm lint` stream across packages); Nx available for affected/graph targets (`package.json` scripts with `nx` prefix).
- **Source**: SDK and plugin code lives under `packages/` (for example `analytics-browser`, `analytics-core`, `analytics-node`).

## Before opening a PR (match CI)

Run in order after substantive changes:

1. `pnpm install`
2. `pnpm build`
3. `pnpm docs:check`
4. `pnpm test` and `pnpm test:examples`
5. `pnpm lint`

Full contributor notes: [AGENTS.md](../../../AGENTS.md) at repo root.

## Environment

- CI uses Node.js **18.17.x**, **20.x**, and **22.x** — avoid APIs or syntax that break that range.

## PR titles

Use [Conventional Commits](https://www.conventionalcommits.org/) and include the affected module when it helps, for example `feat(browser): …`, `fix(plugin): …`.

## Scoped commands

- Prefer root scripts above for consistency.
- To target one package: `pnpm --filter <package-name> <script>` (see each package’s `package.json` for local script names).

## Change discipline

- Keep diffs focused on the requested behavior; avoid unrelated refactors or new docs unless asked.
- Match existing patterns in the touched package (imports, types, test style).

## Customer site manual test of analytics (`e2e/manual-test.js`)

**Tell the user up front:** this flow only applies to sites that load Amplitude’s **unified script** or that use cdn.amplitude.com tags.

### Why `dev:ssh`

`e2e/manual-test.js` rewrites the CDN request to `https://local.website.com:5173/unified-script-local.js`. The Vite test server must be reachable at that **HTTPS** origin. Use `pnpm dev:ssh` after the one-time HTTPS setup in [test-server/README.md](../../../test-server/README.md) (`/etc/hosts`, `generate-signed-cert`, trust cert in Keychain). If that setup is missing, the manual test will not load the local bundle. If the user struggles to find the script, make sure they ran `dev:ssh` first.

### Run the manual test

`node ./e2e/manual-test.js <website-url>`. Example: `node ./e2e/manual-test.js https://example.com`

Playwright opens a headed browser, proxies HTML to strip SRI on Amplitude script tags, and swaps the unified script for the local `test-server/unified-script-local.js` chain. Leave Terminal A running until finished; stop with Ctrl+C in each terminal when done.

### Integrity Hashes

If the user experiences a problem with the proxying not working due to SRI failures, instruct the user to take the shasum of the failing integrity hash and add it to manual-test.js under INTEGRITY_HASHES.

### Testing specific versions

Aside from testing local versions, users need to be able to go back and test old versions of the analytics or session replay SDK. If the user asks to test a specific version (e.g.: session replay v1.22.7) than set the environment variable (e.g.: SESSION_REPLAY_VERSION) to have it set to that instead of using local

### Building before testing

Don't build bundles before testing. Leave that up to the user. But notify them if they're having troubles that they may be using a stale bundle.

### Manual tests not automatic

These tests should be manual. The manual script opens the browser and the user tests. Never use HEADLESS and always leave the script running indefinitely
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,6 @@ packages/analytics-browser/playground/react-spa/public/amplitude.js
.nx/cache
.cursor/rules/nx-rules.mdc
.github/instructions/nx.instructions.md
.pnpm-store/
.pnpm-store/
.playwright-mcp/console-**
.playwright-mcp/page-**
181 changes: 181 additions & 0 deletions e2e/manual-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
// Headed Playwright session: proxy publisher pages to load local Amplitude bundles (see test-server README + pnpm dev:ssh).
import { chromium } from 'playwright';
Comment thread
daniel-graham-amplitude marked this conversation as resolved.

// Get URL from command line args
const targetUrl = process.argv[2];

if (!targetUrl) {
console.error('Usage: node ./e2e/manual-test.js <website-url>');
console.error('Example: node ./e2e/manual-test.js https://example.com');
process.exit(1);
}

// Integrity Hashes to rip out
// (RATIONALE: proxying fails if the integrity hash is set so just remove them
// from all HTML and JS responses for testing only)
// when you encounter a new integrity hash that's failing, just add it here
const INTEGRITY_HASHES = [
'sha384-7OMex1WYtzbDAdKl8HtBEJJB+8Yj6zAJRSeZhWCSQmjLGr4H2OBdrKtiw8HEhwgI',
'sha384-1JFhJprHbtX4G26DXID9oEguDxAc6L0h+pxDQaCvp4eIQuAtu0kWQWbJVdkx+k1x',
'sha384-tO0IrD5wYnaoQXROJVMmDUd7cp41nJ8GVLSjquFPrzzmYLdTiy5ePe8jbADN3UTJ',
];

function dropEncodingHeaders(headers) {
const out = { ...headers };
for (const key of Object.keys(out)) {
const lower = key.toLowerCase();
if (lower === 'content-encoding' || lower === 'content-length' || lower === 'transfer-encoding') {
delete out[key];
}
}
return out;
}

const SESSION_REPLAY_VERSION = process.env.SESSION_REPLAY_VERSION;
const ANALYTICS_BROWSER_VERSION = process.env.ANALYTICS_BROWSER_VERSION;

async function main() {
const browser = await chromium.launch({ headless: false });
const context = await browser.newContext();

await context.route('**/*', async (route) => {
const url = route.request().url();
if (
url.includes('cdn.amplitude.com/script/') &&
!url.includes('.async.js')
) {
console.log('Rerouting analytics-browser bundle');
const redirectedUrl = 'https://local.website.com:5173/unified-script-local.js';
return route.continue({ url: redirectedUrl });
}

if(
url.includes('cdn.amplitude.com/libs/analytics-browser-gtm-')
) {
console.log('Rerouting analytics-browser-gtm bundle');
const redirectedUrl = ANALYTICS_BROWSER_VERSION
? `https://cdn.amplitude.com/libs/analytics-browser-gtm-${ANALYTICS_BROWSER_VERSION}-min.js.gz`
: 'https://local.website.com:5173/analytics-browser/lib/scripts/amplitude-gtm-min.js.gz';
return route.continue({ url: redirectedUrl });
}

if(
url.includes('cdn.amplitude.com/libs/analytics-browser-')
) {
console.log('Rerouting analytics-browser bundle');
const redirectedUrl = ANALYTICS_BROWSER_VERSION
? `https://cdn.amplitude.com/libs/analytics-browser-${ANALYTICS_BROWSER_VERSION}-min.js.gz`
: 'https://local.website.com:5173/analytics-browser/lib/scripts/amplitude-min.js.gz';
return route.continue({ url: redirectedUrl });
}

if(
url.includes('cdn.amplitude.com/libs/plugin-session-replay-browser-')
) {
console.log('Rerouting plugin-session-replay-browser bundle');
const redirectedUrl = SESSION_REPLAY_VERSION
? `https://cdn.amplitude.com/libs/plugin-session-replay-browser-${SESSION_REPLAY_VERSION}-min.js.gz`
: 'https://local.website.com:5173/plugin-session-replay-browser/lib/scripts/plugin-session-replay-browser-min.js.gz';
return route.continue({ url: redirectedUrl });
}

const AMPLITUDE_API_KEY = process.env.AMPLITUDE_API_KEY;

if (
url.includes('amplitude.com/2/httpapi') &&
AMPLITUDE_API_KEY
) {
// Change API Key to your own local API Key
const request = route.request();
const postData = request.postDataJSON();
const modifiedPostData = { ...postData, api_key: AMPLITUDE_API_KEY };

// re-direct to EU API endpoint
const modifiedUrl = url.replace('api.eu.amplitude.com', 'api.amplitude.com');

return await route.continue({ url: modifiedUrl, postData: JSON.stringify(modifiedPostData) });
}

// GTM injects Amplitude loader tags with SRI inside JS; strip so rerouted local bytes validate.
if (
route.request().resourceType() === 'script' &&
url.includes('googletagmanager.com')
) {
try {
const response = await route.fetch();
let body = await response.text();
for (const hash of INTEGRITY_HASHES) {
body = body.replaceAll(hash, '');
}
Comment thread
cursor[bot] marked this conversation as resolved.
await route.fulfill({
status: response.status(),
headers: dropEncodingHeaders(response.headers()),
body,
});
return;
} catch (e) {
console.warn('GTM script rewrite failed, passing through:', url, e.message);
return route.continue();
}
}

if (route.request().resourceType() !== 'document') {
return route.continue();
}
// Only rewrite top-level document navigations. Subframe "document" loads (ads, sync pixels)
// often fail or reset on `route.fetch()` and do not need SRI stripping for our Amplitude swap.
if (route.request().frame().parentFrame() !== null) {
return route.continue();
}
let response;
try {
response = await route.fetch();
} catch (e) {
console.warn('route.fetch failed, passing through:', route.request().url(), e.message);
return route.continue();
}
let content = await response.text();
for (const hash of INTEGRITY_HASHES) {
content = content.replaceAll(hash, '');
}

const ct = (response.headers()['content-type'] || '').toLowerCase();
if (!ct.includes('text/html')) {
return route.fulfill({
status: response.status(),
headers: dropEncodingHeaders(response.headers()),
body: content,
});
}
let html = content;

for (const hash of INTEGRITY_HASHES) {
html = html.replaceAll(hash, '');
}
Comment thread
daniel-graham-amplitude marked this conversation as resolved.

await route.fulfill({
status: response.status(),
headers: dropEncodingHeaders(response.headers()),
body: html,
});
});

const page = await context.newPage();

console.log(`\nNavigating to: ${targetUrl}\n`);
// `networkidle` often never settles on publisher sites (ads, analytics, long-poll).
await page.goto(targetUrl, {
waitUntil: 'load',
timeout: 0,
});
console.log('\nPage loaded. Browser will stay open. Press Ctrl+C to exit.');

// Keep process alive
process.on('SIGINT', async () => {
console.log('\nClosing browser...');
await browser.close();
process.exit(0);
});
}

main();
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"jest-environment-jsdom": "^29.3.1",
"lerna": "^9.0.0",
"lint-staged": "^15.2.0",
"playwright": "1.55.0",
"morgan": "^1.10.0",
"nodemon": "^3.0.1",
"nx": "^21.2.1",
Expand Down
Loading
Loading