Skip to content

Commit 1a36076

Browse files
obs: NR browser agent → pro_plus_spa mode + route-level page views + custom attrs
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 <BrowserRouter>. 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 <RouteTracker /> just inside <BrowserRouter> 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) <noreply@anthropic.com>
1 parent 59e1d1f commit 1a36076

6 files changed

Lines changed: 499 additions & 23 deletions

File tree

src/App.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { lazy, Suspense } from 'react'
22
import { BrowserRouter, Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom'
33

4+
// RouteTracker — eagerly imported. Tiny component (~1KB) that watches
5+
// useLocation and forwards every change to the New Relic browser agent
6+
// via setPageViewName + setCustomAttribute. Has to live INSIDE
7+
// <BrowserRouter> (useLocation needs router context) so we mount it as a
8+
// sibling of <AppRoutes>. Renders null — no markup contribution.
9+
import { RouteTracker } from './components/RouteTracker'
10+
411
// Homepage — eagerly imported. It's the cold-load path and the most-visited
512
// public surface, so it stays in the main entry chunk.
613
import { MarketingPage } from './pages/MarketingPage'
@@ -246,6 +253,11 @@ export function AppRoutes() {
246253
export function App() {
247254
return (
248255
<BrowserRouter>
256+
{/* RouteTracker must sit inside the router so its useLocation() has a
257+
context, and outside any Suspense boundary so it never unmounts
258+
during a lazy-chunk fetch (an unmount would skip the
259+
setPageViewName for that navigation). */}
260+
<RouteTracker />
249261
<AppRoutes />
250262
</BrowserRouter>
251263
)
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
/* RouteTracker.test.tsx — unit tests for the New Relic page-view tracker.
2+
*
3+
* Covers:
4+
* 1. Calls setPageViewName(pathname) on initial mount.
5+
* 2. Calls setCustomAttribute for tier, is_admin, commit_id on mount.
6+
* 3. Re-fires setPageViewName + attributes when the route changes
7+
* (this is the SPA-soft-nav case the agent's pro_plus_spa mode is
8+
* designed to capture).
9+
* 4. Does NOT crash when window.newrelic is absent (fail-open).
10+
* 5. Falls back to "anonymous" / false when ctx.me is null
11+
* (pre-auth marketing browse).
12+
* 6. Reflects tier upgrades — re-stamps the new tier when ctx.me.team.tier
13+
* changes (the upgrade-webhook path).
14+
*
15+
* useDashboardCtx is mocked module-level so we can vary `me` per test
16+
* without touching the real subscription store.
17+
*/
18+
19+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
20+
import { render } from '@testing-library/react'
21+
import { MemoryRouter, Routes, Route, useNavigate } from 'react-router-dom'
22+
import { useEffect } from 'react'
23+
24+
// ─── Mock useDashboardCtx ────────────────────────────────────────────────
25+
// Mutated per-test to flip tier / admin flag / null-me.
26+
let mockMe: {
27+
user?: { id: string; email: string }
28+
team?: { tier: string }
29+
is_platform_admin?: boolean
30+
} | null = null
31+
32+
vi.mock('../hooks/useDashboardCtx', () => ({
33+
useDashboardCtx: () => ({
34+
me: mockMe,
35+
meErr: null,
36+
meLoading: false,
37+
env: 'production',
38+
envs: ['production'],
39+
counts: { resources: 0, deployments: 0, vault: 0, team: 1 },
40+
resources: [],
41+
billing: null,
42+
billingLoading: false,
43+
}),
44+
}))
45+
46+
// Imported after the mock so the module under test resolves the stubbed hook.
47+
import { RouteTracker } from './RouteTracker'
48+
49+
type NRStub = {
50+
setPageViewName: ReturnType<typeof vi.fn>
51+
setCustomAttribute: ReturnType<typeof vi.fn>
52+
}
53+
54+
function installNewrelicStub(): NRStub {
55+
const stub: NRStub = {
56+
setPageViewName: vi.fn(),
57+
setCustomAttribute: vi.fn(),
58+
}
59+
;(window as unknown as { newrelic: NRStub }).newrelic = stub
60+
return stub
61+
}
62+
63+
describe('RouteTracker', () => {
64+
let originalNewrelic: unknown
65+
66+
beforeEach(() => {
67+
originalNewrelic = (window as unknown as { newrelic?: unknown }).newrelic
68+
mockMe = null
69+
})
70+
71+
afterEach(() => {
72+
if (originalNewrelic === undefined) {
73+
delete (window as unknown as { newrelic?: unknown }).newrelic
74+
} else {
75+
;(window as unknown as { newrelic?: unknown }).newrelic = originalNewrelic
76+
}
77+
})
78+
79+
it('calls setPageViewName with the initial pathname', () => {
80+
const nr = installNewrelicStub()
81+
render(
82+
<MemoryRouter initialEntries={['/app/resources']}>
83+
<RouteTracker />
84+
</MemoryRouter>,
85+
)
86+
expect(nr.setPageViewName).toHaveBeenCalledWith('/app/resources')
87+
})
88+
89+
it('stamps tier / is_admin / commit_id custom attributes', () => {
90+
mockMe = {
91+
user: { id: 'u1', email: 'a@b.test' },
92+
team: { tier: 'pro' },
93+
is_platform_admin: true,
94+
}
95+
const nr = installNewrelicStub()
96+
render(
97+
<MemoryRouter initialEntries={['/app']}>
98+
<RouteTracker />
99+
</MemoryRouter>,
100+
)
101+
// The three custom attributes we promise to stamp on every page view.
102+
expect(nr.setCustomAttribute).toHaveBeenCalledWith('tier', 'pro')
103+
expect(nr.setCustomAttribute).toHaveBeenCalledWith('is_admin', true)
104+
// commit_id is sourced from VITE_COMMIT_ID; in test it's "dev".
105+
const commitCalls = nr.setCustomAttribute.mock.calls.filter((c) => c[0] === 'commit_id')
106+
expect(commitCalls.length).toBeGreaterThanOrEqual(1)
107+
expect(typeof commitCalls[0][1]).toBe('string')
108+
expect((commitCalls[0][1] as string).length).toBeGreaterThan(0)
109+
})
110+
111+
it('falls back to anonymous tier and is_admin=false when ctx.me is null', () => {
112+
mockMe = null // unauthenticated
113+
const nr = installNewrelicStub()
114+
render(
115+
<MemoryRouter initialEntries={['/pricing']}>
116+
<RouteTracker />
117+
</MemoryRouter>,
118+
)
119+
expect(nr.setCustomAttribute).toHaveBeenCalledWith('tier', 'anonymous')
120+
expect(nr.setCustomAttribute).toHaveBeenCalledWith('is_admin', false)
121+
})
122+
123+
it('re-fires setPageViewName when the route changes (SPA soft nav)', () => {
124+
const nr = installNewrelicStub()
125+
126+
// Helper inside the router that triggers navigation post-mount.
127+
function Nav() {
128+
const navigate = useNavigate()
129+
useEffect(() => {
130+
navigate('/app/billing')
131+
}, [navigate])
132+
return null
133+
}
134+
135+
render(
136+
<MemoryRouter initialEntries={['/app/resources']}>
137+
<RouteTracker />
138+
<Routes>
139+
<Route path="/app/resources" element={<Nav />} />
140+
<Route path="/app/billing" element={<div>billing</div>} />
141+
</Routes>
142+
</MemoryRouter>,
143+
)
144+
145+
// First mount: /app/resources. Then the Nav effect pushes /app/billing.
146+
// setPageViewName must have been called for BOTH pathnames.
147+
const names = nr.setPageViewName.mock.calls.map((c) => c[0])
148+
expect(names).toContain('/app/resources')
149+
expect(names).toContain('/app/billing')
150+
})
151+
152+
it('does not crash when window.newrelic is absent (fail-open)', () => {
153+
delete (window as unknown as { newrelic?: unknown }).newrelic
154+
expect(() =>
155+
render(
156+
<MemoryRouter initialEntries={['/login']}>
157+
<RouteTracker />
158+
</MemoryRouter>,
159+
),
160+
).not.toThrow()
161+
})
162+
163+
it('stamps the new tier when team.tier changes (upgrade webhook path)', () => {
164+
// First render: hobby
165+
mockMe = { user: { id: 'u', email: 'x@y' }, team: { tier: 'hobby' } }
166+
const nr = installNewrelicStub()
167+
const { rerender } = render(
168+
<MemoryRouter initialEntries={['/app']}>
169+
<RouteTracker />
170+
</MemoryRouter>,
171+
)
172+
expect(nr.setCustomAttribute).toHaveBeenCalledWith('tier', 'hobby')
173+
174+
// Simulate the upgrade webhook flipping the ctx state.
175+
nr.setCustomAttribute.mockClear()
176+
mockMe = { user: { id: 'u', email: 'x@y' }, team: { tier: 'pro' } }
177+
rerender(
178+
<MemoryRouter initialEntries={['/app']}>
179+
<RouteTracker />
180+
</MemoryRouter>,
181+
)
182+
expect(nr.setCustomAttribute).toHaveBeenCalledWith('tier', 'pro')
183+
})
184+
185+
it('renders no markup (null)', () => {
186+
const { container } = render(
187+
<MemoryRouter>
188+
<RouteTracker />
189+
</MemoryRouter>,
190+
)
191+
// The component returns null; nothing should be appended.
192+
expect(container.firstChild).toBeNull()
193+
})
194+
})

src/components/RouteTracker.tsx

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/* RouteTracker.tsx — wires React Router location changes into the New
2+
* Relic browser agent.
3+
*
4+
* What it does on every route change:
5+
* 1. Calls `newrelic.setPageViewName(pathname)` so NR's page-view UI
6+
* shows route-level granularity (`/app/admin/customers` vs
7+
* `/app/resources`) instead of every event collapsing under the SPA
8+
* shell URL. Without this, soft-nav events carry the previous full
9+
* URL (or a generic "/") and the page-load funnel is unreadable.
10+
* 2. Calls `newrelic.setCustomAttribute(...)` for three dimensions:
11+
* - tier: current paid tier (anonymous|hobby|pro|growth|team)
12+
* - is_admin: whether the signed-in user is a platform admin
13+
* - commit_id: the dashboard build SHA (already stamped by
14+
* main.tsx, but we re-set it here so a single
15+
* PageView/SoftNav event always carries it even if
16+
* the agent's global-attr cache was cleared)
17+
*
18+
* Why a component vs. an `init` hook:
19+
* `useLocation()` requires a Router context. The simplest correct shape
20+
* is a component mounted *inside* <BrowserRouter> that watches
21+
* location via a useEffect dependency. App.tsx renders this just below
22+
* the router's opening tag — see App.tsx for the placement.
23+
*
24+
* Fail-open:
25+
* When `window.newrelic` is absent (no license key, ad-blocker, agent
26+
* boot failed), every call here is a no-op. Telemetry must never break
27+
* the app.
28+
*
29+
* Tier + admin sourcing:
30+
* We read the live `me` from useDashboardCtx — not from props — so the
31+
* tracker rerenders when the user signs in or upgrades. Before /auth/me
32+
* resolves, ctx.me is null and we stamp "anonymous"/false for tier and
33+
* is_admin (matches the unauthenticated marketing-shell case).
34+
*/
35+
36+
import { useEffect } from 'react'
37+
import { useLocation } from 'react-router-dom'
38+
import { useDashboardCtx } from '../hooks/useDashboardCtx'
39+
40+
// NR API surface — typed loosely because the agent attaches at runtime
41+
// and may be absent (no license key, ad-blocker, agent boot failed).
42+
type NRWindow = Window & {
43+
newrelic?: {
44+
setPageViewName?: (name: string, host?: string) => void
45+
setCustomAttribute?: (key: string, value: string | number | boolean | null) => void
46+
}
47+
}
48+
49+
// Defaults used when /auth/me hasn't resolved yet (anonymous browse of
50+
// marketing pages) — match the API's anonymous-tier semantics. We don't
51+
// fabricate a different shape just because the page is public.
52+
const TIER_FALLBACK = 'anonymous'
53+
const COMMIT_ID_FALLBACK = 'dev'
54+
55+
export function RouteTracker(): null {
56+
const location = useLocation()
57+
const ctx = useDashboardCtx()
58+
59+
useEffect(() => {
60+
// The agent might not have booted yet on the first render (the npm
61+
// BrowserAgent constructor schedules its bootstrap async). Re-check
62+
// each effect run; once it's there, every subsequent location change
63+
// gets stamped.
64+
const nr = (window as NRWindow).newrelic
65+
if (!nr) return
66+
67+
const pathname = location.pathname || '/'
68+
const tier = ctx.me?.team?.tier ?? TIER_FALLBACK
69+
const isAdmin = Boolean(ctx.me?.is_platform_admin)
70+
const commitId = import.meta.env.VITE_COMMIT_ID || COMMIT_ID_FALLBACK
71+
72+
try {
73+
// setPageViewName: the second arg ("host") is optional; NR fills it
74+
// from window.location.host when omitted. Skip it so a custom
75+
// domain (instanode.dev vs preview-*.netlify.app) doesn't drift the
76+
// grouping.
77+
nr.setPageViewName?.(pathname)
78+
nr.setCustomAttribute?.('tier', tier)
79+
nr.setCustomAttribute?.('is_admin', isAdmin)
80+
nr.setCustomAttribute?.('commit_id', commitId)
81+
} catch {
82+
// Best-effort. A NR API throw must not crash the router.
83+
}
84+
// Re-run on path change OR when the user/tier/admin flag changes (sign
85+
// in, upgrade webhook lands, admin promote). location.search /
86+
// location.hash deliberately excluded — query-string churn on the same
87+
// page shouldn't double-count as a new page view.
88+
}, [location.pathname, ctx.me?.team?.tier, ctx.me?.is_platform_admin])
89+
90+
return null
91+
}

src/lib/newrelic-config.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/* newrelic-config.test.ts — assert the options we pass to
2+
* `new BrowserAgent({...})` correspond to the pro_plus_spa mode.
3+
*
4+
* Why this test exists:
5+
* The dashboard is an SPA. If a future refactor accidentally drops to
6+
* the lite loader (jserrors-only), NR's Page Views dashboard goes
7+
* dark — no page loads, no AJAX waterfalls, no web vitals. This test
8+
* pins every feature flag that distinguishes pro_plus_spa from lite,
9+
* so a regression shows up in CI instead of in a stale grafana panel.
10+
*/
11+
12+
import { describe, it, expect } from 'vitest'
13+
import { buildBrowserAgentOptions, NR_BROWSER_MODE } from './newrelic-config'
14+
15+
describe('newrelic-config: pro_plus_spa mode', () => {
16+
const opts = buildBrowserAgentOptions({
17+
licenseKey: 'NRBR-test-license',
18+
applicationID: '1234567',
19+
})
20+
21+
it('mode tag is pro_plus_spa', () => {
22+
// Surfaced as a constant so docs / changelogs / debug overlays can
23+
// reference one place instead of greping for feature combos.
24+
expect(NR_BROWSER_MODE).toBe('pro_plus_spa')
25+
})
26+
27+
it('soft_navigations is enabled (SPA route changes)', () => {
28+
// This is THE flag that makes the agent "pro_plus_spa" vs plain "pro".
29+
// Without it, React Router pushState navigations never show up in NR.
30+
expect(opts.init.soft_navigations).toEqual({ enabled: true, autoStart: true })
31+
})
32+
33+
it('page_view_event is enabled (classic page loads)', () => {
34+
expect(opts.init.page_view_event).toEqual({ enabled: true, autoStart: true })
35+
})
36+
37+
it('page_view_timing is enabled (LCP / FID / CLS / FCP / TTFB)', () => {
38+
// The agent emits PageViewTiming events for each web vital
39+
// automatically when this feature is on. Required for NR's Core
40+
// Web Vitals UI.
41+
expect(opts.init.page_view_timing).toEqual({ enabled: true, autoStart: true })
42+
})
43+
44+
it('ajax instrumentation is on with the beacon denylisted', () => {
45+
// Without deny_list, every NR beacon POST appears in the AJAX
46+
// waterfall (recursive noise). The agent's own host is excluded.
47+
expect(opts.init.ajax.deny_list).toContain('bam.nr-data.net')
48+
})
49+
50+
it('metrics + jserrors are enabled', () => {
51+
expect(opts.init.metrics).toEqual({ enabled: true, autoStart: true })
52+
expect(opts.init.jserrors).toEqual({ enabled: true, autoStart: true })
53+
})
54+
55+
it('distributed_tracing is enabled (correlates browser → api spans)', () => {
56+
// The Go agent on instant-api creates spans for every Fiber handler;
57+
// this flag tells the browser agent to inject the W3C traceparent
58+
// header on outgoing fetches so the two halves stitch in NR.
59+
expect(opts.init.distributed_tracing).toEqual({ enabled: true })
60+
})
61+
62+
it('keys and IDs propagate through info + loader_config', () => {
63+
expect(opts.info.licenseKey).toBe('NRBR-test-license')
64+
expect(opts.info.applicationID).toBe('1234567')
65+
expect(opts.loader_config.licenseKey).toBe('NRBR-test-license')
66+
expect(opts.loader_config.applicationID).toBe('1234567')
67+
// accountID + trustKey + agentID currently mirror the appID for a
68+
// single-account install. If we ever move to a sub-account model,
69+
// this assertion needs to relax.
70+
expect(opts.loader_config.accountID).toBe('1234567')
71+
expect(opts.loader_config.trustKey).toBe('1234567')
72+
expect(opts.loader_config.agentID).toBe('1234567')
73+
})
74+
})

0 commit comments

Comments
 (0)