fix(datadog): restore LCP/FCP reporting by keeping the initial_load view alive - #1642
Draft
dawsontoth wants to merge 6 commits into
Draft
fix(datadog): restore LCP/FCP reporting by keeping the initial_load view alive#1642dawsontoth wants to merge 6 commits into
dawsontoth wants to merge 6 commits into
Conversation
… 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>
Contributor
There was a problem hiding this comment.
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.
Coverage Report
File Coverage
|
||||||||||||||||||||||||||||||||||||||
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Studio has reported no LCP or FCP since 2026-07-04 (#1570) because it called
datadogRum.startViewtwice 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: truethe RUM SDK stays stopped until the firststartView, adopts that call's options as its singleinitial_loadview, and turns every later call into aroute_changeview (preStartRum.tstryStartRum,trackViews.tsstartView). Only aninitial_loadview runstrackInitialViewMetrics, so it is the only view that can ever carry LCP or FCP.useDatadoganduseOnRouteLoadTrackerboth calledstartView, so the initial view was ended microseconds after it began.For the human reviewer
Which of the two
startViewcalls to delete. KeptuseOnRouteLoadTracker's, deleteduseDatadog's. The alternative — keepuseDatadog's and gate the tracker to fire only on subsequent routes — restores vitals equally well but leaves everyinitial_loadview named/, becauseuseDatadognamed views fromwindow.location.pathnameand Studio uses hash routing. DeletinguseDatadog's call fixes both defects with one edit. Fully reversible; a change of mind costs one commit.A boot-time redirect can still fire two views, and I deliberately did not fix it. The one finding most worth your judgment.
dashboardLayout.beforeLoadthrowsredirect({ to: '/sign-in' })for a logged-out deep link (src/router/dashboardRoute.ts:11-19), solocation.hrefchanges twice during that boot and the tracker's effect — keyed[location.href, router]— can fire twice, downgrading theinitial_loadview again for those sessions. I confirmed the redirect exists but not thatStudioCloudmounts 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 secondstartViewrisks losing genuineroute_changetracking, 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.RUM now starts on first route render rather than on
Appmount — and there is a narrow telemetry consequence. This follows from (1): the firststartViewis what starts the SDK, and the tracker lives inStudioCloud, 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 refuted —rootRouteTreeisrootRoute.addChildren([...]), so every route renders insideStudioCloud, includingdefaultNotFoundComponentanddefaultErrorComponent. 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 previouslyuseDatadogwould already have started RUM from outside the router. If you want that closed, the clean way isuseDatadogkeeping astartViewand the tracker callingsetViewName()on its first run instead ofstartView— 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.StudioLocaldoes not call the tracker, so local Studio never starts a view. Pre-existing and correct —enabledis!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
useRouterreturned a fresh object per render. That was not cosmetic — because the tracker's effect listsrouterin its deps, the effect re-fired on every render and the subsequent-navigation assertion was vacuous, holding even withoutlocation.hrefas a dependency. Fixed inff3635b0with 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 therouter-dep finding declined below — the production deps are sound only because the realuseRouteris 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
startViewbeforeuseDatadog'sinit) is safe —onReadyinvokes its callback synchronously in the bundled build, and astartViewarriving beforeinitis buffered asfirstStartViewCalland adopted asinitialViewOptions(preStartRum.ts:106-118, 295-296), which is exactly the SDK's designed path. Not taken: narrowing the tracker's effect deps fromroutertolocation.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.0bundle over HTTP and replicated Studio's boot sequence (initin a deferred callback,startViewinsideonReady, then a secondstartViewin the same flush), with abeforeSendthat captured each assembled event and returnedfalseso nothing reached Datadog:stagebehaviour): the calls land att=78.2msandt=78.5ms— 0.3ms apart. Theinitial_loadevent ships withdom_complete: 79msand nolcp, nofcp, then aroute_changeview takes over.initial_loadviews over 7 days,dom_complete17,fcp2,lcp0. All 880initial_loadviews that week are named/— one facet bucket — whileroute_changeviews carry proper route names.Regression tests (
datadog.test.tsx, 4 cases): mounts both hooks in the production nesting and asserts exactly onestartView; pins its name to the translated route; keeps an isolateduseDatadogcase 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 run322 files / 2625 passed,tsc -b,oxlint,dprint check, andpnpm test:e2e:docker(4 passed, 4 skipped — the skipped specs need roundtrip credentials). Script mapping: Studio has notest:unit:main/test:unit:resources/test:integration:all; the equivalents aretest(vitest) andtest: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, andtrackFirstHiddenwould discard them anyway — so vitals read as absent whether the fix works or not. The cheap confirmation is@view.largest_contentful_paintcoverage oninitial_loadviews 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
domainadjudicator 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.geminiran 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