From 1a360765afe6e743b7398c141da02f075d9f4930 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Wed, 13 May 2026 09:03:54 +0530 Subject: [PATCH] =?UTF-8?q?obs:=20NR=20browser=20agent=20=E2=86=92=20pro?= =?UTF-8?q?=5Fplus=5Fspa=20mode=20+=20route-level=20page=20views=20+=20cus?= =?UTF-8?q?tom=20attrs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on PR #37 (JS error reports) so the dashboard's NR browser agent emits the full APM surface, not just errors. Changes: 1. src/lib/newrelic-config.ts — extract the BrowserAgent init options into a pure function buildBrowserAgentOptions() so a unit test can pin the pro_plus_spa feature set. Tagged with NR_BROWSER_MODE = 'pro_plus_spa'. Explicit flags for soft_navigations, page_view_event, page_view_timing, metrics, jserrors, ajax, distributed_tracing — all defaults of the BrowserAgent loader, but stating them in source means a regression to lite shows up in review here, not in a grafana panel that suddenly goes flat. 2. src/main.tsx — now imports buildBrowserAgentOptions and passes the shaped options to `new BrowserAgent(...)`. Docstring lists what each enabled feature buys us (LCP/FID/CLS via page_view_timing, AJAX waterfalls via ajax, SPA pushState events via soft_navigations). 3. src/components/RouteTracker.tsx — null-rendering component that sits inside . On every useLocation change it calls newrelic.setPageViewName(pathname) + setCustomAttribute(...) for tier (from ctx.me.team.tier), is_admin (ctx.me.is_platform_admin), and commit_id (VITE_COMMIT_ID). Falls back to "anonymous"/false when ctx.me hasn't resolved. Fail-open when window.newrelic is absent — telemetry must never crash the router. 4. src/App.tsx — mounts just inside and outside any Suspense boundary so a lazy-chunk fetch never unmounts it mid-navigation. Tests: - 7 RouteTracker.test.tsx cases (initial fire, route-change fire, tier/admin/commit_id stamping, anonymous fallback, missing-agent fail-open, upgrade-webhook tier flip, null render) - 8 newrelic-config.test.ts cases (mode tag + every feature flag) - ErrorBoundary.test.tsx (PR #37) still passes — no regression Run: vitest run → 397 passed | 3 skipped (was 382 | 3 before) Bundle size delta (npm run build): - main index.js: 632.33 kB → 634.40 kB (+2.07 kB raw) - gzipped: 174.21 kB → 174.76 kB (+0.55 kB) The agent itself was already in the bundle from PR #37 (full BrowserAgent loader, not lite). The +2 kB is RouteTracker + config builder; no loader swap. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/App.tsx | 12 ++ src/components/RouteTracker.test.tsx | 194 +++++++++++++++++++++++++++ src/components/RouteTracker.tsx | 91 +++++++++++++ src/lib/newrelic-config.test.ts | 74 ++++++++++ src/lib/newrelic-config.ts | 86 ++++++++++++ src/main.tsx | 65 +++++---- 6 files changed, 499 insertions(+), 23 deletions(-) create mode 100644 src/components/RouteTracker.test.tsx create mode 100644 src/components/RouteTracker.tsx create mode 100644 src/lib/newrelic-config.test.ts create mode 100644 src/lib/newrelic-config.ts diff --git a/src/App.tsx b/src/App.tsx index 6ea22ac..3e26c96 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,13 @@ import { lazy, Suspense } from 'react' import { BrowserRouter, Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom' +// RouteTracker — eagerly imported. Tiny component (~1KB) that watches +// useLocation and forwards every change to the New Relic browser agent +// via setPageViewName + setCustomAttribute. Has to live INSIDE +// (useLocation needs router context) so we mount it as a +// sibling of . Renders null — no markup contribution. +import { RouteTracker } from './components/RouteTracker' + // Homepage — eagerly imported. It's the cold-load path and the most-visited // public surface, so it stays in the main entry chunk. import { MarketingPage } from './pages/MarketingPage' @@ -246,6 +253,11 @@ export function AppRoutes() { export function App() { return ( + {/* RouteTracker must sit inside the router so its useLocation() has a + context, and outside any Suspense boundary so it never unmounts + during a lazy-chunk fetch (an unmount would skip the + setPageViewName for that navigation). */} + ) diff --git a/src/components/RouteTracker.test.tsx b/src/components/RouteTracker.test.tsx new file mode 100644 index 0000000..3a2d1af --- /dev/null +++ b/src/components/RouteTracker.test.tsx @@ -0,0 +1,194 @@ +/* RouteTracker.test.tsx — unit tests for the New Relic page-view tracker. + * + * Covers: + * 1. Calls setPageViewName(pathname) on initial mount. + * 2. Calls setCustomAttribute for tier, is_admin, commit_id on mount. + * 3. Re-fires setPageViewName + attributes when the route changes + * (this is the SPA-soft-nav case the agent's pro_plus_spa mode is + * designed to capture). + * 4. Does NOT crash when window.newrelic is absent (fail-open). + * 5. Falls back to "anonymous" / false when ctx.me is null + * (pre-auth marketing browse). + * 6. Reflects tier upgrades — re-stamps the new tier when ctx.me.team.tier + * changes (the upgrade-webhook path). + * + * useDashboardCtx is mocked module-level so we can vary `me` per test + * without touching the real subscription store. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render } from '@testing-library/react' +import { MemoryRouter, Routes, Route, useNavigate } from 'react-router-dom' +import { useEffect } from 'react' + +// ─── Mock useDashboardCtx ──────────────────────────────────────────────── +// Mutated per-test to flip tier / admin flag / null-me. +let mockMe: { + user?: { id: string; email: string } + team?: { tier: string } + is_platform_admin?: boolean +} | null = null + +vi.mock('../hooks/useDashboardCtx', () => ({ + useDashboardCtx: () => ({ + me: mockMe, + meErr: null, + meLoading: false, + env: 'production', + envs: ['production'], + counts: { resources: 0, deployments: 0, vault: 0, team: 1 }, + resources: [], + billing: null, + billingLoading: false, + }), +})) + +// Imported after the mock so the module under test resolves the stubbed hook. +import { RouteTracker } from './RouteTracker' + +type NRStub = { + setPageViewName: ReturnType + setCustomAttribute: ReturnType +} + +function installNewrelicStub(): NRStub { + const stub: NRStub = { + setPageViewName: vi.fn(), + setCustomAttribute: vi.fn(), + } + ;(window as unknown as { newrelic: NRStub }).newrelic = stub + return stub +} + +describe('RouteTracker', () => { + let originalNewrelic: unknown + + beforeEach(() => { + originalNewrelic = (window as unknown as { newrelic?: unknown }).newrelic + mockMe = null + }) + + afterEach(() => { + if (originalNewrelic === undefined) { + delete (window as unknown as { newrelic?: unknown }).newrelic + } else { + ;(window as unknown as { newrelic?: unknown }).newrelic = originalNewrelic + } + }) + + it('calls setPageViewName with the initial pathname', () => { + const nr = installNewrelicStub() + render( + + + , + ) + expect(nr.setPageViewName).toHaveBeenCalledWith('/app/resources') + }) + + it('stamps tier / is_admin / commit_id custom attributes', () => { + mockMe = { + user: { id: 'u1', email: 'a@b.test' }, + team: { tier: 'pro' }, + is_platform_admin: true, + } + const nr = installNewrelicStub() + render( + + + , + ) + // The three custom attributes we promise to stamp on every page view. + expect(nr.setCustomAttribute).toHaveBeenCalledWith('tier', 'pro') + expect(nr.setCustomAttribute).toHaveBeenCalledWith('is_admin', true) + // commit_id is sourced from VITE_COMMIT_ID; in test it's "dev". + const commitCalls = nr.setCustomAttribute.mock.calls.filter((c) => c[0] === 'commit_id') + expect(commitCalls.length).toBeGreaterThanOrEqual(1) + expect(typeof commitCalls[0][1]).toBe('string') + expect((commitCalls[0][1] as string).length).toBeGreaterThan(0) + }) + + it('falls back to anonymous tier and is_admin=false when ctx.me is null', () => { + mockMe = null // unauthenticated + const nr = installNewrelicStub() + render( + + + , + ) + expect(nr.setCustomAttribute).toHaveBeenCalledWith('tier', 'anonymous') + expect(nr.setCustomAttribute).toHaveBeenCalledWith('is_admin', false) + }) + + it('re-fires setPageViewName when the route changes (SPA soft nav)', () => { + const nr = installNewrelicStub() + + // Helper inside the router that triggers navigation post-mount. + function Nav() { + const navigate = useNavigate() + useEffect(() => { + navigate('/app/billing') + }, [navigate]) + return null + } + + render( + + + + } /> + billing} /> + + , + ) + + // First mount: /app/resources. Then the Nav effect pushes /app/billing. + // setPageViewName must have been called for BOTH pathnames. + const names = nr.setPageViewName.mock.calls.map((c) => c[0]) + expect(names).toContain('/app/resources') + expect(names).toContain('/app/billing') + }) + + it('does not crash when window.newrelic is absent (fail-open)', () => { + delete (window as unknown as { newrelic?: unknown }).newrelic + expect(() => + render( + + + , + ), + ).not.toThrow() + }) + + it('stamps the new tier when team.tier changes (upgrade webhook path)', () => { + // First render: hobby + mockMe = { user: { id: 'u', email: 'x@y' }, team: { tier: 'hobby' } } + const nr = installNewrelicStub() + const { rerender } = render( + + + , + ) + expect(nr.setCustomAttribute).toHaveBeenCalledWith('tier', 'hobby') + + // Simulate the upgrade webhook flipping the ctx state. + nr.setCustomAttribute.mockClear() + mockMe = { user: { id: 'u', email: 'x@y' }, team: { tier: 'pro' } } + rerender( + + + , + ) + expect(nr.setCustomAttribute).toHaveBeenCalledWith('tier', 'pro') + }) + + it('renders no markup (null)', () => { + const { container } = render( + + + , + ) + // The component returns null; nothing should be appended. + expect(container.firstChild).toBeNull() + }) +}) diff --git a/src/components/RouteTracker.tsx b/src/components/RouteTracker.tsx new file mode 100644 index 0000000..72523f4 --- /dev/null +++ b/src/components/RouteTracker.tsx @@ -0,0 +1,91 @@ +/* RouteTracker.tsx — wires React Router location changes into the New + * Relic browser agent. + * + * What it does on every route change: + * 1. Calls `newrelic.setPageViewName(pathname)` so NR's page-view UI + * shows route-level granularity (`/app/admin/customers` vs + * `/app/resources`) instead of every event collapsing under the SPA + * shell URL. Without this, soft-nav events carry the previous full + * URL (or a generic "/") and the page-load funnel is unreadable. + * 2. Calls `newrelic.setCustomAttribute(...)` for three dimensions: + * - tier: current paid tier (anonymous|hobby|pro|growth|team) + * - is_admin: whether the signed-in user is a platform admin + * - commit_id: the dashboard build SHA (already stamped by + * main.tsx, but we re-set it here so a single + * PageView/SoftNav event always carries it even if + * the agent's global-attr cache was cleared) + * + * Why a component vs. an `init` hook: + * `useLocation()` requires a Router context. The simplest correct shape + * is a component mounted *inside* that watches + * location via a useEffect dependency. App.tsx renders this just below + * the router's opening tag — see App.tsx for the placement. + * + * Fail-open: + * When `window.newrelic` is absent (no license key, ad-blocker, agent + * boot failed), every call here is a no-op. Telemetry must never break + * the app. + * + * Tier + admin sourcing: + * We read the live `me` from useDashboardCtx — not from props — so the + * tracker rerenders when the user signs in or upgrades. Before /auth/me + * resolves, ctx.me is null and we stamp "anonymous"/false for tier and + * is_admin (matches the unauthenticated marketing-shell case). + */ + +import { useEffect } from 'react' +import { useLocation } from 'react-router-dom' +import { useDashboardCtx } from '../hooks/useDashboardCtx' + +// NR API surface — typed loosely because the agent attaches at runtime +// and may be absent (no license key, ad-blocker, agent boot failed). +type NRWindow = Window & { + newrelic?: { + setPageViewName?: (name: string, host?: string) => void + setCustomAttribute?: (key: string, value: string | number | boolean | null) => void + } +} + +// Defaults used when /auth/me hasn't resolved yet (anonymous browse of +// marketing pages) — match the API's anonymous-tier semantics. We don't +// fabricate a different shape just because the page is public. +const TIER_FALLBACK = 'anonymous' +const COMMIT_ID_FALLBACK = 'dev' + +export function RouteTracker(): null { + const location = useLocation() + const ctx = useDashboardCtx() + + useEffect(() => { + // The agent might not have booted yet on the first render (the npm + // BrowserAgent constructor schedules its bootstrap async). Re-check + // each effect run; once it's there, every subsequent location change + // gets stamped. + const nr = (window as NRWindow).newrelic + if (!nr) return + + const pathname = location.pathname || '/' + const tier = ctx.me?.team?.tier ?? TIER_FALLBACK + const isAdmin = Boolean(ctx.me?.is_platform_admin) + const commitId = import.meta.env.VITE_COMMIT_ID || COMMIT_ID_FALLBACK + + try { + // setPageViewName: the second arg ("host") is optional; NR fills it + // from window.location.host when omitted. Skip it so a custom + // domain (instanode.dev vs preview-*.netlify.app) doesn't drift the + // grouping. + nr.setPageViewName?.(pathname) + nr.setCustomAttribute?.('tier', tier) + nr.setCustomAttribute?.('is_admin', isAdmin) + nr.setCustomAttribute?.('commit_id', commitId) + } catch { + // Best-effort. A NR API throw must not crash the router. + } + // Re-run on path change OR when the user/tier/admin flag changes (sign + // in, upgrade webhook lands, admin promote). location.search / + // location.hash deliberately excluded — query-string churn on the same + // page shouldn't double-count as a new page view. + }, [location.pathname, ctx.me?.team?.tier, ctx.me?.is_platform_admin]) + + return null +} diff --git a/src/lib/newrelic-config.test.ts b/src/lib/newrelic-config.test.ts new file mode 100644 index 0000000..93a79f6 --- /dev/null +++ b/src/lib/newrelic-config.test.ts @@ -0,0 +1,74 @@ +/* newrelic-config.test.ts — assert the options we pass to + * `new BrowserAgent({...})` correspond to the pro_plus_spa mode. + * + * Why this test exists: + * The dashboard is an SPA. If a future refactor accidentally drops to + * the lite loader (jserrors-only), NR's Page Views dashboard goes + * dark — no page loads, no AJAX waterfalls, no web vitals. This test + * pins every feature flag that distinguishes pro_plus_spa from lite, + * so a regression shows up in CI instead of in a stale grafana panel. + */ + +import { describe, it, expect } from 'vitest' +import { buildBrowserAgentOptions, NR_BROWSER_MODE } from './newrelic-config' + +describe('newrelic-config: pro_plus_spa mode', () => { + const opts = buildBrowserAgentOptions({ + licenseKey: 'NRBR-test-license', + applicationID: '1234567', + }) + + it('mode tag is pro_plus_spa', () => { + // Surfaced as a constant so docs / changelogs / debug overlays can + // reference one place instead of greping for feature combos. + expect(NR_BROWSER_MODE).toBe('pro_plus_spa') + }) + + it('soft_navigations is enabled (SPA route changes)', () => { + // This is THE flag that makes the agent "pro_plus_spa" vs plain "pro". + // Without it, React Router pushState navigations never show up in NR. + expect(opts.init.soft_navigations).toEqual({ enabled: true, autoStart: true }) + }) + + it('page_view_event is enabled (classic page loads)', () => { + expect(opts.init.page_view_event).toEqual({ enabled: true, autoStart: true }) + }) + + it('page_view_timing is enabled (LCP / FID / CLS / FCP / TTFB)', () => { + // The agent emits PageViewTiming events for each web vital + // automatically when this feature is on. Required for NR's Core + // Web Vitals UI. + expect(opts.init.page_view_timing).toEqual({ enabled: true, autoStart: true }) + }) + + it('ajax instrumentation is on with the beacon denylisted', () => { + // Without deny_list, every NR beacon POST appears in the AJAX + // waterfall (recursive noise). The agent's own host is excluded. + expect(opts.init.ajax.deny_list).toContain('bam.nr-data.net') + }) + + it('metrics + jserrors are enabled', () => { + expect(opts.init.metrics).toEqual({ enabled: true, autoStart: true }) + expect(opts.init.jserrors).toEqual({ enabled: true, autoStart: true }) + }) + + it('distributed_tracing is enabled (correlates browser → api spans)', () => { + // The Go agent on instant-api creates spans for every Fiber handler; + // this flag tells the browser agent to inject the W3C traceparent + // header on outgoing fetches so the two halves stitch in NR. + expect(opts.init.distributed_tracing).toEqual({ enabled: true }) + }) + + it('keys and IDs propagate through info + loader_config', () => { + expect(opts.info.licenseKey).toBe('NRBR-test-license') + expect(opts.info.applicationID).toBe('1234567') + expect(opts.loader_config.licenseKey).toBe('NRBR-test-license') + expect(opts.loader_config.applicationID).toBe('1234567') + // accountID + trustKey + agentID currently mirror the appID for a + // single-account install. If we ever move to a sub-account model, + // this assertion needs to relax. + expect(opts.loader_config.accountID).toBe('1234567') + expect(opts.loader_config.trustKey).toBe('1234567') + expect(opts.loader_config.agentID).toBe('1234567') + }) +}) diff --git a/src/lib/newrelic-config.ts b/src/lib/newrelic-config.ts new file mode 100644 index 0000000..776514e --- /dev/null +++ b/src/lib/newrelic-config.ts @@ -0,0 +1,86 @@ +/* newrelic-config.ts — pure, testable construction of the + * `@newrelic/browser-agent` init options. + * + * Extracted from main.tsx so a unit test can assert the shape we pass to + * `new BrowserAgent({...})` without standing up the agent itself + * (constructing the real agent hits the network and installs window + * listeners — neither is safe in a vitest run). + * + * Mode: pro_plus_spa + * - `soft_navigations` enabled → React Router route changes show up as + * SoftNavigation events on the Page Views UI + * - `page_view_event` enabled → classic full-page-load PageView event + * - `page_view_timing` enabled → LCP / FID / CLS / FCP / TTFB as + * PageViewTiming events + * - `ajax` instruments fetch + XHR (waterfalls, AJAX errors) + * - `metrics` enabled (default) for the agent's internal supportability + * + * Each `enabled: true` here matches the upstream default in + * node_modules/@newrelic/browser-agent/src/common/config/init.js. We + * state them anyway to lock the mode into source and to make it obvious + * in code review when one of them flips off. + */ + +export interface BrowserAgentOptions { + info: { + beacon: string + errorBeacon: string + licenseKey: string + applicationID: string + sa: number + } + loader_config: { + accountID: string + trustKey: string + agentID: string + licenseKey: string + applicationID: string + } + init: { + distributed_tracing: { enabled: boolean } + privacy: { cookies_enabled: boolean } + ajax: { deny_list: string[] } + soft_navigations: { enabled: boolean; autoStart: boolean } + page_view_event: { enabled: boolean; autoStart: boolean } + page_view_timing: { enabled: boolean; autoStart: boolean } + metrics: { enabled: boolean; autoStart: boolean } + jserrors: { enabled: boolean; autoStart: boolean } + } +} + +/** Tag identifying the configured mode. Read by tests to assert we're + * shipping the full SPA+APM feature set, not the lite/errors-only loader. */ +export const NR_BROWSER_MODE = 'pro_plus_spa' as const + +export function buildBrowserAgentOptions(args: { + licenseKey: string + applicationID: string +}): BrowserAgentOptions { + const { licenseKey, applicationID } = args + return { + info: { + beacon: 'bam.nr-data.net', + errorBeacon: 'bam.nr-data.net', + licenseKey, + applicationID, + sa: 1, + }, + loader_config: { + accountID: applicationID, + trustKey: applicationID, + agentID: applicationID, + licenseKey, + applicationID, + }, + init: { + distributed_tracing: { enabled: true }, + privacy: { cookies_enabled: true }, + ajax: { deny_list: ['bam.nr-data.net'] }, + soft_navigations: { enabled: true, autoStart: true }, + page_view_event: { enabled: true, autoStart: true }, + page_view_timing: { enabled: true, autoStart: true }, + metrics: { enabled: true, autoStart: true }, + jserrors: { enabled: true, autoStart: true }, + }, + } +} diff --git a/src/main.tsx b/src/main.tsx index ae0f3bb..1717fa7 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -8,6 +8,30 @@ * 2. Then mount React, wrapped in so render-time crashes * surface as NR `noticeError` calls instead of a blank screen. * + * Mode: pro_plus_spa + * ────────────────── + * The `@newrelic/browser-agent` npm package exposes three loader shapes: + * + * - `loaders/browser-agent-lite` → jserrors only + * - `loaders/browser-agent` → jserrors + page_view_event + + * page_view_timing + ajax + metrics + * + session_trace + generic_events + + * logging + soft_navigations + * (this is the full "pro_plus_spa") + * - `loaders/browser-agent-no-replay`→ same as above minus session_replay + * + * The dashboard IS an SPA (React Router, route-level lazy chunks under + * /app/*), so the right shape is the full BrowserAgent loader. It already + * pulls in `soft_navigations` (SPA route-change instrumentation) and + * `page_view_timing` (LCP / FID / CLS / FCP / TTFB web vitals) which the + * lite loader does NOT include. See: + * node_modules/@newrelic/browser-agent/src/loaders/browser-agent.js + * + * We pass an explicit `init.soft_navigations.enabled = true` to make the + * intent unambiguous in this file (the default IS true, but tying the + * mode to a config flag means a future refactor that disables features + * surface in code review here, not in NR's dashboard going dark). + * * Fail-open: when VITE_NEWRELIC_LICENSE_KEY is empty (local dev, PR * previews, anyone running their own fork) we skip init entirely. This * mirrors how the Go services treat an empty NEW_RELIC_LICENSE_KEY — no @@ -18,15 +42,27 @@ import ReactDOM from 'react-dom/client' import { BrowserAgent } from '@newrelic/browser-agent/loaders/browser-agent' import { App } from './App' import { ErrorBoundary } from './components/ErrorBoundary' +import { buildBrowserAgentOptions } from './lib/newrelic-config' import './styles/tokens.css' // initNewRelic — boot the browser agent when both keys are present. The // agent attaches itself to `window.newrelic` so ErrorBoundary.componentDidCatch -// can call `noticeError` later without holding a reference here. +// and RouteTracker can call `noticeError`/`setPageViewName`/`setCustomAttribute` +// later without holding a reference here. // // We pass VITE_COMMIT_ID as a global custom attribute via the init config so // every JS error, AJAX failure, page action, and SPA route change collected // by the agent is automatically stamped with the dashboard's build SHA. +// +// Feature flags below are explicit (vs. relying on defaults) so the mode is +// readable from this file alone. All of these default to `true` in the +// upstream agent, but we set them anyway to lock the contract: +// - soft_navigations: SPA route-change events (the "spa" in pro_plus_spa) +// - page_view_event: classic full-page-load PageView event +// - page_view_timing: LCP / FID / CLS / FCP / TTFB → transmitted as +// PageViewTiming events; viewable on NR's Page Views UI +// - ajax: fetch/XHR waterfalls (AjaxRequest events) +// - distributed_tracing: cross-origin trace propagation to the Go API function initNewRelic(): void { const licenseKey = import.meta.env.VITE_NEWRELIC_LICENSE_KEY const applicationID = import.meta.env.VITE_NEWRELIC_APP_ID @@ -36,28 +72,11 @@ function initNewRelic(): void { return } try { - new BrowserAgent({ - info: { - beacon: 'bam.nr-data.net', - errorBeacon: 'bam.nr-data.net', - licenseKey, - applicationID, - sa: 1, - }, - // loader_config mirrors `info` for the bootstrap fetch — same account. - loader_config: { - accountID: applicationID, - trustKey: applicationID, - agentID: applicationID, - licenseKey, - applicationID, - }, - init: { - distributed_tracing: { enabled: true }, - privacy: { cookies_enabled: true }, - ajax: { deny_list: ['bam.nr-data.net'] }, - }, - }) + // The actual options live in src/lib/newrelic-config.ts so a unit test + // can assert the pro_plus_spa shape (page_view_event, page_view_timing, + // soft_navigations, ajax, metrics all on) without instantiating the + // real agent (which hits the network + installs window listeners). + new BrowserAgent(buildBrowserAgentOptions({ licenseKey, applicationID })) // Stamp every event with the build SHA. The agent exposes setCustomAttribute // on window.newrelic once it finishes booting; do it best-effort. const nr = (window as Window & { newrelic?: { setCustomAttribute?: (k: string, v: string) => void } }).newrelic