fix(frontend): load Google Analytics only when a measurement ID is set (#15748)

### Why / What / How

**Why:** self-hosted installs report to our Google Analytics property by
default. `.env.default` shipped our measurement ID and `layout.tsx` had
it as a hardcoded fallback, so when someone running their own install
accepted analytics in the cookie banner, their users' page views landed
in our GA. This is part of the privacy project (goal: do it legally).

**What:** the GA measurement ID now has no default. The Google tag only
loads when a GA or Google Ads ID is set, so an install without either
loads nothing.

**How:** the ID comes from `NEXT_PUBLIC_GA_MEASUREMENT_ID` only (new
`environment.getGAMeasurementID()`). `SetupAnalytics` skips the Google
tag when there is no ID, and the init script leaves out the GA `config`
call when only Google Ads is set.

Linear: [SECRT-2714](https://linear.app/autogpt/issue/SECRT-2714)

> [!IMPORTANT]
> Vercel Production must have `NEXT_PUBLIC_GA_MEASUREMENT_ID` set (to
`G-FH2XK2W4GN`) before this deploys, otherwise production GA stops. It
is inlined at build time, so a redeploy is needed after changing it.

### Changes 🏗️

- `frontend/.env.default`: `NEXT_PUBLIC_GA_MEASUREMENT_ID` is empty,
with a comment explaining it.
- `frontend/src/app/layout.tsx`: removed the hardcoded `G-FH2XK2W4GN`
fallback.
- `frontend/src/services/environment/index.ts`: added
`getGAMeasurementID()`.
- `frontend/src/services/analytics/index.tsx`: no Google tag without a
GA or Ads ID; no GA `config` call without a GA ID.
- Tests in `consent-consumers.test.tsx` for: no IDs (no tag), Ads ID
only (Ads config only), GA ID (GA config).
- Configuration change: `NEXT_PUBLIC_GA_MEASUREMENT_ID` default changed
from our ID to empty. No other place in the repo (docker-compose, docs,
other env files) referenced the ID.

### Agents and large language models used

- Claude Code — Claude Opus 5.5

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] `pnpm vitest run src/services/analytics` passes (new cases for no
ID, Ads only, GA ID)
  - [x] `pnpm lint` and `pnpm types` pass
- [ ] After deploy: Production GA still receives page views (needs the
Vercel env var above)

#### For configuration changes:

- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Krzysztof CzerwinskiandClaude Opus 5.5 authored and GitHub committed 2026-10-20 13:48:28 +00:00
1 parent 9e09da8757
commit 7aa5db5817
5 files changed
+56 -10

No files matched your search

+5 -1
View File
@@ -90,7 +90,11 @@ NEXT_PUBLIC_TOKEN_DEVTOOL=true
# React render counter for `pnpm perf` (window.__renderCounter). Off unless
# exactly "1"; see docs/engineering/frontend-perf-measurement.md.
# NEXT_PUBLIC_RENDER_COUNTER=1
NEXT_PUBLIC_GA_MEASUREMENT_ID=G-FH2XK2W4GN
# Google Analytics 4 measurement ID (G-...). Leave empty unless you run your
# own GA property: the Google tag only loads when this (or the Google Ads ID
# below) is set. Inlined at build time.
NEXT_PUBLIC_GA_MEASUREMENT_ID=
# Google Ads conversion tracking (production only). The account's tag ID and
# one label per conversion action, e.g.
+1 -4
View File
@@ -125,10 +125,7 @@ export default async function RootLayout({
>
<SetupAnalytics
host={host}
ga={{
gaId:
process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID || "G-FH2XK2W4GN",
}}
ga={{ gaId: environment.getGAMeasurementID() }}
/>
<div className="flex min-h-screen flex-col items-stretch justify-items-stretch supports-[min-height:100dvh]:min-h-dvh">
{children}
@@ -15,20 +15,21 @@ vi.mock("next/script", () => ({
id,
src,
strategy,
dangerouslySetInnerHTML: _inline,
dangerouslySetInnerHTML: inline,
onLoad: _onLoad,
...attributes
}: {
id?: string;
src?: string;
strategy?: string;
dangerouslySetInnerHTML?: unknown;
dangerouslySetInnerHTML?: { __html?: string };
onLoad?: unknown;
}) => (
<div
data-testid={`script:${id ?? src}`}
data-src={src}
data-strategy={strategy}
data-inline={inline?.__html}
{...attributes}
/>
),
@@ -160,6 +161,42 @@ describe("with NEXT_PUBLIC_COOKIEBOT_CBID", () => {
});
});
describe("Google tag IDs", () => {
beforeEach(() => {
configureCookiebot();
installCookiebot({ statistics: true, marketing: true });
});
it("loads no Google tag when neither a GA nor a Google Ads ID is set", () => {
vi.stubEnv("NEXT_PUBLIC_GOOGLE_ADS_ID", "");
render(<SetupAnalytics host="platform.agpt.co" ga={{ gaId: "" }} />);
expect(queryScript("_custom-ga")).toBeNull();
expect(queryScript("_custom-ga-init")).toBeNull();
});
it("configures only Google Ads when the GA ID is not set", () => {
vi.stubEnv("NEXT_PUBLIC_GOOGLE_ADS_ID", "AW-TEST");
render(<SetupAnalytics host="platform.agpt.co" ga={{ gaId: "" }} />);
const init = queryScript("_custom-ga-init")?.getAttribute("data-inline");
expect(queryScript("_custom-ga")).not.toBeNull();
expect(init).toContain(`gtag('config', "AW-TEST"`);
expect(init).not.toContain(`gtag('config', ""`);
});
it("configures the GA property it is given", () => {
vi.stubEnv("NEXT_PUBLIC_GOOGLE_ADS_ID", "");
render(<SetupAnalytics host="platform.agpt.co" ga={GA} />);
const init = queryScript("_custom-ga-init")?.getAttribute("data-inline");
expect(init).toContain(`gtag('config', "G-TEST"`);
});
});
describe("ConsentWithdrawalReload", () => {
const reload = vi.fn<() => void>();
@@ -35,8 +35,9 @@ export function SetupAnalytics(props: SetupProps) {
return (
<>
{/* Google tag: GA4 + Google Ads */}
{googleTagEnabled ? (
{/* Google tag: GA4 + Google Ads. Nothing to load without an ID: a
self-hosted install must not report to anyone else's property */}
{googleTagEnabled && (gaId || adsID) ? (
<>
<GoogleConsentModeSync />
<Script
@@ -94,7 +95,9 @@ function buildGoogleTagInitScript({
`window['${DATA_LAYER_NAME}'] = window['${DATA_LAYER_NAME}'] || [];`,
`function gtag(){window['${DATA_LAYER_NAME}'].push(arguments);}`,
`gtag('js', new Date());`,
`gtag('config', ${JSON.stringify(GAID)}${debugMode ? ", { 'debug_mode': true }" : ""});`,
GAID
? `gtag('config', ${JSON.stringify(GAID)}${debugMode ? ", { 'debug_mode': true }" : ""});`
: "",
adsID
? `gtag('config', ${JSON.stringify(adsID)}, { 'allow_enhanced_conversions': true });`
: "",
@@ -87,6 +87,10 @@ function getLaunchDarklyClientId() {
return process.env.NEXT_PUBLIC_LAUNCHDARKLY_CLIENT_ID;
}
function getGAMeasurementID() {
return process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID?.trim() || "";
}
function getGoogleAdsID() {
return process.env.NEXT_PUBLIC_GOOGLE_ADS_ID || "";
}
@@ -195,6 +199,7 @@ export const environment = {
getRevision,
getPostHogCredentials,
getLaunchDarklyClientId,
getGAMeasurementID,
getGoogleAdsID,
getGoogleAdsConversionLabels,
getCookiebotCBID,