Skip to content

fix(datadog): restore LCP/FCP reporting by keeping the initial_load view alive - #1642

Draft
dawsontoth wants to merge 6 commits into
stagefrom
fix/rum-initial-view-vitals
Draft

fix(datadog): restore LCP/FCP reporting by keeping the initial_load view alive#1642
dawsontoth wants to merge 6 commits into
stagefrom
fix/rum-initial-view-vitals

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Studio has reported no LCP or FCP since 2026-07-04 (#1570) because it called datadogRum.startView twice on boot, and the second call destroyed the only view that can carry paint metrics. This removes the redundant call, which also fixes every initial page load being attributed to the view name /.

Under trackViewsManually: true the RUM SDK stays stopped until the first startView, adopts that call's options as its single initial_load view, and 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. useDatadog and useOnRouteLoadTracker both called startView, so the initial view was ended microseconds after it began.

For the human reviewer

  1. Which of the two startView calls to delete. Kept useOnRouteLoadTracker's, deleted useDatadog's. The alternative — keep useDatadog's and gate the tracker to fire only on subsequent routes — restores vitals equally well but leaves every initial_load view named /, because useDatadog named views from window.location.pathname and Studio uses hash routing. Deleting useDatadog's call fixes both defects with one edit. Fully reversible; a change of mind costs one commit.

  2. A boot-time redirect can still fire two views, and I deliberately did not fix it. The one finding most worth your judgment. dashboardLayout.beforeLoad throws redirect({ to: '/sign-in' }) for a logged-out deep link (src/router/dashboardRoute.ts:11-19), so location.href changes twice during that boot and the tracker's effect — keyed [location.href, router] — can fire twice, downgrading the initial_load view again for those sessions. I confirmed the redirect exists but not that StudioCloud mounts before the redirect resolves; if the router holds the root component until the initial navigation settles, the second view never happens. I left it alone because suppressing that second startView risks losing genuine route_change tracking, and because this change is a strict improvement regardless — it removes the double-fire that hit every load, which is why LCP is 0-for-200 today rather than merely low. Say the word and I'll extend this PR; otherwise I'll file it as a follow-up. Cost of a "no": logged-out deep-link sessions may keep reporting no vitals.

  3. RUM now starts on first route render rather than on App mount — and there is a narrow telemetry consequence. This follows from (1): the first startView is what starts the SDK, and the tracker lives in StudioCloud, the root route component. Gemini flagged this as a data-loss major on the theory that some routes render outside the cloud root; that is refutedrootRouteTree is rootRoute.addChildren([...]), so every route renders inside StudioCloud, including defaultNotFoundComponent and defaultErrorComponent. What remains is genuinely narrow: if the root component or the router itself fails catastrophically before rendering, no view ever starts and that session reports nothing, where previously useDatadog would already have started RUM from outside the router. If you want that closed, the clean way is useDatadog keeping a startView and the tracker calling setViewName() on its first run instead of startView — the SDK exposes it (rumPublicApi.ts:118) and it renames the initial view without ending it. I did not do it because it adds first-run state for a failure mode I cannot reproduce, but it is the strictly-better design if you judge the boot-error window worth it.

  4. StudioLocal does not call the tracker, so local Studio never starts a view. Pre-existing and correct — enabled is !import.meta.env.DEV && !isLocalStudio, so RUM is fully disabled there and no view would have been sent anyway. Flagged only because the asymmetry reads like an oversight in the diff.

Addressed from PR review: gemini-code-assist found the mocked useRouter returned a fresh object per render. That was not cosmetic — because the tracker's effect lists router in its deps, the effect re-fired on every render and the subsequent-navigation assertion was vacuous, holding even without location.href as a dependency. Fixed in ff3635b0 with a single stable router identity, plus an assertion that a no-op re-render produces no view; that assertion fails against the old mock, so it stays guarded. Note it is the test-side mirror of the router-dep finding declined below — the production deps are sound only because the real useRouter is stable.

Declined: a repeated nit asking to remove the comment above the router mock. It explains why the mock uses a single instance with a getter, which is the one thing a future reader would otherwise "simplify" back into a literal — silently making the navigation tests vacuous again. That is a constraint the code cannot express, so it stays.

Verified and closed, recorded here so you don't re-derive them: gemini's child-before-parent effect-ordering concern (the tracker in a child firing startView before useDatadog's init) is safeonReady invokes its callback synchronously in the bundled build, and a startView arriving before init is buffered as firstStartViewCall and adopted as initialViewOptions (preStartRum.ts:106-118, 295-296), which is exactly the SDK's designed path. Not taken: narrowing the tracker's effect deps from router to location.href — a pre-existing line this PR doesn't touch, with a hypothetical trigger, so it stayed out under YAGNI.

Verification

Route: live reproduction against the real SDK, recorded — the change is not observable through the e2e suite (RUM is disabled in dev and test builds, and no e2e spec touches it).

Served the shipped @datadog/browser-rum@7.8.0 bundle over HTTP and replicated Studio's boot sequence (init in a deferred callback, startView inside onReady, then a second startView in the same flush), with a beforeSend that captured each assembled event and returned false so nothing reached Datadog:

  • Two calls (current stage behaviour): the calls land at t=78.2ms and t=78.5ms — 0.3ms apart. The initial_load event ships with dom_complete: 79ms and no lcp, no fcp, then a route_change view takes over.
  • Production agrees: of 200 raw initial_load views over 7 days, dom_complete 17, fcp 2, lcp 0. All 880 initial_load views that week are named / — one facet bucket — while route_change views carry proper route names.

Regression tests (datadog.test.tsx, 4 cases): mounts both hooks in the production nesting and asserts exactly one startView; pins its name to the translated route; keeps an isolated useDatadog case to localise a failure; and asserts a further named view per subsequent navigation. Mutation-verified — re-adding the deleted block turns the tree-level and isolated tests red.

Gates (Node 24.19.0, all exit 0, re-run after every review round): vitest run 322 files / 2625 passed, tsc -b, oxlint, dprint check, and pnpm test:e2e:docker (4 passed, 4 skipped — the skipped specs need roundtrip credentials). Script mapping: Studio has no test:unit:main/test:unit:resources/test:integration:all; the equivalents are test (vitest) and test:e2e:docker (Playwright).

Not proven, and the thing to watch post-merge: that the fix restores LCP/FCP end-to-end. Every browser surface available locally reports visibilityState: 'hidden', which emits zero paint and LCP entries, and trackFirstHidden would discard them anyway — so vitals read as absent whether the fix works or not. The cheap confirmation is @view.largest_contentful_paint coverage on initial_load views after this deploys: it should go from 0% to a non-trivial share. Note #1405's baseline predates this and needs re-framing rather than just fresh data — its "/ view" was every deep-link entry conflated into one bucket.

Coverage caveat: across six pre-push review rounds the Harper domain adjudicator failed every time (exit-1, zero-byte log — a known failure on this machine), so the outside findings above were never machine-adjudicated; I triaged them myself against the SDK source and the route tree, which is why two are recorded as refuted/verified rather than fixed. gemini ran in rounds 2–4 and failed in rounds 1 and 5 (a sandbox denial, then a leg failure), so the final round is codex-only. That is a coverage gap, not an open blocker.

Complexity: medium

Review-Coverage: authored=claude; ran=codex; blocked=gemini(quota); declined=cursor-grok,cursor-composer,domain; rounds=6 @ b6d33ae

Human-Review-Need: 4 @ b6d33ae

dawsontoth and others added 4 commits August 21, 2026 11:25
… again

Under `trackViewsManually` the RUM SDK stays stopped until the first
`startView`, adopts that call's options as its one `initial_load` view, and
turns every later call into a `route_change` view. Only an `initial_load` view
runs `trackInitialViewMetrics`, so it is the only view that can ever carry LCP
or FCP.

Studio called `startView` twice on boot — once in `useDatadog`, then again in
`useOnRouteLoadTracker` — so the initial view was ended microseconds later and
its paint metrics were thrown away. Measured against the real SDK bundle, the
two calls land 0.3ms apart and the initial_load event ships with dom_complete
but no lcp and no fcp, matching production exactly: of 200 initial_load views,
dom_complete 17, fcp 2, lcp 0.

Drop the `useDatadog` call and leave the single one to `useOnRouteLoadTracker`,
which mounts on the root route and so runs on every cloud route. That also
fixes the view name: `useDatadog` used `window.location.pathname`, which is
permanently `/` under the hash router, so all 880 initial_load views in a week
were named `/` regardless of the route actually loaded.

Refs #1570

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…trim comments

Cross-model review (cursor-composer) noted the #1570 guard rendered `useDatadog`
alone, so it only caught the specific regression of re-adding that call — a
second `startView` introduced anywhere else in the boot tree would leave it
green while production went back to zero vitals. Mount both hooks the way
production does (App → StudioCloud) and assert exactly one `startView`, which
is the invariant that actually matters; keep the isolated case to narrow a
failure to the hook that regressed.

Also pin the expected view name to the translated route so a revert to
pathname-based naming fails CI instead of silently restoring permanently-`/`
names, and trim the comments in both files to the one non-obvious SDK
constraint per the repo's zero-new-comments default (codex nit).

Both new assertions are mutation-verified: re-adding the deleted `startView`
turns the tree-level test and the isolated test red.

Refs #1570

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round-2 review (gemini) noted the suite proved the boot case but nothing about
later navigations, so a regression that stopped emitting `route_change` views
would go unnoticed. Assert the tracker emits a further named view per href
change.

Comment trim per the repeated nit from both lenses: drop the issue-number
narration and the restated test rationale, keeping only the two non-obvious
constraints (the module-scope `enabled` read, and the production nesting the
boot test mirrors).

Refs #1570

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The invariant is documented in AGENTS.md; the nesting is visible in the code.
Third repeat of the same review nit.

Refs #1570

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request resolves an issue where Datadog RUM's Core Web Vitals (LCP/FCP) were not being tracked due to multiple startView calls during boot. The initial startView call has been removed from useDatadog so that useOnRouteLoadTracker is the sole owner of the initial view. Documentation has been added to AGENTS.md to explain this behavior, and a new test suite has been introduced. The review feedback suggests stabilizing the mocked useRouter hook in the tests to prevent unnecessary effect re-runs caused by unstable object references.

Comment thread src/integrations/datadog/datadog.test.tsx Outdated
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 58.81% 7755 / 13186
🔵 Statements 59.31% 8329 / 14042
🔵 Functions 51.44% 1957 / 3804
🔵 Branches 52.69% 5554 / 10539
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/integrations/datadog/datadog.ts 53.48% 35.71% 87.5% 54.76% 18, 80-84, 92-125
Generated in workflow #1781 for commit b6d33ae by the Vitest Coverage Report Action

dawsontoth and others added 2 commits August 21, 2026 11:47
gemini-code-assist: the mock returned a fresh `useRouter()` object per render,
and the tracker's effect lists `router` in its deps — so the effect re-fired on
every render and the navigation assertions held even without `location.href` as
a dependency. The real `useRouter` returns a stable reference, so the mock was
also unfaithful.

Instantiate the router once with a getter for `state`, and assert that a
re-render which changes nothing produces no view. That assertion fails against
the old unstable mock, so the fix stays guarded.

Refs #1570

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… identities

The lesson from this PR's own escaped review finding: an unstable mocked
`useRouter` made an effect-counting test pass for the wrong reason, and would
have passed with the dependency removed entirely. Records the getter pattern
and the no-op-rerender assertion that catches it.

Refs #1570

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant