From e3e0982348c8ee52063c9189fe5f9f4f4960ae1a Mon Sep 17 00:00:00 2001 From: Chris Bailey Date: Wed, 15 Jul 2026 13:21:48 -0400 Subject: [PATCH] add properties to GA4 destination --- .gitignore | 1 + .../__tests__/ga4-destination.test.ts | 225 ++++++++++++++++++ .../src/functions/ga4-destination.ts | 110 ++++++++- 3 files changed, 328 insertions(+), 8 deletions(-) create mode 100644 libs/destination-functions/__tests__/ga4-destination.test.ts diff --git a/.gitignore b/.gitignore index b1d3435e4..40dcee952 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules **/node_modules .idea .vscode +.kube .env.local .turbo .pnpm-debug.log diff --git a/libs/destination-functions/__tests__/ga4-destination.test.ts b/libs/destination-functions/__tests__/ga4-destination.test.ts new file mode 100644 index 000000000..4308dba85 --- /dev/null +++ b/libs/destination-functions/__tests__/ga4-destination.test.ts @@ -0,0 +1,225 @@ +import type { AnalyticsServerEvent } from "@jitsu/protocols/analytics"; +import { describe, expect, test, vi } from "vitest"; +import type { Ga4Credentials } from "../src/meta"; +import Ga4Destination from "../src/functions/ga4-destination"; +import { testJitsuFunction, type TestOptions } from "./lib/testing-lib"; + +async function sendEvent(context: AnalyticsServerEvent["context"], ua?: Record) { + let request: Record | undefined; + const event: AnalyticsServerEvent = { + type: "track", + event: "Test Event", + messageId: "message-id", + anonymousId: "anonymous-id", + timestamp: "2026-07-14T12:00:00.000Z", + context, + }; + + await Ga4Destination(event, { + props: { + apiSecret: "secret", + measurementId: "G-TEST123", + url: "https://www.google-analytics.com/mp/collect", + events: "", + }, + ua, + fetch: async (_url: string, opts?: { body?: string | Uint8Array }) => { + request = JSON.parse(opts?.body as string); + return new Response(null, { status: 204 }); + }, + log: { + info: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }, + } as any); + + return request; +} + +describe("GA4 request context", () => { + test("sends explicit device context and the raw user agent", async () => { + const request = await sendEvent( + { + userAgent: "raw user agent", + locale: "en-US", + screen: { width: 1179, height: 2556 }, + os: { name: "iOS", version: "17.5" }, + device: { type: "mobile", model: "iPhone 15 Pro", manufacturer: "Apple" }, + }, + { + browser: { name: "Mobile Safari", version: "17.5" }, + os: { name: "Parsed OS", version: "1" }, + device: { type: "desktop", model: "Parsed Model", vendor: "Parsed Brand" }, + } + ); + + expect(request).toMatchObject({ + user_agent: "raw user agent", + device: { + category: "mobile", + language: "en-US", + screen_resolution: "1179x2556", + operating_system: "iOS", + operating_system_version: "17.5", + model: "iPhone 15 Pro", + brand: "Apple", + browser: "Mobile Safari", + browser_version: "17.5", + }, + }); + }); + + test("fills missing structured fields from the parsed user agent", async () => { + const request = await sendEvent( + { userAgent: "fallback raw user agent" }, + { + browser: { name: "Chrome", version: "136.0.7103.60" }, + os: { name: "Android", version: "14" }, + device: { type: "mobile", model: "Pixel 9 Pro", vendor: "Google" }, + } + ); + + expect(request).toMatchObject({ + user_agent: "fallback raw user agent", + device: { + category: "mobile", + operating_system: "Android", + operating_system_version: "14", + model: "Pixel 9 Pro", + brand: "Google", + browser: "Chrome", + browser_version: "136.0.7103.60", + }, + }); + }); + + test("sends structured location and IP override", async () => { + const request = await sendEvent({ + ip: "203.0.113.10", + geo: { + city: { name: "New York" }, + country: { code: "US" }, + region: { code: "NY" }, + }, + } as any); + + expect(request).toMatchObject({ + user_location: { + city: "New York", + region_id: "US-NY", + country_id: "US", + }, + ip_override: "203.0.113.10", + }); + }); + + test("preserves an ISO 3166-2 region code", async () => { + const request = await sendEvent({ + geo: { + country: { code: "CA" }, + region: { code: "CA-BC" }, + }, + } as any); + + expect(request?.user_location).toEqual({ + country_id: "CA", + region_id: "CA-BC", + }); + }); + + test("normalizes boolean GA consent preferences", async () => { + const request = await sendEvent({ + consent: { + categoryPreferences: { + ad_user_data: true, + ad_personalization: false, + }, + }, + }); + + expect(request?.consent).toEqual({ + ad_user_data: "GRANTED", + ad_personalization: "DENIED", + }); + }); + + test("passes through valid GA consent states", async () => { + const request = await sendEvent({ + consent: { + categoryPreferences: { + ad_user_data: "DENIED", + ad_personalization: "GRANTED", + }, + }, + }); + + expect(request?.consent).toEqual({ + ad_user_data: "DENIED", + ad_personalization: "GRANTED", + }); + }); + + test("omits empty device metadata and incomplete screen resolution", async () => { + const request = await sendEvent({ + screen: { width: 1179 }, + ip: "", + geo: {}, + consent: { + categoryPreferences: { + analytics: true, + ad_user_data: "yes", + ad_personalization: null, + }, + }, + } as any); + + expect(request).not.toHaveProperty("device"); + expect(request).not.toHaveProperty("user_agent"); + expect(request).not.toHaveProperty("user_location"); + expect(request).not.toHaveProperty("ip_override"); + expect(request).not.toHaveProperty("consent"); + }); +}); + +// Live test. Set TEST_GA4_DESTINATION_CONFIG to JSON credentials, then run: +// TEST_GA4_DESTINATION_CONFIG='{ "apiSecret": "...", "measurementId": "G-..." }' pnpm --filter @jitsu/destination-functions exec vitest run __tests__/ga4-destination.test.ts -t ga4-destination-integration +test("ga4-destination-integration", async () => { + if (!process.env.TEST_GA4_DESTINATION_CONFIG) { + console.log("TEST_GA4_DESTINATION_CONFIG is not set, skipping test"); + return; + } + + const now = Date.now(); + const event = { + type: "track", + event: "Jitsu GA4 Destination Live Test", + messageId: `ga4-live-test-${now}`, + anonymousId: `jitsu-ga4-live-test-${now}`, + timestamp: new Date(now).toISOString(), + context: { + ip: "203.0.113.10", + userAgent: "Jitsu GA4 Destination Live Test/1.0", + locale: "en-US", + screen: { width: 1280, height: 720 }, + os: { name: "Test OS", version: "1.0" }, + device: { type: "desktop", manufacturer: "Jitsu", model: "GA4 Live Test" }, + geo: { + city: { name: "New York" }, + country: { code: "US", name: "United States", isEU: false }, + region: { code: "NY", name: "New York" }, + }, + consent: { + categoryPreferences: { ad_user_data: false, ad_personalization: false }, + }, + }, + } as AnalyticsServerEvent; + + const opts: TestOptions = { + func: Ga4Destination, + configEnvVar: "TEST_GA4_DESTINATION_CONFIG", + events: [event], + }; + await testJitsuFunction(opts); +}, 60000); diff --git a/libs/destination-functions/src/functions/ga4-destination.ts b/libs/destination-functions/src/functions/ga4-destination.ts index 80fcb8ff4..e6d021c31 100644 --- a/libs/destination-functions/src/functions/ga4-destination.ts +++ b/libs/destination-functions/src/functions/ga4-destination.ts @@ -1,8 +1,8 @@ -import { JitsuFunction } from "@jitsu/protocols/functions"; +import { createFilter, eventTimeSafeMs } from "@jitsu/core-functions-lib"; import { RetryError } from "@jitsu/functions-lib"; import type { AnalyticsServerEvent } from "@jitsu/protocols/analytics"; +import { FullContext, JitsuFunction } from "@jitsu/protocols/functions"; import { Ga4Credentials } from "../meta"; -import { createFilter, eventTimeSafeMs } from "@jitsu/core-functions-lib"; const ReservedUserProperties = [ "first_open_time", @@ -27,9 +27,39 @@ type Ga4Request = { user_id?: string; timestamp_micros: number; user_properties?: Record; + device?: Ga4Device; + user_agent?: string; + user_location?: Ga4UserLocation; + ip_override?: string; + consent?: Ga4Consent; events: Ga4Event[]; }; +type Ga4ConsentState = "GRANTED" | "DENIED"; + +type Ga4Consent = { + ad_user_data?: Ga4ConsentState; + ad_personalization?: Ga4ConsentState; +}; + +type Ga4UserLocation = { + city?: string; + region_id?: string; + country_id?: string; +}; + +type Ga4Device = { + category?: string; + language?: string; + screen_resolution?: string; + operating_system?: string; + operating_system_version?: string; + model?: string; + brand?: string; + browser?: string; + browser_version?: string; +}; + type Ga4Event = { name: string; params?: Record; @@ -62,20 +92,26 @@ type Ga4Item = { }; function getItems(event: AnalyticsServerEvent): Ga4Item[] { - if (!event.properties) return []; + if (!event.properties) { + return []; + } let items: Ga4Item[] = []; if (Array.isArray(event.properties.products)) { items = event.properties.products.map(getItem).filter(item => item != undefined) as Ga4Item[]; } else { const item = getItem(event.properties); - if (item) items.push(item); + if (item) { + items.push(item); + } } return items; } function getItem(product: any): Ga4Item | undefined { - if (!product.product_id || !product.name) return undefined; + if (!product.product_id || !product.name) { + return undefined; + } return { item_id: product.product_id, item_name: product.name, @@ -104,7 +140,7 @@ function getItem(product: any): Ga4Item | undefined { } function getUserProperties(event: AnalyticsServerEvent): Record { - let userProperties: Record = {}; + const userProperties: Record = {}; // const ua = parser(event.context?.userAgent); // userProperties["platform"] = { value: "web" }; // userProperties["os"] = { value: ua.os?.name }; @@ -145,6 +181,59 @@ function getSessionId(event: AnalyticsServerEvent, measurementId: string): strin return event.context?.clientIds?.ga4?.sessionIds?.[measurementId.replace("G-", "")]; } +function getDevice(event: AnalyticsServerEvent, ctx: FullContext): Ga4Device | undefined { + const screen = event.context?.screen; + const device: Ga4Device = { + category: event.context?.device?.type || ctx.ua?.device?.type, + language: event.context?.locale, + screen_resolution: screen?.width != null && screen?.height != null ? `${screen.width}x${screen.height}` : undefined, + operating_system: event.context?.os?.name || ctx.ua?.os?.name, + operating_system_version: event.context?.os?.version || ctx.ua?.os?.version, + model: event.context?.device?.model || ctx.ua?.device?.model, + brand: event.context?.device?.manufacturer || ctx.ua?.device?.vendor, + browser: ctx.ua?.browser?.name, + browser_version: ctx.ua?.browser?.version, + }; + const definedDevice = Object.fromEntries( + Object.entries(device).filter(([, value]) => value !== undefined && value !== null && value !== "") + ); + return Object.keys(definedDevice).length > 0 ? definedDevice : undefined; +} + +function getUserLocation(event: AnalyticsServerEvent): Ga4UserLocation | undefined { + const geo = event.context?.geo; + const countryId = geo?.country?.code; + const regionCode = geo?.region?.code; + const userLocation: Ga4UserLocation = { + city: geo?.city?.name, + country_id: countryId, + region_id: regionCode && countryId && !regionCode.includes("-") ? `${countryId}-${regionCode}` : regionCode, + }; + const definedLocation = Object.fromEntries( + Object.entries(userLocation).filter(([, value]) => value !== undefined && value !== null && value !== "") + ); + return Object.keys(definedLocation).length > 0 ? definedLocation : undefined; +} + +function getConsentState(value: any): Ga4ConsentState | undefined { + if (value === true) { + return "GRANTED"; + } + if (value === false) { + return "DENIED"; + } + return value === "GRANTED" || value === "DENIED" ? value : undefined; +} + +function getConsent(event: AnalyticsServerEvent): Ga4Consent | undefined { + const preferences = event.context?.consent?.categoryPreferences; + const consent: Ga4Consent = { + ad_user_data: getConsentState(preferences?.ad_user_data), + ad_personalization: getConsentState(preferences?.ad_personalization), + }; + return consent.ad_user_data || consent.ad_personalization ? consent : undefined; +} + function pageViewEvent(event: AnalyticsServerEvent): Ga4Event { const pageProperties = { ...(event.context?.page || {}), @@ -320,14 +409,14 @@ const Ga4Destination: JitsuFunction = asyn return; } } - let gaRequest: Ga4Request | undefined = undefined; + let gaRequest: Ga4Request | undefined; try { const clientId = getClientId(event); const sessionId = getSessionId(event, ctx.props.measurementId); const firebaseAppInstanceId = getFirebaseAppInstanceId(event); const measurementId = ctx.props.measurementId || ""; let query = `api_secret=${ctx.props.apiSecret}`; - let idPart = {} as any; + const idPart = {} as any; if (measurementId.match(/^\d:\d+:\w+:\w+$/)) { if (!firebaseAppInstanceId) { ctx.log.info(`Ga4: no app instance id found for event ID: ${event.messageId}`); @@ -374,6 +463,11 @@ const Ga4Destination: JitsuFunction = asyn user_id: event.userId, timestamp_micros: eventTimeSafeMs(event) * 1000, user_properties: userProperties, + device: getDevice(event, ctx), + user_agent: event.context?.userAgent, + user_location: getUserLocation(event), + ip_override: event.context?.ip?.split(",")[0] || undefined, + consent: getConsent(event), events: sessionId ? events.map(e => ({ ...e, params: { ...e.params, session_id: sessionId } })) : events, };