-
Notifications
You must be signed in to change notification settings - Fork 3
Add Datadog Browser RUM client instrumentation for Next.js App Router #610
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 6 commits
2d302a2
9fd03ec
60753e5
0248193
442d6c9
f72c0cd
f325111
fbde037
2b3131e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| import test from "node:test"; | ||
| import assert from "node:assert/strict"; | ||
| import React from "react"; | ||
| import { act, create } from "react-test-renderer"; | ||
| import { | ||
| DatadogRumInitComponent, | ||
| type DatadogRumInitDeps, | ||
| } from "@/components/datadog-rum-init"; | ||
|
|
||
| const mockWindow = { | ||
| clearInterval: () => undefined, | ||
| location: { origin: "https://yourpeer.nyc" }, | ||
| setInterval: () => 1, | ||
| } as unknown as Window; | ||
|
|
||
| (globalThis as { window?: Window }).window = mockWindow; | ||
|
|
||
| const flushEffects = async () => { | ||
| await act(async () => { | ||
| await Promise.resolve(); | ||
| }); | ||
| }; | ||
|
|
||
| test("DatadogRumInitComponent reacts to consent changes and preserves init/view ordering", async () => { | ||
| let consent = false; | ||
| let intervalCallback: (() => void) | undefined; | ||
| const callLog: string[] = []; | ||
|
|
||
| const deps: DatadogRumInitDeps = { | ||
| clearIntervalFn: (() => undefined) as typeof window.clearInterval, | ||
| getConsentStatus: () => consent, | ||
| initializeRumFn: ({ hasConsent }) => { | ||
| callLog.push(`init:${hasConsent ? "granted" : "not-granted"}`); | ||
| return hasConsent; | ||
| }, | ||
| isRumAlreadyInitializedFn: () => false, | ||
| markRumAsInitializedFn: () => { | ||
| callLog.push("mark-initialized"); | ||
| }, | ||
| setIntervalFn: ((callback: TimerHandler) => { | ||
| intervalCallback = callback as () => void; | ||
| return 1; | ||
| }) as typeof window.setInterval, | ||
| startRumViewFn: ({ hasConsent, normalizedViewName }) => { | ||
| callLog.push( | ||
| `startView:${hasConsent ? "granted" : "not-granted"}:${normalizedViewName}`, | ||
| ); | ||
| return hasConsent; | ||
| }, | ||
| syncTrackingConsentFn: ({ hasConsent }) => { | ||
| callLog.push(`sync:${hasConsent ? "granted" : "not-granted"}`); | ||
| return true; | ||
| }, | ||
| }; | ||
|
|
||
| const renderer = create( | ||
| <DatadogRumInitComponent deps={deps} pathname="/jane-doe/private-area" />, | ||
| ); | ||
|
|
||
| await flushEffects(); | ||
|
|
||
| assert.ok(callLog.includes("sync:not-granted")); | ||
| assert.ok(callLog.includes("init:not-granted")); | ||
| assert.ok( | ||
| callLog.includes("startView:not-granted:route:/:slug/:slug"), | ||
| "view names should use slug-masked normalization", | ||
| ); | ||
|
|
||
| consent = true; | ||
|
|
||
| await act(async () => { | ||
| intervalCallback?.(); | ||
| await Promise.resolve(); | ||
| }); | ||
|
|
||
| const syncGrantedIndex = callLog.indexOf("sync:granted"); | ||
| const initGrantedIndex = callLog.indexOf("init:granted"); | ||
| const markInitializedIndex = callLog.indexOf("mark-initialized"); | ||
| const startViewGrantedIndex = callLog.findIndex((entry) => | ||
| entry.startsWith("startView:granted:"), | ||
| ); | ||
|
|
||
| assert.ok(syncGrantedIndex >= 0); | ||
| assert.ok(initGrantedIndex > syncGrantedIndex); | ||
| assert.ok(markInitializedIndex > initGrantedIndex); | ||
| assert.ok(startViewGrantedIndex > markInitializedIndex); | ||
|
|
||
| renderer.unmount(); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,177 @@ | ||
| "use client"; | ||
|
|
||
| import { datadogRum } from "@datadog/browser-rum"; | ||
| import { usePathname } from "next/navigation"; | ||
| import { useEffect, useState } from "react"; | ||
| import { | ||
| initializeRum, | ||
| startRumView, | ||
| syncTrackingConsent, | ||
| } from "@/components/datadog-rum-runtime"; | ||
| import { | ||
| hasDatadogConsent, | ||
| isRumAlreadyInitialized, | ||
| markRumAsInitialized, | ||
| normalizePathnameForViewName, | ||
| } from "@/components/datadog-rum-utils"; | ||
|
|
||
| const DATADOG_APPLICATION_ID = | ||
| process.env.NEXT_PUBLIC_DATADOG_APPLICATION_ID ?? ""; | ||
| const DATADOG_CLIENT_TOKEN = process.env.NEXT_PUBLIC_DATADOG_CLIENT_TOKEN ?? ""; | ||
| const DATADOG_SITE = process.env.NEXT_PUBLIC_DATADOG_SITE ?? "datadoghq.com"; | ||
| const DATADOG_SERVICE = | ||
| process.env.NEXT_PUBLIC_DATADOG_SERVICE ?? "yourpeer-frontend"; | ||
| const DATADOG_ENV = process.env.NEXT_PUBLIC_DATADOG_ENV ?? ""; | ||
| const DATADOG_VERSION = process.env.NEXT_PUBLIC_DATADOG_VERSION ?? ""; | ||
| const DATADOG_APP_NAME = | ||
| process.env.NEXT_PUBLIC_DATADOG_APP_NAME ?? "yourpeer.nyc"; | ||
| const DATADOG_SESSION_SAMPLE_RATE = Number.parseFloat( | ||
| process.env.NEXT_PUBLIC_DATADOG_SESSION_SAMPLE_RATE ?? "100", | ||
| ); | ||
| const DATADOG_SESSION_REPLAY_SAMPLE_RATE = Number.parseFloat( | ||
| process.env.NEXT_PUBLIC_DATADOG_SESSION_REPLAY_SAMPLE_RATE ?? "0", | ||
| ); | ||
| const DATADOG_SESSION_REPLAY_ENABLED = | ||
| process.env.NEXT_PUBLIC_DATADOG_SESSION_REPLAY_ENABLED === "true"; | ||
| const DATADOG_TRACING_ORIGINS = | ||
| process.env.NEXT_PUBLIC_DATADOG_TRACING_ORIGINS ?? ""; | ||
| const DATADOG_ENABLED = process.env.NEXT_PUBLIC_DATADOG_ENABLED === "true"; | ||
| const DATADOG_REQUIRE_CONSENT = | ||
| process.env.NEXT_PUBLIC_DATADOG_REQUIRE_CONSENT !== "false"; | ||
| const DATADOG_CONSENT_COOKIE_NAME = | ||
| process.env.NEXT_PUBLIC_DATADOG_CONSENT_COOKIE_NAME ?? "analytics_consent"; | ||
| const CONSENT_REFRESH_INTERVAL_MS = 1000; | ||
|
|
||
| const hasDatadogConfiguration = | ||
| DATADOG_ENABLED && | ||
| DATADOG_APPLICATION_ID.length > 0 && | ||
| DATADOG_CLIENT_TOKEN.length > 0; | ||
|
|
||
| export type DatadogRumInitDeps = { | ||
| clearIntervalFn: typeof window.clearInterval; | ||
| getConsentStatus: () => boolean; | ||
| initializeRumFn: typeof initializeRum; | ||
| isRumAlreadyInitializedFn: typeof isRumAlreadyInitialized; | ||
| markRumAsInitializedFn: typeof markRumAsInitialized; | ||
| setIntervalFn: typeof window.setInterval; | ||
| startRumViewFn: typeof startRumView; | ||
| syncTrackingConsentFn: typeof syncTrackingConsent; | ||
| }; | ||
|
|
||
| const buildTracingOrigins = () => { | ||
| const configuredOrigins = DATADOG_TRACING_ORIGINS.split(",") | ||
| .map((origin) => origin.trim()) | ||
| .filter(Boolean); | ||
|
|
||
| const origins = new Set(configuredOrigins); | ||
|
|
||
| if (typeof window !== "undefined") { | ||
| origins.add(window.location.origin); | ||
| } | ||
|
|
||
| return Array.from(origins); | ||
| }; | ||
|
|
||
| const getConsentStatus = () => { | ||
| if (typeof window === "undefined") { | ||
| return false; | ||
| } | ||
|
|
||
| return ( | ||
| !DATADOG_REQUIRE_CONSENT || | ||
| hasDatadogConsent(window, DATADOG_CONSENT_COOKIE_NAME) | ||
| ); | ||
| }; | ||
|
|
||
| const createDeps = (): DatadogRumInitDeps => ({ | ||
| clearIntervalFn: window.clearInterval, | ||
| getConsentStatus, | ||
| initializeRumFn: initializeRum, | ||
| isRumAlreadyInitializedFn: isRumAlreadyInitialized, | ||
| markRumAsInitializedFn: markRumAsInitialized, | ||
| setIntervalFn: window.setInterval, | ||
| startRumViewFn: startRumView, | ||
| syncTrackingConsentFn: syncTrackingConsent, | ||
| }); | ||
|
|
||
| export function DatadogRumInitComponent({ | ||
| deps, | ||
| pathname, | ||
| }: { | ||
| deps?: DatadogRumInitDeps; | ||
| pathname: string; | ||
| }) { | ||
| const [hasConsent, setHasConsent] = useState(false); | ||
| const runtimeDeps = deps ?? createDeps(); | ||
|
|
||
| useEffect(() => { | ||
| setHasConsent(runtimeDeps.getConsentStatus()); | ||
|
|
||
| const interval = runtimeDeps.setIntervalFn(() => { | ||
| setHasConsent(runtimeDeps.getConsentStatus()); | ||
| }, CONSENT_REFRESH_INTERVAL_MS); | ||
|
Comment on lines
+148
to
+150
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This effect always starts a 1s interval even when Useful? React with 👍 / 👎. |
||
|
|
||
| return () => { | ||
| runtimeDeps.clearIntervalFn(interval); | ||
| }; | ||
| }, [runtimeDeps]); | ||
|
|
||
| useEffect(() => { | ||
| runtimeDeps.syncTrackingConsentFn({ | ||
| datadogRum, | ||
| hasConsent, | ||
| hasDatadogConfiguration, | ||
| }); | ||
|
|
||
| const didInitialize = runtimeDeps.initializeRumFn({ | ||
| allowedTracingUrls: buildTracingOrigins(), | ||
| appName: DATADOG_APP_NAME, | ||
| applicationId: DATADOG_APPLICATION_ID, | ||
| clientToken: DATADOG_CLIENT_TOKEN, | ||
| datadogRum, | ||
| defaultPrivacyLevel: "mask-user-input", | ||
| env: DATADOG_ENV, | ||
| hasConsent, | ||
| hasDatadogConfiguration, | ||
| isAlreadyInitialized: runtimeDeps.isRumAlreadyInitializedFn(window), | ||
| service: DATADOG_SERVICE, | ||
| sessionReplaySampleRate: DATADOG_SESSION_REPLAY_ENABLED | ||
| ? Number.isFinite(DATADOG_SESSION_REPLAY_SAMPLE_RATE) | ||
| ? DATADOG_SESSION_REPLAY_SAMPLE_RATE | ||
| : 100 | ||
|
Comment on lines
+182
to
+185
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| : 0, | ||
| sessionSampleRate: Number.isFinite(DATADOG_SESSION_SAMPLE_RATE) | ||
| ? DATADOG_SESSION_SAMPLE_RATE | ||
| : 100, | ||
| site: DATADOG_SITE, | ||
| trackLongTasks: true, | ||
| trackResources: true, | ||
| trackUserInteractions: true, | ||
| trackViewsManually: true, | ||
| version: DATADOG_VERSION, | ||
| }); | ||
|
|
||
| if (didInitialize) { | ||
| runtimeDeps.markRumAsInitializedFn(window); | ||
| } | ||
| }, [hasConsent, runtimeDeps]); | ||
|
|
||
| useEffect(() => { | ||
| runtimeDeps.startRumViewFn({ | ||
| appName: DATADOG_APP_NAME, | ||
| datadogRum, | ||
| hasConsent, | ||
| hasDatadogConfiguration, | ||
| normalizedViewName: normalizePathnameForViewName(pathname), | ||
| service: DATADOG_SERVICE, | ||
| }); | ||
| }, [hasConsent, pathname, runtimeDeps]); | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| export default function DatadogRumInit() { | ||
| const pathname = usePathname() ?? "/"; | ||
|
|
||
| return <DatadogRumInitComponent pathname={pathname} />; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because
@datadog/browser-rumis imported at module load andDatadogRumInitis mounted in the root layout, every client still downloads and parses the RUM SDK even whenNEXT_PUBLIC_DATADOG_ENABLEDisfalseor consent is not granted. In those common no-op paths, this adds avoidable JavaScript cost to all page loads and undercuts the runtime gating in this change; loading the SDK dynamically only after the gate passes avoids that startup regression.Useful? React with 👍 / 👎.