How to add session replay to an Angular app

Turn on Amplitude session replay in an Angular app, record every session in development and a small share in production, mask sensitive elements and whole routes, and open your own test replay. About 15 minutes, all copy-paste.

  • Angular 20
  • Unified SDK 1.x
  • 15 min

Chris Tam · Last updated 14 October 2026

Bug reports rarely include the state that caused them. Session replay records what each user of your Angular app saw and did, so "the button didn't work" becomes a session you can watch.

I turn on session replay, set a sample rate per environment, mask sensitive elements and routes, and check a test session. The complete setup on this page works without completing another tutorial.

What you'll build

An Angular app that records every session under ng serve and 1% of sessions in a production build, masks text inputs by default, masks any element marked with an ampMask directive and the routed content under /billing, replaces blocked elements with a placeholder, and tags the session's events with [Amplitude] Session Replay ID. It works with and without Angular SSR. By the end you'll play back your own test session from your event stream in Amplitude.

Prerequisites

A Node.js 22.12+ (22.x) or Node.js 24 environment and pnpm for the Angular 20 demo below. An existing app can use the same additions after merging them into its current configuration.

The complete demo uses Angular 20 and the app.ts / App file names. When merging into an existing app, keep its actual root component file.

  • 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

I use the Browser Unified SDK installed with pnpm. Its initAll call accepts analytics and session replay options. The complete setup below includes the SDK dependency; if your app already initializes Amplitude, merge only the additions.

I start with the complete demo below. No earlier Build tutorial is required. In an existing app, merge the SDK and route additions into its current files; keep authentication, identity handling and other instrumentation. The setup block contains separate files: create each named file before running the terminal commands.

Complete setup files (split at each filename)
// -- package.json --
{
  "name": "angular-session-replay",
  "private": true,
  "packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8",
  "scripts": {
    "start": "ng serve",
    "build": "ng build"
  },
  "dependencies": {
    "@amplitude/unified": "1.1.42",
    "@angular/common": "20.3.34",
    "@angular/compiler": "20.3.34",
    "@angular/core": "20.3.34",
    "@angular/platform-browser": "20.3.34",
    "@angular/router": "20.3.34",
    "rxjs": "7.8.2",
    "tslib": "2.8.1",
    "zone.js": "0.15.1"
  },
  "devDependencies": {
    "@angular/build": "20.3.40",
    "@angular/cli": "20.3.40",
    "@angular/compiler-cli": "20.3.34",
    "typescript": "5.9.3"
  }
}

// -- pnpm-workspace.yaml --
minimumReleaseAge: 10080
trustPolicy: no-downgrade

// -- angular.json --
{
  "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
  "version": 1,
  "projects": {
    "demo": {
      "projectType": "application",
      "root": "",
      "sourceRoot": "src",
      "architect": {
        "build": {
          "builder": "@angular/build:application",
          "options": {
            "browser": "src/main.ts",
            "tsConfig": "tsconfig.app.json",
            "index": "src/index.html",
            "polyfills": [
              "zone.js"
            ],
            "assets": [
              {
                "glob": "**/*",
                "input": "public"
              }
            ],
            "styles": []
          },
          "configurations": {
            "production": {
              "outputHashing": "all"
            },
            "development": {
              "optimization": false,
              "sourceMap": true,
              "fileReplacements": [
                {
                  "replace": "src/environments/environment.ts",
                  "with": "src/environments/environment.development.ts"
                }
              ]
            }
          },
          "defaultConfiguration": "production"
        },
        "serve": {
          "builder": "@angular/build:dev-server",
          "configurations": {
            "production": {
              "buildTarget": "demo:build:production"
            },
            "development": {
              "buildTarget": "demo:build:development"
            }
          },
          "defaultConfiguration": "development"
        }
      }
    }
  }
}

// -- tsconfig.json --
{
  "compileOnSave": false,
  "compilerOptions": {
    "strict": true,
    "skipLibCheck": true,
    "experimentalDecorators": true,
    "importHelpers": true,
    "target": "ES2022",
    "module": "preserve",
    "moduleResolution": "bundler",
    "lib": [
      "ES2022",
      "dom"
    ],
    "useDefineForClassFields": false
  },
  "angularCompilerOptions": {
    "strictInjectionParameters": true,
    "strictInputAccessModifiers": true,
    "strictTemplates": true
  }
}

// -- tsconfig.app.json --
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./out-tsc/app",
    "types": []
  },
  "files": [
    "src/main.ts"
  ],
  "include": [
    "src/**/*.d.ts"
  ]
}

// -- src/index.html --
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Local demo</title><base href="/"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><app-root></app-root></body></html>

// -- src/environments/environment.ts --
export const environment = { production: true, amplitudeApiKey: 'your-project-api-key' };

// -- src/environments/environment.development.ts --
export const environment = { production: false, amplitudeApiKey: 'your-project-api-key' };

// -- src/main.ts --
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { appConfig } from './app/app.config';
import { initAmplitude } from './app/amplitude';

// Run before Angular creates its zone. Do not add this to main.server.ts.
initAmplitude();
bootstrapApplication(App, appConfig).catch(console.error);

// -- src/app/app.config.ts --
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = { providers: [provideZoneChangeDetection(), provideRouter(routes)] };

// -- src/app/app.ts --
import { Component } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router';
@Component({
  selector: 'app-root',
  imports: [RouterLink, RouterOutlet],
  template: `<nav><a routerLink="/">Home</a> <a routerLink="/team">Team</a> <a routerLink="/billing">Billing</a> <a routerLink="/billing/invoices">Invoices</a></nav><router-outlet />`
})
export class App {}

// -- src/app/home.ts --
import { Component } from '@angular/core';
@Component({ selector: 'app-home', template: '<h1>Local demo</h1><p>Use the navigation to exercise the tutorial.</p>' })
export class Home {}

// -- src/app/projects/new-project.ts --
import { Component } from '@angular/core';
@Component({ selector: 'app-new-project', template: '<h1>New project</h1>' })
export class NewProject {}
Terminal
mkdir angular-session-replay-demo
cd angular-session-replay-demo
# Create the named setup files shown on this page, then install:
pnpm install
# Start the app after adding the SDK and route files in the remaining steps.

The two environment files in the setup block hold the public project API key. Set the same development project's key in both files when you are ready to check ingestion. The development build sets production: false, and the production build uses production: true.

Already 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 per environment

I initialize once from src/main.ts, before bootstrapApplication, using the complete entry point in step 1. I choose the sample rate from environment.production. In an existing app, keep any options another tutorial added and merge only the sessionReplay changes.

src/app/amplitude.ts
// Keep lines other Angular pages added here: pageViews: { trackHistoryChanges: 'pathOnly' }
// (/build/angular-web-analytics); the setAmplitudeReady(...) wrapper, its import and the
// setAmplitudeReady(null) line (/build/angular-feature-flags, /build/angular-guides-surveys);
// the experiment block and any analytics.deviceId handoff (/build/angular-feature-flags);
// the WebExperimentGlobal type and the amplitude.add(webExperiment.plugin()) block
// (/build/angular-web-experiment).
import * as amplitude from '@amplitude/unified';
import { environment } from '../environments/environment';

export function initAmplitude(): void {
  if (!environment.amplitudeApiKey) {
    console.warn('amplitudeApiKey is not set; analytics is off.');
    return;
  }

  // This page doesn't wait for initAll. If setAmplitudeReady(...) wraps this call, keep it.
  void amplitude.initAll(environment.amplitudeApiKey, {
    // This tutorial does not load guides and surveys. Keep your existing engagement setup if you use it.
    engagement: { skip: true },
    analytics: {
      autocapture: {
        attribution: true,
        pageViews: true,
        // Tracks [Amplitude] Start Session and End Session events.
        sessions: true,
        formInteractions: true,
        fileDownloads: true,
        elementInteractions: true,
      },
    },
    sessionReplay: {
      useWebWorker: false, // Explicitly use main-thread processing for this demo.
      // Fraction of sessions to record: every session under ng serve, 1% in a production build.
      sampleRate: environment.production ? 0.01 : 1,
      // Adds [Amplitude] Session Replay Debug with an API key hash. Initial setup only.
      debugMode: !environment.production,
      // Selectors for the data attributes in steps 3 and 4.
      privacyConfig: {
        maskSelector: ['[data-amp-mask]'],
        blockSelector: ['[data-amp-block]'],
        unmaskSelector: ['[data-amp-unmask]'],
      },
    },
  });
}

Why. Session replay takes a full snapshot of the page when it initializes, then records every change to the page after it. main.ts runs once per page load, in the browser only; with Angular SSR the server renders from main.server.ts, which never calls initAmplitude(). With SSR, the masking attributes from steps 3 and 4 are already in the server HTML when the first snapshot is taken. Without SSR, Angular sets the attribute in the same render pass that creates the element, before the recorder flushes its next batch of changes. Initializing once also matters for matching: initializing the SDK more than once 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. An initAll in a component constructor, ngOnInit, or provideAppInitializer runs inside the Angular zone, which the Browser SDK docs ask Zone.js apps to avoid, and an initAll in a routed component runs again on every visit to that route.

Later pages in this hub show src/app/amplitude.ts again. If you copy a version that lacks privacyConfig, keep this block.

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 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.

Guides and surveys initialize by default in the Unified SDK. This demo sets engagement: { skip: true } because it does not use them. Keep your current engagement configuration if your existing app does use them, and follow that SDK's CSP requirements.

3. Mask sensitive elements with a directive

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, amp-block, and amp-unmask (for inputs), and they work on any element in an Angular template. The data attributes registered in step 2 keep privacy separate from styling, and they're what the directive below sets. Put one small attribute directive in charge of masking, so every template says it the same way:

src/app/privacy/amp-mask.ts
import { Directive, input } from '@angular/core';

// <span ampMask> masks text as asterisks.
// <img ampMask="block"> replaces the element with a same-size placeholder.
@Directive({
  selector: '[ampMask]',
  host: {
    '[attr.data-amp-mask]': "ampMask() === 'block' ? null : ''",
    '[attr.data-amp-block]': "ampMask() === 'block' ? '' : null",
  },
})
export class AmpMask {
  readonly ampMask = input<'' | 'block'>('');
}

Use it in any component, next to plain data attributes where a directive would be overkill. Add AmpMask to the component's imports:

public/avatar.svg
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><rect width="32" height="32" fill="#0052F2"/><circle cx="16" cy="12" r="6" fill="white"/><path d="M5 31c0-14 22-14 22 0" fill="white"/></svg>
src/app/team/team.ts
import { Component, signal } from '@angular/core';
import { AmpMask } from '../privacy/amp-mask';

interface Member {
  id: string;
  name: string;
  email: string;
  avatarUrl: string;
}

@Component({
  selector: 'app-team',
  imports: [AmpMask],
  template: `
    <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>
      @for (member of members(); track member.id) {
        <li class="row">
          <!-- Replaced by a same-size placeholder -->
          <img ampMask="block" [src]="member.avatarUrl" alt="" width="32" height="32" />
          <span>{{ member.name }}</span>
          <!-- Captured as asterisks -->
          <span ampMask>{{ member.email }}</span>
        </li>
      }
    </ul>
  `,
  styles: `
    .row {
      display: flex;
      gap: 0.5rem;
    }
  `,
})
export class Team {
  // Replace with data from your API.
  protected readonly members = signal<Member[]>([
    { id: '1', name: 'Test User', email: 'test@example.com', avatarUrl: '/avatar.svg' },
  ]);
}

A bare ampMask passes an empty string and adds data-amp-mask="". ampMask="block" adds data-amp-block="" instead. The input accepts only those two states; passing null is not a supported way to disable this directive.

Why. In Angular, a component's host element is a real element in the DOM, so <app-member-card ampMask> or <app-member-card data-amp-mask> masks everything that component renders. <ng-container> and <ng-template> are the exceptions: they render no element, so an attribute or directive on them masks nothing, and nothing in the replay tells you. Put it on a real element instead. Masking also follows the DOM, not the component tree. Angular CDK overlays, which back Angular Material's dialogs, menus, snack bars, and tooltips, render into a container at the end of document.body, outside any masked parent. Put ampMask inside the dialog component's own template, and don't feed personal data to matTooltip. 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 [title]="member.email" leaks the address no matter what you mask.

Don't target the attributes Angular generates for component styles, such as _ngcontent-ng-c123456 or _nghost-ng-c123456, as masking selectors, in code or in Session Replay Settings. Angular generates them at build time and they can change between builds, 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 routes with a parent route

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. In Angular Router, a parent route's component wraps every child route in its own <router-outlet>, so one component covers a whole section:

src/app/billing/billing-layout.ts
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { AmpMask } from '../privacy/amp-mask';

@Component({
  selector: 'app-billing-layout',
  imports: [RouterOutlet, AmpMask],
  template: `
    <!-- Everything routed under /billing renders inside this div, including pages added later. -->
    <div ampMask>
      <router-outlet />
    </div>
  `,
})
export class BillingLayout {}

Add two pages for the section, so there's text to check in the replay. If you already have billing pages, use those instead:

src/app/billing/billing-home.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-billing-home',
  template: `
    <h1>Billing</h1>
    <p>Card ending 4242, billed to Test User</p>
  `,
})
export class BillingHome {}
src/app/billing/invoices.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-invoices',
  template: `<h1>Invoices</h1>`,
})
export class Invoices {}

The complete route file below includes the demo home route and the new screens. For an existing app, merge these routes into its current array and keep all existing routes and titles.

src/app/app.routes.ts
// Keep lines other Angular pages added here: their routes, such as projects/:projectId and
// settings (/build/angular-heatmaps) and pricing, welcome, login, account, plans and offer/:code
// (/build/angular-web-analytics), and the title on each route if you added titles there.
import { Routes } from '@angular/router';
import { Home } from './home';
import { NewProject } from './projects/new-project';
import { Team } from './team/team';
import { BillingLayout } from './billing/billing-layout';
import { BillingHome } from './billing/billing-home';
import { Invoices } from './billing/invoices';

export const routes: Routes = [
  { path: '', component: Home },
  { path: 'projects/new', component: NewProject },
  { path: 'team', component: Team },
  {
    path: 'billing',
    component: BillingLayout,
    children: [
      { path: '', component: BillingHome },
      { path: 'invoices', component: Invoices },
    ],
  },
];

The complete src/app/app.ts in the setup block includes the demo navigation. For an existing app, add the links below to its current navigation without replacing the component.

src/app/app.ts (Angular 19 and earlier: app.component.ts): add inside the template's <nav>
<!-- Keep lines other Angular pages added here: the Sample project and Settings links
     (/build/angular-heatmaps); EngagementBridgeService, the FeedbackButton import and
     <app-feedback-button /> (/build/angular-guides-surveys); ServerFlagsService
     (/build/angular-feature-flags). -->
<a routerLink="/team">Team</a>
<a routerLink="/billing">Billing</a>
<a routerLink="/billing/invoices">Invoices</a>

To mask several sections that don't share a URL prefix, such as /billing and /account, give them one parent route with path: '' and a masking layout as its component, and list both sections under its children. A parent with an empty path doesn't add anything to the URL. The same layout works when the children come from loadChildren.

Why. <router-outlet> doesn't render routed components inside itself. Angular inserts each one as the outlet's next sibling, so <router-outlet ampMask /> masks an empty element and leaves the page visible. Wrapping the outlet in a <div ampMask> puts every routed page inside the masked element. 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 /billing/payment-methods next year as another child route gets masking without knowing this component exists. It also means data-amp-unmask does nothing under /billing, so put content you want visible outside the section. Angular Router keeps BillingLayout in place while someone moves between /billing and /billing/invoices, so the wrapper never disappears mid-session.

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 <div ampMask="block"> in the layout instead.

The layout doesn't cover the page title. Keep personal data out of route title values and Title.setTitle() calls under /billing; autocaptured page views send the title to Amplitude as an event property too.

To mask the title on every page, add data-amp-mask to the <title> in src/index.html: <title data-amp-mask>MyApp</title>.

MCP. Working in Cursor or Claude Code? Amplitude's MCP server includes session replay workflows, 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. Set up the MCP server.

5. Allow Amplitude in your CSP

The Angular CLI doesn't add a Content-Security-Policy header to your app. Skip this step unless your server, your host, or a <meta http-equiv="Content-Security-Policy"> tag in src/index.html sets one. If one does, add Amplitude's hosts to the connect-src directive you already have:

Content-Security-Policy (connect-src only; keep your other directives)
connect-src 'self' https://api2.amplitude.com https://api-sr.amplitude.com https://sr-client-cfg.amplitude.com;

For a strict CSP, retain the policy your app needs and allow the complete endpoint set for its region. US connect-src: https://api2.amplitude.com https://api-sr.amplitude.com https://sr-client-cfg.amplitude.com. EU connect-src: https://api.eu.amplitude.com https://api-sr.eu.amplitude.com https://sr-client-cfg.eu.amplitude.com, with serverZone set to EU at the top level of initAll options. This demo explicitly sets useWebWorker to false. If your existing app enables it, allow worker-src blob: too. The demo skips guides and surveys; if your app enables them, also apply their documented CSP requirements. A blocked remote-configuration request prevents replay capture.

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 pnpm start, open http://localhost:4200 in a fresh private window, click Team, type into the filter, click Billing, then Invoices, then check in this order:

  1. Console and Network tab. No Content Security Policy errors in the console. In the Network tab, filter for amplitude: you should see event uploads to api2.amplitude.com returning 200, a request to sr-client-cfg.amplitude.com, and replay uploads to api-sr.amplitude.com. With Angular SSR, none of them start until the page loads in the browser; the server render sends nothing.
  2. 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 the device ID shown by the Instrumentation Explorer extension. Trigger an interaction after initialization and inspect its event properties for [Amplitude] Session Replay ID. Events appear in ten seconds to one minute; replays are generally available about five minutes after a session begins. A local-only test does not establish ingestion or playback.

Session Replay debug panel. Install the Instrumentation Explorer Chrome extension and turn on its 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 under pnpm start confirms step 2 took effect, and the effective masking settings show whether Session Replay Settings overrode your code.

  1. The replay itself. Play it. On /team, the filter text is visible, emails are asterisks, and avatars are replaced by empty placeholders of the same size. The plugin's applyBackgroundColorToBlockedElements option shades those placeholders, but it's off by default, so expect a blank gap, not a gray box. On /billing and /billing/invoices, the billing content 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.

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.

Troubleshooting

No session replay requests at all

The effective sample rate is 0. Check sessionReplay.sampleRate, then the rate in Session Replay Settings; the debug panel shows the rate the SDK is using. A production build reads environment.ts, where production is true, and records only about one session in a hundred at 0.01, so test with pnpm start or raise the rate temporarily. If environment.ts lacks production: true, every build records every session instead.

Debug instrumentation during initial setup

For initial setup, debugMode: !environment.production from step 2 adds the [Amplitude] Session Replay Debug event property, which contains a hash of your API key to help diagnose instrumentation issues such as mismatched apps. Current SDKs record without a focus check, including while DevTools is open. Keep debugMode disabled in production.

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 a component, a service, provideAppInitializer, or app.config.ts, so the replay and the events disagree on device or session ID. Search the codebase and keep the one call in src/main.ts.

Text under /billing shows up in the replay

The masking attribute sits on <router-outlet> or an <ng-container>, which never wraps the routed page. Inspect the page in DevTools: the routed component should be inside the <div data-amp-mask="">. Wrap the outlet in an element as in step 4. Also check that the masking layout is the component of the billing route itself, not of some other parent route.

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 directive sits on an <ng-container> or <ng-template> and never reached the DOM; inspect the element. The text renders in a CDK overlay (a dialog, menu, snack bar, or tooltip) at the end of document.body, outside the masked parent. 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

pnpm build writes your global stylesheet to a hashed file name such as styles-5INURTSO.css, and a deploy that replaces the build output removes the old one. Keep previous build assets available on your host, allow app.amplitude.com (US) or app.eu.amplitude.com (EU) in your asset server's CORS policy, and add crossorigin="anonymous" to any external <link rel="stylesheet">.

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 in the analytics block for events, plus remoteConfig.serverUrl for remote configuration, as on the web analytics page.

No requests to Amplitude at all

The key is empty, so the warning from step 2 is in the console. pnpm start reads environment.development.ts and pnpm build reads environment.ts, so check the file your configuration uses. If neither exists, run pnpm exec ng generate environments and check fileReplacements in angular.json.

Full code reference

Every file needed for the standalone demo is included below. Split the block at its filename comments. For an existing app, merge changes into its files and keep any existing authentication, identity, routes and other Amplitude configuration.

21 complete files
// -- src/app/amplitude.ts --
// Keep lines other Angular pages added here: pageViews: { trackHistoryChanges: 'pathOnly' }
// (/build/angular-web-analytics); the setAmplitudeReady(...) wrapper, its import and the
// setAmplitudeReady(null) line (/build/angular-feature-flags, /build/angular-guides-surveys);
// the experiment block and any analytics.deviceId handoff (/build/angular-feature-flags);
// the WebExperimentGlobal type and the amplitude.add(webExperiment.plugin()) block
// (/build/angular-web-experiment).
import * as amplitude from '@amplitude/unified';
import { environment } from '../environments/environment';

export function initAmplitude(): void {
  if (!environment.amplitudeApiKey) {
    console.warn('amplitudeApiKey is not set; analytics is off.');
    return;
  }

  // This page doesn't wait for initAll. If setAmplitudeReady(...) wraps this call, keep it.
  void amplitude.initAll(environment.amplitudeApiKey, {
    // This tutorial does not load guides and surveys. Keep your existing engagement setup if you use it.
    engagement: { skip: true },
    analytics: {
      autocapture: {
        attribution: true,
        pageViews: true,
        // Tracks [Amplitude] Start Session and End Session events.
        sessions: true,
        formInteractions: true,
        fileDownloads: true,
        elementInteractions: true,
      },
    },
    sessionReplay: {
      useWebWorker: false, // Explicitly use main-thread processing for this demo.
      // Fraction of sessions to record: every session under ng serve, 1% in a production build.
      sampleRate: environment.production ? 0.01 : 1,
      // Adds [Amplitude] Session Replay Debug with an API key hash. Initial setup only.
      debugMode: !environment.production,
      // Selectors for the data attributes in steps 3 and 4.
      privacyConfig: {
        maskSelector: ['[data-amp-mask]'],
        blockSelector: ['[data-amp-block]'],
        unmaskSelector: ['[data-amp-unmask]'],
      },
    },
  });
}

// -- src/app/privacy/amp-mask.ts --
import { Directive, input } from '@angular/core';

// <span ampMask> masks text as asterisks.
// <img ampMask="block"> replaces the element with a same-size placeholder.
@Directive({
  selector: '[ampMask]',
  host: {
    '[attr.data-amp-mask]': "ampMask() === 'block' ? null : ''",
    '[attr.data-amp-block]': "ampMask() === 'block' ? '' : null",
  },
})
export class AmpMask {
  readonly ampMask = input<'' | 'block'>('');
}

// -- src/app/team/team.ts --
import { Component, signal } from '@angular/core';
import { AmpMask } from '../privacy/amp-mask';

interface Member {
  id: string;
  name: string;
  email: string;
  avatarUrl: string;
}

@Component({
  selector: 'app-team',
  imports: [AmpMask],
  template: `
    <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>
      @for (member of members(); track member.id) {
        <li class="row">
          <!-- Replaced by a same-size placeholder -->
          <img ampMask="block" [src]="member.avatarUrl" alt="" width="32" height="32" />
          <span>{{ member.name }}</span>
          <!-- Captured as asterisks -->
          <span ampMask>{{ member.email }}</span>
        </li>
      }
    </ul>
  `,
  styles: `
    .row {
      display: flex;
      gap: 0.5rem;
    }
  `,
})
export class Team {
  // Replace with data from your API.
  protected readonly members = signal<Member[]>([
    { id: '1', name: 'Test User', email: 'test@example.com', avatarUrl: '/avatar.svg' },
  ]);
}

// -- src/app/billing/billing-layout.ts --
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { AmpMask } from '../privacy/amp-mask';

@Component({
  selector: 'app-billing-layout',
  imports: [RouterOutlet, AmpMask],
  template: `
    <!-- Everything routed under /billing renders inside this div, including pages added later. -->
    <div ampMask>
      <router-outlet />
    </div>
  `,
})
export class BillingLayout {}

// -- src/app/billing/billing-home.ts --
import { Component } from '@angular/core';

@Component({
  selector: 'app-billing-home',
  template: `
    <h1>Billing</h1>
    <p>Card ending 4242, billed to Test User</p>
  `,
})
export class BillingHome {}

// -- src/app/billing/invoices.ts --
import { Component } from '@angular/core';

@Component({
  selector: 'app-invoices',
  template: `<h1>Invoices</h1>`,
})
export class Invoices {}

// -- src/app/app.routes.ts --
// Keep lines other Angular pages added here: their routes, such as projects/:projectId and
// settings (/build/angular-heatmaps) and pricing, welcome, login, account, plans and offer/:code
// (/build/angular-web-analytics), and the title on each route if you added titles there.
import { Routes } from '@angular/router';
import { Home } from './home';
import { NewProject } from './projects/new-project';
import { Team } from './team/team';
import { BillingLayout } from './billing/billing-layout';
import { BillingHome } from './billing/billing-home';
import { Invoices } from './billing/invoices';

export const routes: Routes = [
  { path: '', component: Home },
  { path: 'projects/new', component: NewProject },
  { path: 'team', component: Team },
  {
    path: 'billing',
    component: BillingLayout,
    children: [
      { path: '', component: BillingHome },
      { path: 'invoices', component: Invoices },
    ],
  },
];

// -- package.json --
{
  "name": "angular-session-replay",
  "private": true,
  "packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8",
  "scripts": {
    "start": "ng serve",
    "build": "ng build"
  },
  "dependencies": {
    "@amplitude/unified": "1.1.42",
    "@angular/common": "20.3.34",
    "@angular/compiler": "20.3.34",
    "@angular/core": "20.3.34",
    "@angular/platform-browser": "20.3.34",
    "@angular/router": "20.3.34",
    "rxjs": "7.8.2",
    "tslib": "2.8.1",
    "zone.js": "0.15.1"
  },
  "devDependencies": {
    "@angular/build": "20.3.40",
    "@angular/cli": "20.3.40",
    "@angular/compiler-cli": "20.3.34",
    "typescript": "5.9.3"
  }
}

// -- pnpm-workspace.yaml --
minimumReleaseAge: 10080
trustPolicy: no-downgrade

// -- angular.json --
{
  "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
  "version": 1,
  "projects": {
    "demo": {
      "projectType": "application",
      "root": "",
      "sourceRoot": "src",
      "architect": {
        "build": {
          "builder": "@angular/build:application",
          "options": {
            "browser": "src/main.ts",
            "tsConfig": "tsconfig.app.json",
            "index": "src/index.html",
            "polyfills": [
              "zone.js"
            ],
            "assets": [
              {
                "glob": "**/*",
                "input": "public"
              }
            ],
            "styles": []
          },
          "configurations": {
            "production": {
              "outputHashing": "all"
            },
            "development": {
              "optimization": false,
              "sourceMap": true,
              "fileReplacements": [
                {
                  "replace": "src/environments/environment.ts",
                  "with": "src/environments/environment.development.ts"
                }
              ]
            }
          },
          "defaultConfiguration": "production"
        },
        "serve": {
          "builder": "@angular/build:dev-server",
          "configurations": {
            "production": {
              "buildTarget": "demo:build:production"
            },
            "development": {
              "buildTarget": "demo:build:development"
            }
          },
          "defaultConfiguration": "development"
        }
      }
    }
  }
}

// -- tsconfig.json --
{
  "compileOnSave": false,
  "compilerOptions": {
    "strict": true,
    "skipLibCheck": true,
    "experimentalDecorators": true,
    "importHelpers": true,
    "target": "ES2022",
    "module": "preserve",
    "moduleResolution": "bundler",
    "lib": [
      "ES2022",
      "dom"
    ],
    "useDefineForClassFields": false
  },
  "angularCompilerOptions": {
    "strictInjectionParameters": true,
    "strictInputAccessModifiers": true,
    "strictTemplates": true
  }
}

// -- tsconfig.app.json --
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./out-tsc/app",
    "types": []
  },
  "files": [
    "src/main.ts"
  ],
  "include": [
    "src/**/*.d.ts"
  ]
}

// -- src/index.html --
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Local demo</title><base href="/"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><app-root></app-root></body></html>

// -- src/environments/environment.ts --
export const environment = { production: true, amplitudeApiKey: 'your-project-api-key' };

// -- src/environments/environment.development.ts --
export const environment = { production: false, amplitudeApiKey: 'your-project-api-key' };

// -- src/main.ts --
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { appConfig } from './app/app.config';
import { initAmplitude } from './app/amplitude';

// Run before Angular creates its zone. Do not add this to main.server.ts.
initAmplitude();
bootstrapApplication(App, appConfig).catch(console.error);

// -- src/app/app.config.ts --
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = { providers: [provideZoneChangeDetection(), provideRouter(routes)] };

// -- src/app/app.ts --
import { Component } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router';
@Component({
  selector: 'app-root',
  imports: [RouterLink, RouterOutlet],
  template: `<nav><a routerLink="/">Home</a> <a routerLink="/team">Team</a> <a routerLink="/billing">Billing</a> <a routerLink="/billing/invoices">Invoices</a></nav><router-outlet />`
})
export class App {}

// -- src/app/home.ts --
import { Component } from '@angular/core';
@Component({ selector: 'app-home', template: '<h1>Local demo</h1><p>Use the navigation to exercise the tutorial.</p>' })
export class Home {}

// -- src/app/projects/new-project.ts --
import { Component } from '@angular/core';
@Component({ selector: 'app-new-project', template: '<h1>New project</h1>' })
export class NewProject {}

// -- public/avatar.svg --
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><rect width="32" height="32" fill="#0052F2"/><circle cx="16" cy="12" r="6" fill="white"/><path d="M5 31c0-14 22-14 22 0" fill="white"/></svg>

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.

Using Cursor or Claude Code? Paste this prompt
Follow https://amplitude.com/build/angular-session-replay using only the complete setup and Full code reference on this page. Do not require another tutorial. Split the reference at filename comments and create every required file. For an existing app, merge additions and keep authentication, identity, routes and other instrumentation. Use the existing root component/layout file. Initialize Amplitude once in src/main.ts before bootstrapApplication via initAmplitude(). Keep the empty-key guard and the existing ready-promise wrapper if present. Use development sample rate 1 and production 0.01, with debugMode enabled only in development. This option adds diagnostic information; current recording has no focus gate. Keep the masking selectors and do not place personal data in HTML attributes. Use the page's demo routes to exercise the behavior. Keep engagement.skip true for this demo, or retain the existing engagement configuration and its required CSP if the app uses guides and surveys. Check the full endpoint set for the selected region. Build the actual app and run its Verify steps; report build, local browser behavior and live service results separately. Do not claim ingestion or playback from a mocked test.

Frequently asked questions

Do I need to finish the product analytics tutorial first?

No. This page includes the setup and complete demo files. In an existing app, merge the additions into the SDK initialization you already have.

Does debugMode bypass a focus check?

Current SDK recording has no focus gate. debugMode adds a diagnostic event property containing an API key hash. Use it for initial setup and keep it off in production.

What can I verify without an Amplitude project?

You can check builds, navigation, DOM attributes and local browser behavior. Successful ingestion, replay playback and processed heatmaps need a configured project and separate verification.

What this unlocks

  • Session replay sits on the same events as your charts: open a funnel step that ends at /billing and 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 /team aggregate 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 users whose replays show a stall on /billing what they were looking for.

Next steps

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.

Ship the instrumentation, then read the data

Once events are flowing, Amplitude turns them into funnels, retention curves, and session replays without any further setup.