Skip to content
Open
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,24 @@ Create a `.env.local` in your project root directory. It should contain the foll
```
NEXT_PUBLIC_GO_GETTA_PROD_URL=https://w6pkliozjh.execute-api.us-east-1.amazonaws.com/Stage
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=<insert a google maps API key here or contact us to get a development key>
NEXT_PUBLIC_DATADOG_APPLICATION_ID=<required for Browser RUM>
NEXT_PUBLIC_DATADOG_CLIENT_TOKEN=<required for Browser RUM>
NEXT_PUBLIC_DATADOG_SITE=datadoghq.com
NEXT_PUBLIC_DATADOG_SERVICE=yourpeer-frontend
NEXT_PUBLIC_DATADOG_ENV=production
NEXT_PUBLIC_DATADOG_VERSION=<set to release/version identifier>
NEXT_PUBLIC_DATADOG_APP_NAME=yourpeer.nyc
NEXT_PUBLIC_DATADOG_TRACING_ORIGINS=https://yourpeer.nyc,https://<api-gateway-domain>
NEXT_PUBLIC_DATADOG_ENABLED=false
NEXT_PUBLIC_DATADOG_REQUIRE_CONSENT=true
NEXT_PUBLIC_DATADOG_CONSENT_COOKIE_NAME=analytics_consent
NEXT_PUBLIC_DATADOG_SESSION_SAMPLE_RATE=100
NEXT_PUBLIC_DATADOG_SESSION_REPLAY_ENABLED=false
NEXT_PUBLIC_DATADOG_SESSION_REPLAY_SAMPLE_RATE=0
```

Datadog Browser RUM only initializes in the browser and is a no-op unless all of the following are true: `NEXT_PUBLIC_DATADOG_ENABLED=true`, required Datadog tokens are present, and consent is granted when `NEXT_PUBLIC_DATADOG_REQUIRE_CONSENT=true` (read from `NEXT_PUBLIC_DATADOG_CONSENT_COOKIE_NAME`).

Then run:

```
Expand Down
395 changes: 301 additions & 94 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"dependencies": {
"@aws-amplify/ui": "^6.7.0",
"@aws-amplify/ui-react": "^6.7.0",
"@datadog/browser-rum": "^6.30.1",
"@heroicons/react": "^2.1.4",
"@next/third-parties": "15.5.3",
"@radix-ui/react-alert-dialog": "^1.1.5",
Expand Down Expand Up @@ -65,6 +66,7 @@
"@types/node": "^20",
"@types/react": "19.1.13",
"@types/react-dom": "19.1.9",
"@types/react-test-renderer": "^19.1.0",
"@types/underscore": "^1.11.15",
"aws-cdk": "^2.170.0",
"aws-cdk-lib": "^2.170.0",
Expand All @@ -75,6 +77,7 @@
"eslint-config-next": "15.5.3",
"postcss": "^8",
"prettier": "^3.3.2",
"react-test-renderer": "^19.1.1",
"tailwind-scrollbar": "^3.1.0",
"tailwind-scrollbar-hide": "^1.1.7",
"tailwindcss": "^3.4.3",
Expand Down
2 changes: 2 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import Script from "next/script";
import { Toaster } from "sonner";
import QueryClientProvider from "@/app/QueryClientProvider";
import GTProdGuardScript from "@/components/gt-prod-guard-script";
import DatadogRumInit from "@/components/datadog-rum-init";
import { inter } from "./fonts";

export const viewport: Viewport = {
Expand Down Expand Up @@ -63,6 +64,7 @@ export default function RootLayout({
</LanguageTranslationProvider>
</CookiesProvider>
<Toaster />
<DatadogRumInit />
<GoogleAnalytics gaId={GOOGLE_ANALYTICS_MEASUREMENT_ID} />
<GoogleTagManager gtmId="GTM-ND2QBSQH" />
</body>
Expand Down
89 changes: 89 additions & 0 deletions src/components/datadog-rum-init.test.tsx
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();
});
177 changes: 177 additions & 0 deletions src/components/datadog-rum-init.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"use client";

import { datadogRum } from "@datadog/browser-rum";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Lazily load Browser RUM SDK behind consent/config checks

Because @datadog/browser-rum is imported at module load and DatadogRumInit is mounted in the root layout, every client still downloads and parses the RUM SDK even when NEXT_PUBLIC_DATADOG_ENABLED is false or 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 👍 / 👎.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Skip consent polling when RUM is disabled

This effect always starts a 1s interval even when hasDatadogConfiguration is false (for example the documented default NEXT_PUBLIC_DATADOG_ENABLED=false), so every client session still wakes up every second to re-read consent/cookies despite RUM being a no-op. In that disabled path this recurring timer does unnecessary background work on all page loads and tabs; short-circuiting the polling effect when configuration is missing avoids the avoidable CPU/battery overhead.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Fail closed on invalid session replay sample rate

When NEXT_PUBLIC_DATADOG_SESSION_REPLAY_ENABLED is true and NEXT_PUBLIC_DATADOG_SESSION_REPLAY_SAMPLE_RATE is malformed (for example an empty string or non-numeric value), this branch falls back to 100, which enables replay collection for all sessions instead of defaulting to a safe disabled value. In a misconfigured deploy this can unexpectedly capture far more session replay data (and cost) than intended, so the fallback should be conservative.

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} />;
}
Loading