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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -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
// <BrowserRouter> (useLocation needs router context) so we mount it as a
// sibling of <AppRoutes>. 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'
Expand Down Expand Up @@ -246,6 +253,11 @@ export function AppRoutes() {
export function App() {
return (
<BrowserRouter>
{/* 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). */}
<RouteTracker />
<AppRoutes />
</BrowserRouter>
)
Expand Down
194 changes: 194 additions & 0 deletions src/components/RouteTracker.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>
setCustomAttribute: ReturnType<typeof vi.fn>
}

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(
<MemoryRouter initialEntries={['/app/resources']}>
<RouteTracker />
</MemoryRouter>,
)
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(
<MemoryRouter initialEntries={['/app']}>
<RouteTracker />
</MemoryRouter>,
)
// 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(
<MemoryRouter initialEntries={['/pricing']}>
<RouteTracker />
</MemoryRouter>,
)
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(
<MemoryRouter initialEntries={['/app/resources']}>
<RouteTracker />
<Routes>
<Route path="/app/resources" element={<Nav />} />
<Route path="/app/billing" element={<div>billing</div>} />
</Routes>
</MemoryRouter>,
)

// 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(
<MemoryRouter initialEntries={['/login']}>
<RouteTracker />
</MemoryRouter>,
),
).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(
<MemoryRouter initialEntries={['/app']}>
<RouteTracker />
</MemoryRouter>,
)
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(
<MemoryRouter initialEntries={['/app']}>
<RouteTracker />
</MemoryRouter>,
)
expect(nr.setCustomAttribute).toHaveBeenCalledWith('tier', 'pro')
})

it('renders no markup (null)', () => {
const { container } = render(
<MemoryRouter>
<RouteTracker />
</MemoryRouter>,
)
// The component returns null; nothing should be appended.
expect(container.firstChild).toBeNull()
})
})
91 changes: 91 additions & 0 deletions src/components/RouteTracker.tsx
Original file line number Diff line number Diff line change
@@ -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* <BrowserRouter> 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
}
74 changes: 74 additions & 0 deletions src/lib/newrelic-config.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading