Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/fix-ga4-gtag-stub.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@junctionjs/destination-ga4": patch
---

Fix gtag.js integration: use Arguments object instead of Array for dataLayer

The gtag stub function was using an arrow function with rest parameters, which
pushed plain Arrays to the dataLayer. gtag.js silently ignores array entries —
it expects the Arguments object. Switched to a named function declaration using
`arguments` to match Google's official snippet.

Also added `gtag("consent", "default", {...})` call before `gtag("config", ...)`
when consent mode is enabled. Without this, gtag.js doesn't know consent mode
is active and consent state is never communicated to Google.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,6 @@ yarn-debug.log*
yarn-error.log*
.vercel
.env*.local

# Worktrees
.worktrees/
1 change: 0 additions & 1 deletion apps/demo/lib/demo-sink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@ function noopDestination(name: string, consent: string[]): Destination<Record<st
};
}

export const simulatedGA4 = noopDestination("ga4", ["analytics"]);
export const simulatedAmplitude = noopDestination("amplitude", ["analytics"]);
export const simulatedMeta = noopDestination("meta", ["marketing"]);

Expand Down
24 changes: 19 additions & 5 deletions apps/demo/lib/junction-config.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { readConsentCookie } from "@/components/consent/use-consent-cookie";
import type { CollectorConfig } from "@junctionjs/core";
import { ga4 } from "@junctionjs/destination-ga4";
import { contracts } from "./contracts";
import { demoSink, simulatedAmplitude, simulatedGA4, simulatedMeta } from "./demo-sink";
import { demoSink, simulatedAmplitude, simulatedMeta } from "./demo-sink";

/**
* Junction collector configuration for the Orbit Supply demo.
*
* The demo-sink (exempt) always receives events for visualization.
* Simulated GA4/Amplitude/Meta destinations are consent-gated so the
* GA4 is a real destination gated on NEXT_PUBLIC_GA4_MEASUREMENT_ID env var.
* Simulated Amplitude/Meta destinations are consent-gated so the
* consent queue actually works: events queue while pending, flush on
* grant, and drop on deny.
*/
Expand All @@ -32,9 +34,21 @@ export const junctionConfig: CollectorConfig = {
consent: ["exempt"],
enabled: true,
},
// Simulated consent-gated destinations — no-op send, but their consent
// requirements drive the queue/flush/drop behavior visible in the demo.
{ destination: simulatedGA4, config: {}, enabled: true },
// Real GA4 — gated on env var so the demo works without it.
...(process.env.NEXT_PUBLIC_GA4_MEASUREMENT_ID
? [
{
destination: ga4,
config: {
measurementId: process.env.NEXT_PUBLIC_GA4_MEASUREMENT_ID,
sendPageView: false,
consentMode: true,
},
consent: ["analytics"],
enabled: true,
},
]
: []),
{ destination: simulatedAmplitude, config: {}, enabled: true },
{ destination: simulatedMeta, config: {}, enabled: true },
],
Expand Down
35 changes: 27 additions & 8 deletions packages/destination-ga4/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,12 +196,17 @@ function loadGtag(measurementId: string, gtagUrl?: string): void {
if (typeof window === "undefined") return;
if (typeof (window as any).gtag === "function") return;

// Initialize dataLayer
// Initialize dataLayer and gtag stub matching Google's official snippet exactly.
// The stub MUST use `arguments` (not rest params) — gtag.js expects Arguments
// objects in the dataLayer queue, not plain arrays. Arrow functions and rest
// params produce arrays which gtag.js silently ignores.
(window as any).dataLayer = (window as any).dataLayer || [];
(window as any).gtag = (...args: any[]) => {
(window as any).dataLayer.push(args);
};
(window as any).gtag("js", new Date());
function gtagStub(..._: unknown[]) {
// biome-ignore lint/style/noArguments: gtag.js requires the Arguments object, not an Array
(window as any).dataLayer.push(arguments);
}
(window as any).gtag = gtagStub;
gtagStub("js", new Date());

// Load script
const script = document.createElement("script");
Expand Down Expand Up @@ -231,19 +236,33 @@ export function createGA4(): Destination<GA4Config> {
throw new Error("[Junction:GA4] measurementId is required");
}

consentModeEnabled = config.consentMode === true;

// Client-side: load gtag.js if needed
if (typeof window !== "undefined" && config.loadScript !== false) {
loadGtag(config.measurementId, config.gtagUrl);

// Set consent defaults BEFORE config — required by Google's consent mode v2.
// Without this, gtag doesn't know consent mode is active.
if (consentModeEnabled) {
(window as any).gtag("consent", "default", {
ad_storage: "denied",
analytics_storage: "denied",
ad_user_data: "denied",
ad_personalization: "denied",
personalization_storage: "denied",
functionality_storage: "granted",
security_storage: "granted",
});
}

// Configure GA4
const gtagConfig: Record<string, unknown> = {
send_page_view: config.sendPageView ?? false,
};

(window as any).gtag?.("config", config.measurementId, gtagConfig);
(window as any).gtag("config", config.measurementId, gtagConfig);
}

consentModeEnabled = config.consentMode === true;
},

transform(event: JctEvent, config: GA4Config) {
Expand Down
Loading