Skip to content
Open
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
69 changes: 69 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,34 @@ and collapses. Fix: make the wrapper `flex flex-col` (or add `block`/`w-full` to
anchor). The GitHub button (`.github-signin-btn`) sets `display: flex` explicitly and is
not affected. (Hit while building the "Last used" sign-in badge, #1316.)

## A mocked hook whose value lands in a dependency array must keep a stable identity

If you `vi.mock` a hook and the code under test puts its return value in a `useEffect` dependency
array, returning a fresh object per call re-fires that effect on **every render** — and any
assertion counting effect side-effects then passes for the wrong reason. `vi.mock('@tanstack/
react-router', () => ({ useRouter: () => ({ ... }) }))` did exactly that to
[`datadog.test.tsx`](src/integrations/datadog/datadog.test.tsx): the test that claimed to prove
"one view per navigation" would have passed with `location.href` removed from the deps entirely.
The real `useRouter` returns a stable reference, so the mock was also lying about production.

Instantiate once in the factory and expose changing state through a getter:

```ts
vi.mock('@tanstack/react-router', () => {
const router = {
get state() {
return { location: { href: routerState.href } };
},
};
return { useRouter: () => router };
});
```

The general check, worth running on any effect-counting test: **re-render without changing
anything and assert nothing happened.** That assertion is what distinguishes "the effect re-ran
because its input changed" from "the effect re-runs constantly"; it fails immediately against an
unstable mock.

## Testing Radix menus in jsdom

This repo has NO `@testing-library/user-event` — only `@testing-library/react` +
Expand Down Expand Up @@ -383,6 +411,47 @@ Only fields on the SDK's modifiable-field allowlist can be mutated here. `view.n
`error.fingerprint`; resource events add `resource.url` (plus GraphQL/header/websocket fields).
Grep the installed bundle for `"view.referrer":"string"` to re-check after an SDK bump.

## Datadog RUM — exactly one `startView` per page load, or Core Web Vitals vanish

[`datadog.ts`](src/integrations/datadog/datadog.ts) sets `trackViewsManually: true`, and the SDK's
contract for that flag is narrower than it looks. It stays stopped until the **first** `startView`,
adopts that call's options as its single `initial_load` view, then turns every later call into a
`route_change` view (`preStartRum.ts` `tryStartRum`, `trackViews.ts` `startView`). Only an
`initial_load` view runs `trackInitialViewMetrics`, so it is the only view that can ever carry LCP
or FCP.

A second `startView` on boot therefore ends the one view that collects paint metrics. That is what
zeroed Studio's vitals for a month (#1570): `useDatadog` and `useOnRouteLoadTracker` both called
it, 0.3ms apart, and every `initial_load` event shipped with `dom_complete` but no `lcp` and no
`fcp`. Ownership of that first call now lives in `useOnRouteLoadTracker` alone — it mounts on the
root route (`rootRoute.ts`), so it runs on every cloud route.

Two traps when reading this from RUM data:

- `view.time_spent` on an `initial_load` view is measured from `clocksOrigin()` — page origin, not
SDK start — so a ~1s `time_spent` does **not** mean the view was alive and observing for a
second. It says nothing about the observation window.
- Never name a view from `window.location.pathname`. Studio uses hash routing, so that is
permanently `/` — which is why all 880 `initial_load` views in one week were named `/` whatever
route actually loaded, and why #1405's "`/` view regression" was really every deep-link entry
conflated into one bucket.

A boot-time redirect does **not** cost you that first view, and the deciding factor is whether
`getAllConnections()` can answer synchronously. It synthesizes `{ user: null, isLoading: false }`
for `OverallAppSignIn` only when the `Studio:PotentiallyAuthenticated` localStorage record has no
entry for it; with an entry it returns the record untouched, so the key is simply **absent**.
`dashboardLayout.beforeLoad` guards on `auth && !auth.isLoading && !auth.user`, so those are two
different outcomes: a signed-out deep link redirects _during_ the router's initial load and the root
component never commits the deep-link location — one `startView`, named `/sign-in/` — while an
expired session short-circuits on the missing key, renders the deep link, and only redirects once
auth resolves and `AppRouted` calls `router.invalidate()`. Two views there, a network round trip
apart, the second a genuine navigation.

Verifying a change here needs a **visible** browser on a production build: a headless or background
tab reports `visibilityState: 'hidden'`, emits zero paint and LCP entries, and `trackFirstHidden`
discards them anyway — so vitals always read as absent, and a broken fix looks identical to a
working one.

## Never put an address or a credential in a URL

Studio uses **hash routing**, so a query param lives in the fragment: for
Expand Down
123 changes: 123 additions & 0 deletions src/integrations/datadog/datadog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* @vitest-environment jsdom
*/
import { cleanup, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const { rum, routerState } = vi.hoisted(() => ({
rum: {
init: vi.fn(),
startView: vi.fn(),
onReady: vi.fn((callback: () => void) => callback()),
setUser: vi.fn(),
clearUser: vi.fn(),
addAction: vi.fn(),
},
routerState: { href: '/org-1/clu-2/apps', params: [{ organizationId: 'org-1', clusterId: 'clu-2' }] },
}));

vi.mock('@datadog/browser-rum', () => ({ datadogRum: rum }));
vi.mock('@datadog/browser-rum-react', () => ({ reactPlugin: () => ({ name: 'react' }) }));
vi.mock('@/config/constants', () => ({ isLocalStudio: false }));
vi.mock('@/hooks/useAuth', () => ({ useOverallAuth: () => ({ user: undefined }) }));
// One stable `router` identity, as the real `useRouter` returns — the tracker's effect lists
// `router` in its deps, so a fresh object per render would re-fire it on every render and the
// navigation assertions below would hold even if `location.href` were not a dependency.
vi.mock('@tanstack/react-router', () => {
const router = {
get state() {
return { location: { href: routerState.href } };
},
matchRoutes: () => routerState.params.map((params) => ({ params })),
};
return {
useLocation: () => ({ href: routerState.href }),
useRouter: () => router,
};
});

async function loadDatadogModule() {
vi.resetModules();
// `enabled` is a module-scope const, so DEV has to be false before the import.
vi.stubEnv('DEV', false);
return import('./datadog');
}

beforeEach(() => {
routerState.href = '/org-1/clu-2/apps';
routerState.params = [{ organizationId: 'org-1', clusterId: 'clu-2' }];
});

afterEach(() => {
cleanup();
vi.unstubAllEnvs();
vi.clearAllMocks();
});

describe('Datadog view tracking', () => {
it('starts exactly one view when the whole tree boots', async () => {
const { useDatadog, useOnRouteLoadTracker } = await loadDatadogModule();
function CloudRoot() {
useOnRouteLoadTracker();
return null;
}
function App() {
useDatadog();
return <CloudRoot />;
}

render(<App />);

expect(rum.init).toHaveBeenCalledTimes(1);
expect(rum.startView).toHaveBeenCalledTimes(1);
});

it("names that view by route, not by the hash router's pathname", async () => {
const { useDatadog, useOnRouteLoadTracker } = await loadDatadogModule();
function CloudRoot() {
useOnRouteLoadTracker();
return null;
}
function App() {
useDatadog();
return <CloudRoot />;
}

render(<App />);

expect(rum.startView).toHaveBeenCalledWith(
expect.objectContaining({ name: '/$organizationId/$clusterId/apps/' }),
);
});

it('does not start a view from useDatadog', async () => {
const { useDatadog } = await loadDatadogModule();
function Harness() {
useDatadog();
return null;
}

render(<Harness />);

expect(rum.startView).not.toHaveBeenCalled();
});

it('starts a further view on each subsequent navigation', async () => {
const { useOnRouteLoadTracker } = await loadDatadogModule();
function CloudRoot() {
useOnRouteLoadTracker();
return null;
}

const { rerender } = render(<CloudRoot />);
rerender(<CloudRoot />);
routerState.href = '/org-1/clu-2/config';
rerender(<CloudRoot />);

// The middle re-render changed nothing, so it must not have produced a view.
expect(rum.startView.mock.calls.map(([options]) => options.name)).toEqual([
'/$organizationId/$clusterId/apps/',
'/$organizationId/$clusterId/config/',
]);
});
});
9 changes: 2 additions & 7 deletions src/integrations/datadog/datadog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,8 @@ export function useDatadog() {
plugins: [reactPlugin()],
});

datadogRum.onReady(() => {
datadogRum.startView({
service: 'studio',
version: import.meta.env.VITE_STUDIO_VERSION,
name: window.location.pathname || 'initial',
});
});
// No `startView` here: `useOnRouteLoadTracker` must own the sole boot view, because
// only the first one is an `initial_load` view and only that view collects LCP/FCP.
}
}, []);
}
Expand Down
73 changes: 73 additions & 0 deletions src/integrations/datadog/datadogBootView.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* @vitest-environment jsdom
*/
import { QueryClient } from '@tanstack/react-query';
import { render, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';

const { rum } = vi.hoisted(() => ({
rum: {
init: vi.fn(),
startView: vi.fn(),
onReady: vi.fn((callback: () => void) => callback()),
setUser: vi.fn(),
clearUser: vi.fn(),
addAction: vi.fn(),
},
}));

vi.mock('@datadog/browser-rum', () => ({ datadogRum: rum }));
vi.mock('@datadog/browser-rum-react', () => ({ reactPlugin: () => ({ name: 'react' }) }));
vi.mock('@/features/notifications/NotificationsSubscriptionManager', () => ({
NotificationsSubscriptionManager: () => null,
}));
vi.mock('@/components/NotificationBanner', () => ({ NotificationBanner: () => null }));

// Real `rootRoute` and real `dashboardLayout`, stub leaves: mocking either would stop this from
// guarding the one thing it exists for — that the tracker is still mounted at the root.
async function bootDeepLink() {
vi.resetModules();
vi.stubEnv('DEV', false);
const router = await import('@tanstack/react-router');
const { rootRoute } = await import('@/router/rootRoute');
const { dashboardLayout } = await import('@/router/dashboardRoute');
const { OverallAppSignIn } = await import('@/features/auth/store/authStore');

const deepRoute = router.createRoute({
getParentRoute: () => dashboardLayout,
path: '/$organizationId/$clusterId/apps',
component: () => null,
});
const signInRoute = router.createRoute({
getParentRoute: () => rootRoute,
path: '/sign-in',
component: () => null,
});
const instance = router.createRouter({
routeTree: rootRoute.addChildren([signInRoute, dashboardLayout.addChildren([deepRoute])]),
history: router.createMemoryHistory({ initialEntries: ['/org-1/clu-2/apps'] }),
context: {
queryClient: new QueryClient(),
authentication: { [OverallAppSignIn]: { isLoading: false, user: null } },
},
});

render(<router.RouterProvider router={instance} />);
return instance;
}

afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
});

describe('Datadog boot view against the real router', () => {
it('starts exactly one view when a signed-out deep link redirects to sign-in', async () => {
const instance = await bootDeepLink();

await waitFor(() => expect(instance.state.location.pathname).toBe('/sign-in'));

expect(rum.startView).toHaveBeenCalledTimes(1);
expect(rum.startView).toHaveBeenCalledWith(expect.objectContaining({ name: '/sign-in/' }));
});
});