Chris Tam · Last updated 19 October 2026
An Astro page is mostly static HTML, so when something breaks, your server logs rarely show why. Session replay records what each visitor saw and did, so "the form didn't submit" becomes a session you can watch.
In this tutorial, you'll turn on session replay, set a sample rate per environment, mask sensitive components and sections, and confirm a replay lands. It builds on How to add product analytics to an Astro site.
What you'll build
An Astro site that records every session in development and 1% of sessions in production, masks text inputs by default, masks anything wrapped in an AmpMask component and every page under /billing, replaces blocked elements with a placeholder, and tags the events you trigger after load with [Amplitude] Session Replay ID. By the end you'll play back your own test session from your event stream in Amplitude.
Prerequisites
- The setup from How to add product analytics to an Astro site:
@amplitude/unifiedinstalled,PUBLIC_AMPLITUDE_API_KEYin.env,initAllinsrc/scripts/amplitude.ts, and<Amplitude />rendered insrc/layouts/Layout.astro, so Verify can find your session. - An Astro 6 or later project. Either output mode works: every page on this one can be prerendered or rendered on demand, with or without
<ClientRouter />. - An Amplitude project API key. In Amplitude, go to Settings > Projects > your project > General.
- To change sampling or masking in Amplitude's settings, an Admin or Manager role. The code on this page doesn't need it.
No Amplitude account yet? Session replay for web is available on all plans, so a free account covers this tutorial. Sign up for free to get your project API key.
1. Install the SDK
The Quickstart installs @amplitude/unified for browser apps that use npm. It bundles analytics, session replay, Feature Experiment, and guides and surveys behind one initAll call, and session replay records once sessionReplay.sampleRate is above 0. If you followed the Astro product analytics page, it's already installed and .env holds PUBLIC_AMPLITUDE_API_KEY; skip to step 2. Otherwise install it, then add the key to .env as that page's step 1 shows.
npm install @amplitude/unifiedAlready on @amplitude/analytics-browser to keep bundle size down? The session replay plugin takes the same options as the sessionReplay block below. Add it to your existing init rather than installing a second SDK.
2. Set a sample rate and masking selectors
All the session replay config lives in the one client module every script imports from. This is the complete src/scripts/amplitude.ts. The first comment names the lines other Astro pages add here; if you followed any of them, keep those lines and change only the sessionReplay block.
// Keep lines other Astro pages added here: pageViews: { trackHistoryChanges:
// "pathOnly" } (/build/astro-web-analytics); the amplitude-cookies import, the
// device ID handoff lines, the deviceId spread, and the experiment block
// (/build/astro-feature-flags); the engagement block with renderCssInDom
// (/build/astro-guides-surveys); the optional webExperiment plugin lines below
// the amplitudeReady export (/build/astro-web-experiment). This page changes only
// sessionReplay. Import this module only from <script> tags, never from component
// frontmatter.
import * as amplitude from "@amplitude/unified";
export * from "@amplitude/unified";
const apiKey = import.meta.env.PUBLIC_AMPLITUDE_API_KEY;
if (!apiKey) {
console.warn("PUBLIC_AMPLITUDE_API_KEY is not set; analytics is off.");
}
// Resolves once initAll finishes. Feature flags and guides and surveys
// wait on it; nothing on this page needs to.
export const amplitudeReady: Promise<void> = apiKey
? amplitude
.initAll(apiKey, {
analytics: {
autocapture: {
attribution: true,
pageViews: true,
sessions: true,
formInteractions: true,
fileDownloads: true,
elementInteractions: true,
},
},
sessionReplay: {
// Every session while you build; about 1% in production.
sampleRate: import.meta.env.DEV ? 1 : 0.01,
// Ignores the focus check so replays record with DevTools open. Setup only.
debugMode: import.meta.env.DEV,
// Selectors for the data attributes in steps 3 and 4.
privacyConfig: {
maskSelector: ["[data-amp-mask]"],
blockSelector: ["[data-amp-block]"],
unmaskSelector: ["[data-amp-unmask]"],
},
},
})
.catch((error) => {
console.warn("Amplitude failed to initialize", error);
})
: Promise.resolve();import.meta.env.DEV is true under npm run dev and false in npm run build, so production builds record about one session in a hundred and never ship debugMode.
Why. Session replay takes a full snapshot of the page when it initializes, then records each change to the DOM as a diff. Astro runs this module in the browser after the HTML has loaded, so the masking attributes from steps 3 and 4 are already in the markup, straight from the prerendered or server-rendered HTML, when the first snapshot is taken. How the module runs depends on how your site navigates:
- Without
<ClientRouter />, every link is a full page load. The module runs again,initAllruns again, and session replay takes a fresh snapshot of the new page, while the device ID and session carry over from browser storage. Keep the defaultstoreTypeofidb: it holds replay events the SDK couldn't send yet in IndexedDB, whilememoryloses them when the page closes, which on a full-load site is every click. - With
<ClientRouter />, the module runs once per visit. The router replaces<body>on each navigation, and session replay records the swap as DOM changes, masking attributes included, because they arrive with the new body.
Either way, initialize only here. A second initAll or init call, for example in an island or an astro:page-load listener, can leave the replay and the session's events with different device or session IDs, and a replay that doesn't match its events is one you can't open from the event stream.
The plugin's own default sample rate is 0, so nothing records until you set one. Amplitude recommends starting production low, around 0.01, and raising it over a few days; capture stops for the month once your org's quota runs out. To stop recording, set the rate to 0. Session Replay Settings in Amplitude can also set a project's sample rate remotely, so check Settings > Organizational Settings > Session Replay Settings when the rate you see doesn't match your code. For every option, see the session replay plugin configuration; the Unified SDK accepts all of them inside sessionReplay except serverZone and instanceName, which go at the top level of the initAll options.
Unified also initializes guides and surveys by default. If you won't use them, the Unified SDK docs describe an engagement: { skip: true } option that stops that SDK from loading.
3. Mask sensitive elements in Astro components
Session replay masks every text input by default: typed characters arrive as asterisks. The project's default masking level, Medium, masks all form fields and text inputs and captures other text as-is. Everything else is your call, element by element.
The SDK recognizes three classes out of the box: amp-mask masks text, amp-block replaces an element with a placeholder, and amp-unmask unmasks a text input. The data attributes registered in step 2 do the same jobs through privacyConfig while keeping privacy separate from styling, and they're what the component below sets. Put one small component in charge of masking, so every page says it the same way:
---
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"div"> & {
// Replace the content with a same-size placeholder instead of asterisks.
block?: boolean;
// Use "span" inside inline content.
as?: "div" | "span" | "section";
};
const { block = false, as: Tag = "div", ...rest } = Astro.props;
---
<Tag
{...rest}
data-amp-mask={block ? undefined : ""}
data-amp-block={block ? "" : undefined}
>
<slot />
</Tag>Use it on any page or in any component, next to plain data attributes where a wrapper would be overkill:
---
import Layout from "../layouts/Layout.astro";
import AmpMask from "../components/AmpMask.astro";
type Member = { id: string; name: string; email: string; avatarUrl: string };
// Replace with data from your CMS, database, or API.
const members: Member[] = [
{ id: "1", name: "Test User", email: "test@example.com", avatarUrl: "https://placehold.co/32" },
];
---
<Layout>
<main>
<h1>Team</h1>
<!-- Inputs are masked by default. This one is safe to watch. -->
<input data-amp-unmask name="filter" placeholder="Filter by name" />
<ul>
{members.map((member) => (
<li class="member">
<!-- Replaced by a same-size placeholder -->
<AmpMask block as="span">
<img src={member.avatarUrl} alt="" width="32" height="32" />
</AmpMask>
<span>{member.name}</span>
<!-- Captured as asterisks -->
<AmpMask as="span">{member.email}</AmpMask>
</li>
))}
</ul>
<a href="/billing">Billing</a>
</main>
</Layout>
<style>
.member {
display: flex;
gap: 0.5rem;
}
</style>Astro leaves an attribute off when its value is undefined and writes it into the HTML otherwise, which the attribute selectors match. The page renders at build time or on the server, so the attributes are in the HTML before the SDK starts.
Why. Attributes you write on a component tag are props, not DOM attributes. <MemberEmail data-amp-mask /> passes a prop that an Astro component ignores unless it spreads Astro.props onto an element, and on a React, Vue, or Svelte island it's a prop of that framework's component. Nothing in the replay tells you. Wrap islands in the .astro file that renders them, <AmpMask><ProfileCard client:load /></AmpMask>: the wrapper is plain server HTML, so it's in the DOM before the island hydrates, and masking applies to everything the island renders inside it. Masking follows the DOM, though: a dialog or toast that a component library renders into document.body leaves its masked parent, so wrap the content inside the dialog itself. And masking covers text and form inputs, not HTML attributes. alt, title, placeholder, aria-label, value, and data-* attributes stay visible in replays even inside a masked element, so a tooltip written as title={member.email} leaks the address no matter what you mask.
Don't use generated class names as masking selectors, in code or in Session Replay Settings. Astro scopes component styles with generated astro- class names and data attributes, and CSS Modules hash theirs; both are produced at build time and can change when the component changes, at which point the rule stops matching without any error.
When the masking rules in Session Replay Settings and the SDK disagree about an element, the settings page wins. You can change the project's level (Light, Medium, Conservative) or add selector overrides there, per project; see Manage privacy settings for session replay.
4. Mask whole sections from the layout
Some pages shouldn't show any text in a replay, such as billing, invoices, or a patient record. Masking each element there is how one gets missed. Astro has no nested layout routes, but every page already renders your shared layout, and the layout knows the URL it's rendering. Mask by path there, and one file covers a whole section. If your layout has no frontmatter yet (the create astro starter's doesn't), add the --- fences at the top of the file. This is the whole layout with this page's lines merged in; keep anything else yours has, such as stylesheets or a <ClientRouter /> in <head>:
---
// src/layouts/Layout.astro (merge: add the AmpMask import, the path check, the conditional around <slot />, and the style block; keep the rest)
// Keep lines other Astro pages added here: the Amplitude import and <Amplitude />
// as the first thing in <body> (/build/astro-product-analytics); the
// experimentSrc constants and the is:inline experiment <script> first in <head>
// (/build/astro-web-experiment); the FeedbackButton import and
// <header><FeedbackButton /></header> after <Amplitude />
// (/build/astro-guides-surveys).
import Amplitude from "../components/Amplitude.astro";
import AmpMask from "../components/AmpMask.astro";
// Every page under these paths renders masked, including pages added later.
const MASKED_PATHS = ["/billing"];
const path = Astro.url.pathname;
const masked = MASKED_PATHS.some(
(prefix) => path === prefix || path.startsWith(`${prefix}/`)
);
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>My site</title>
</head>
<body>
<Amplitude />
{masked ? (
<AmpMask class="masked-section"><slot /></AmpMask>
) : (
<slot />
)}
</body>
</html>
<style is:global>
/* Keeps the masking wrapper out of the page layout. */
.masked-section {
display: contents;
}
</style>Astro.url is available on prerendered pages too, at build time, so this works in both output modes. The masked-section class sets display: contents, which keeps the wrapper out of your page layout, so flex and grid rules on <body> still apply to the page's own elements. It's a class in a <style> block rather than a style attribute because a Content-Security-Policy without 'unsafe-inline' in style-src, including Astro's security.csp, blocks style attributes. The team.astro example in step 3 uses a class for the same reason.
Add two pages for the section, so there's text to check in the replay. If you already have billing pages, they're covered as long as they use the layout:
---
import Layout from "../../layouts/Layout.astro";
---
<Layout>
<main>
<h1>Billing</h1>
<p>Card ending 4242, billed to Test User</p>
</main>
</Layout>---
import Layout from "../../layouts/Layout.astro";
---
<Layout>
<main>
<h1>Invoices</h1>
</main>
</Layout>To mask more sections, add their prefixes to MASKED_PATHS, for example ["/billing", "/account"].
Why. Masking an element through a selector masks all of its children, and a child inside it can't be unmasked again. That's what you want here: a developer who adds src/pages/billing/payment-methods.astro next year gets masking without knowing this file exists. It also means data-amp-unmask does nothing under /billing, so put content you want visible outside the section. With <ClientRouter />, the masked wrapper arrives inside the new <body> on every navigation into /billing, so it's in place before any of that page's text is recorded.
The layout masks text, not structure. Clicks, scrolling, and layout still record, so you can follow what the user did on /billing without reading it. To hide the region entirely, use <AmpMask block> in the layout instead.
The layout doesn't cover the page <title> in <head>; keep personal data out of titles under /billing. A page under /billing that doesn't use Layout.astro isn't masked either.
MCP. Working in Cursor or Claude Code? Amplitude's MCP server includes get_session_replays, and the debug-replay skill in Amplitude's MCP marketplace plugin turns a bug report into numbered reproduction steps from the replay's interaction timeline, so you don't have to scrub through sessions by hand.
5. Allow Amplitude in your CSP
Astro sends no Content-Security-Policy unless you turn on its security.csp option or your host, adapter, or middleware sets one. Skip this step unless you did.
If you have a policy, session replay needs these sources in it:
connect-src:https://api-sr.amplitude.comreceives replay data,https://sr-client-cfg.amplitude.comserves the session replay remote configuration, andhttps://api2.amplitude.comis where the analytics SDK sends events. EU projects useapi-sr.eu.amplitude.comandsr-client-cfg.eu.amplitude.comand setserverZone: 'EU'at the top level of theinitAlloptions.worker-src 'self' blob:, as in the example header in the session replay plugin's CSP guidance. The session replay SDK that@amplitude/unifiedinstalls compresses and sends replay data from web workers it creates fromblob:URLs. Without this source, the console shows CSP errors and the SDK falls back to the main thread.
Unified also loads guides and surveys by default, which needs https://*.amplitude.com in script-src, connect-src, img-src, media-src, and style-src; see the guides and surveys CSP guidance. Allow those, or turn guides and surveys off with engagement: { skip: true } in the initAll options.
Astro's security.csp hashes the scripts and styles it bundles and blocks inline style attributes, which is why steps 3 and 4 style with classes. It doesn't support <ClientRouter />, and it applies only to npm run build output, not the dev server. However you set the policy, test it with npm run build and npm run preview, and check the console.
Why. If remote configuration fails to load, session replay captures nothing, on purpose, so it never records without the privacy settings you defined in Amplitude. A blocked config request looks like "replay does nothing" rather than like an error, until you open the console and find a Content Security Policy message.
Verify it worked
Run npm run dev, open http://localhost:4321 in a fresh private window, visit /team, type into the filter, click the Billing link, then check in this order:
- Console and Network tab. No Content Security Policy errors in the console. In the Network tab, filter for
amplitude: you should see event uploads toapi2.amplitude.comreturning 200, a request tosr-client-cfg.amplitude.com, and replay uploads toapi-sr.amplitude.com. Replay uploads are batched, so a POST toapi-sr.amplitude.comappears a second or more after you move, scroll, or type, not while the tab sits idle. Check only that requests go out and return 200; the SDK compresses larger request bodies, so read events in the Instrumentation Explorer instead. Nothing starts until the module runs in the browser; the build and the server render send none. - Ingestion Debugger. In Amplitude, click Data, select Sources, then open the Ingestion Debugger tab. The successful requests chart should show your event uploads. It shows counts, not individual events.
- User lookup. Go to Users > User Profiles and search your device ID (the Instrumentation Explorer Chrome extension shows it as
device_id) or youruser-prefixed user ID if you're signed in. Within ten seconds to a minute, events you trigger after the page loads, such as[Amplitude] Element Clickedfrom the Billing link, carry[Amplitude] Session Replay ID, and the event stream shows a Play Session button next to that session. Events sent while the SDK starts up, such as[Amplitude] Start Sessionand the page view of a full page load, don't carry it. Replays are generally ready about five minutes after the session begins. - Session Replay debug panel. In the Instrumentation Explorer, turn on the Session Replay debug panel on your page. It floats over the page and shows the current replay ID, the session replay SDK and plugin versions, the sample rate, and the default and effective masking settings. A sample rate of 1 in development confirms step 2 took effect, and the effective masking settings show whether Session Replay Settings overrode your code.
- The replay itself. Play it. On
/team, the filter text is visible, emails are asterisks, and avatars are grey placeholders. On/billing, every line of text is masked while clicks and scrolling still show.
If the replay won't load, open the Ingestion Monitor from the top right of the Session Replay section of your project's Users & Sessions tab. It shows replay request statuses, including 429 throttling and 402 quota errors.
Check a production build too: run npm run build and npm run preview, then repeat step 1. Expect no Content Security Policy errors and event uploads to api2.amplitude.com returning 200; replay uploads to api-sr.amplitude.com appear only in the sessions that get sampled, about one in a hundred. That build is the one your host serves, and it's the only place a security.csp policy applies.
To force a fresh session while testing, delete the cookies that start with AMP_ in DevTools and reload.
Replays recorded against localhost can play back unstyled, because Amplitude can't fetch files from your machine. Check styling against a deployed preview. Development replays also show Astro's dev toolbar; production builds don't include it.
Troubleshooting
No replay uploads to api-sr.amplitude.com
The effective sample rate is 0. Requests to sr-client-cfg.amplitude.com still go out at any sample rate, so they don't mean the session is recording. Check sessionReplay.sampleRate, then the rate in Session Replay Settings; the debug panel shows the rate the SDK is using. A production build (npm run build, then npm run preview) has import.meta.env.DEV set to false and records only about one session in a hundred at 0.01, so test with npm run dev or raise the rate temporarily.
Replays record in production but not while you debug
Session replay only captures the tab that has focus, and an open DevTools panel can take it. Keep debugMode: import.meta.env.DEV from step 2, which ignores the focus check in development.
Events arrive but the session has no Play Session button
No event in that session carries [Amplitude] Session Replay ID. The usual cause is a second initAll or init call, for example in an island, an astro:page-load listener, or an is:inline CDN snippet in the layout, so the replay and the events disagree on device or session ID. Search the codebase and keep the one in src/scripts/amplitude.ts. Check a click or custom event, not a page view or session start: events sent while the SDK starts never carry the ID.
The replay stops or has gaps when the visitor reaches certain pages
Those pages don't load the SDK. Session replay has to run on every page a visitor sees, so check that every page renders your shared layout, or <Amplitude /> directly, including src/pages/404.astro and any page with its own <html> shell.
Masked text shows up in a replay
Check four things in order. The text sits in an attribute such as title or aria-label, which masking never covers. The data-amp-mask sits on a component or island tag and never reached the DOM; inspect the element, then wrap the content in AmpMask. The text renders into document.body, outside the masked wrapper, or on a /billing page that doesn't use Layout.astro. Or the element was unmasked in Session Replay Settings, which overrides the SDK; the debug panel's effective masking settings show it.
Old replays lose their styling after a deploy
Astro writes CSS to hashed file names under _astro/ in your build output, and a deploy that replaces the build output removes the old ones. Keep previous build assets available on your host, allow app.amplitude.com (app.eu.amplitude.com for EU projects) in your asset server's CORS policy, and add crossorigin="anonymous" to any external stylesheet <link> in your layout's <head>.
Requests fail with ERR_BLOCKED_BY_CLIENT
An ad blocker is dropping them. Session replay isn't compatible with ad blocking software, so test in a clean profile. For production traffic, route requests through your own domain and point trackServerUrl and configServerUrl at it, and set serverUrl (events) and remoteConfig.serverUrl (remote configuration) in the analytics block.
Replay works in dev but the console shows Content Security Policy errors after npm run build
Astro's security.csp only applies to the build output. Add the sources from step 5, including worker-src 'self' blob:, to security.csp.directives, then rebuild and run npm run preview. Errors about inline styles come from style attributes; move them to classes, as steps 3 and 4 do.
Full code reference
Every file this tutorial adds or changes, in one block, the version to hand to an agent. 6 files. Shared files start with a comment naming the lines other Astro pages add there; merge into your file and keep those lines. Unchanged from earlier pages; keep your versions: .env, src/components/Amplitude.astro, src/lib/auth.ts, src/middleware.ts, src/env.d.ts, src/components/SignOutButton.astro, src/components/CreateProjectForm.astro, src/pages/projects/new.astro.
// -- src/scripts/amplitude.ts --
// Keep lines other Astro pages added here: pageViews: { trackHistoryChanges:
// "pathOnly" } (/build/astro-web-analytics); the amplitude-cookies import, the
// device ID handoff lines, the deviceId spread, and the experiment block
// (/build/astro-feature-flags); the engagement block with renderCssInDom
// (/build/astro-guides-surveys); the optional webExperiment plugin lines below
// the amplitudeReady export (/build/astro-web-experiment). This page changes only
// sessionReplay. Import this module only from <script> tags, never from component
// frontmatter.
import * as amplitude from "@amplitude/unified";
export * from "@amplitude/unified";
const apiKey = import.meta.env.PUBLIC_AMPLITUDE_API_KEY;
if (!apiKey) {
console.warn("PUBLIC_AMPLITUDE_API_KEY is not set; analytics is off.");
}
// Resolves once initAll finishes. Feature flags and guides and surveys
// wait on it; nothing on this page needs to.
export const amplitudeReady: Promise<void> = apiKey
? amplitude
.initAll(apiKey, {
analytics: {
autocapture: {
attribution: true,
pageViews: true,
sessions: true,
formInteractions: true,
fileDownloads: true,
elementInteractions: true,
},
},
sessionReplay: {
// Every session while you build; about 1% in production.
sampleRate: import.meta.env.DEV ? 1 : 0.01,
// Ignores the focus check so replays record with DevTools open. Setup only.
debugMode: import.meta.env.DEV,
// Selectors for the data attributes in steps 3 and 4.
privacyConfig: {
maskSelector: ["[data-amp-mask]"],
blockSelector: ["[data-amp-block]"],
unmaskSelector: ["[data-amp-unmask]"],
},
},
})
.catch((error) => {
console.warn("Amplitude failed to initialize", error);
})
: Promise.resolve();
// -- src/components/AmpMask.astro --
---
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"div"> & {
// Replace the content with a same-size placeholder instead of asterisks.
block?: boolean;
// Use "span" inside inline content.
as?: "div" | "span" | "section";
};
const { block = false, as: Tag = "div", ...rest } = Astro.props;
---
<Tag
{...rest}
data-amp-mask={block ? undefined : ""}
data-amp-block={block ? "" : undefined}
>
<slot />
</Tag>
// -- src/pages/team.astro --
---
import Layout from "../layouts/Layout.astro";
import AmpMask from "../components/AmpMask.astro";
type Member = { id: string; name: string; email: string; avatarUrl: string };
// Replace with data from your CMS, database, or API.
const members: Member[] = [
{ id: "1", name: "Test User", email: "test@example.com", avatarUrl: "https://placehold.co/32" },
];
---
<Layout>
<main>
<h1>Team</h1>
<!-- Inputs are masked by default. This one is safe to watch. -->
<input data-amp-unmask name="filter" placeholder="Filter by name" />
<ul>
{members.map((member) => (
<li class="member">
<!-- Replaced by a same-size placeholder -->
<AmpMask block as="span">
<img src={member.avatarUrl} alt="" width="32" height="32" />
</AmpMask>
<span>{member.name}</span>
<!-- Captured as asterisks -->
<AmpMask as="span">{member.email}</AmpMask>
</li>
))}
</ul>
<a href="/billing">Billing</a>
</main>
</Layout>
<style>
.member {
display: flex;
gap: 0.5rem;
}
</style>
// -- src/layouts/Layout.astro (merge: add the AmpMask import, the path check, the conditional around <slot />, and the style block; keep the rest) --
---
// Keep lines other Astro pages added here: the Amplitude import and <Amplitude />
// as the first thing in <body> (/build/astro-product-analytics); the
// experimentSrc constants and the is:inline experiment <script> first in <head>
// (/build/astro-web-experiment); the FeedbackButton import and
// <header><FeedbackButton /></header> after <Amplitude />
// (/build/astro-guides-surveys).
import Amplitude from "../components/Amplitude.astro";
import AmpMask from "../components/AmpMask.astro";
// Every page under these paths renders masked, including pages added later.
const MASKED_PATHS = ["/billing"];
const path = Astro.url.pathname;
const masked = MASKED_PATHS.some(
(prefix) => path === prefix || path.startsWith(`${prefix}/`)
);
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>My site</title>
</head>
<body>
<Amplitude />
{masked ? (
<AmpMask class="masked-section"><slot /></AmpMask>
) : (
<slot />
)}
</body>
</html>
<style is:global>
/* Keeps the masking wrapper out of the page layout. */
.masked-section {
display: contents;
}
</style>
// -- src/pages/billing/index.astro --
---
import Layout from "../../layouts/Layout.astro";
---
<Layout>
<main>
<h1>Billing</h1>
<p>Card ending 4242, billed to Test User</p>
</main>
</Layout>
// -- src/pages/billing/invoices.astro --
---
import Layout from "../../layouts/Layout.astro";
---
<Layout>
<main>
<h1>Invoices</h1>
</main>
</Layout>Hand it to your agent
Prefer to have Cursor, Claude Code, or Codex CLI do the wiring? This prompt points the agent at this page and pins the decisions that matter.
Complete https://amplitude.com/build/astro-product-analytics first, or
confirm this Astro 6+ site already has its setup: @amplitude/unified,
PUBLIC_AMPLITUDE_API_KEY in .env, src/scripts/amplitude.ts with initAll
and the amplitudeReady export, src/components/Amplitude.astro rendered in
src/layouts/Layout.astro, and its src/lib/auth.ts stub and
src/pages/projects/new.astro if they're missing. Then add Amplitude session
replay by following https://amplitude.com/build/astro-session-replay
exactly. If the site already uses @amplitude/analytics-browser, add
@amplitude/plugin-session-replay-browser to the existing init instead of
installing a second SDK, pass the same options to sessionReplayPlugin({...}),
and call amplitude.add() with it before amplitude.init(). Otherwise keep
@amplitude/unified with initAll called once in src/scripts/amplitude.ts.
Merge into existing shared files (src/scripts/amplitude.ts,
src/layouts/Layout.astro) and never delete lines another
Amplitude Build page added; each file's first comment names them (the
pageViews options, the experiment block, the Web Experiment script tags).
In src/scripts/amplitude.ts change only sessionReplay: sampleRate
import.meta.env.DEV ? 1 : 0.01, debugMode import.meta.env.DEV, and
privacyConfig with [data-amp-mask], [data-amp-block], and
[data-amp-unmask]. Don't set storeType to memory. Never call initAll or
init anywhere else: not in an island, an astro:page-load listener, an
is:inline script, component frontmatter, middleware, or an endpoint.
Create src/components/AmpMask.astro, which renders data-amp-mask (or
data-amp-block when block is set) on a div, span, or section around its
slot and spreads the rest of its props onto it. Wrap elements that render
emails, billing names, or account data in it, wrap images of people or
documents in <AmpMask block>, and wrap framework islands in AmpMask in the
.astro file instead of putting attributes on the island tag. In
src/layouts/Layout.astro, add MASKED_PATHS with "/billing" and render
<slot /> inside <AmpMask class="masked-section"> when Astro.url.pathname
is under one of those paths, with .masked-section { display: contents; }
in a <style is:global> block, not a style attribute. If the site has no /team or /billing pages,
create src/pages/team.astro, src/pages/billing/index.astro, and
src/pages/billing/invoices.astro as the page shows so the Verify steps
run. Make sure every page renders the shared layout. Never use Astro's
generated astro- class names or CSS Modules class names as masking
selectors. If the site sets a Content-Security-Policy, add the page's
connect-src hosts and worker-src 'self' blob:; if it uses Astro's security.csp together with
<ClientRouter />, tell me instead of changing it. Flag any title,
aria-label, alt, or placeholder that contains personal data. Don't
hardcode the key. Report every file you changed and any
Content-Security-Policy you found.What this unlocks
- Session replay sits on the same events as your charts: open a funnel step that ends at
/billingand watch the sessions where users dropped, text masked. - Heatmaps are built from session replays, so once heatmap capture is on for the project in Session Replay Settings, clicks and scrolls on
/teamaggregate without new code. - Feature Experiment, bundled in
@amplitude/unified, lets you filter replays by variant and see how people use a new version of/team. - Guides and surveys, also bundled, can ask the visitors whose replays show a stall on
/billingwhat they were looking for.
One integration; everything else reads from the same events.
Next steps
Reference docs
Amplitude is the leading all-in-one analytics platform for building better products. Product and engineering teams use Amplitude to connect product analytics and web analytics with session replay, heatmaps, experimentation, feature flags, and guides and surveys in one unified workflow, with AI agents working across all of it. Learn how Amplitude is building self-improving products with Wave, helping teams turn product data into PRs.