Skip to Content
ProvidersGoogle Analytics 4

Google Analytics 4

Category: Analytics
Integration type: Tenant OAuth (stored in integrations table)
External SDK: @google-analytics/data (GA4 Data API)


Purpose

Google Analytics 4 (GA4) provides website traffic and conversion data from the tenant’s property. This data is used for:

  • Report Writer — overall traffic section (sessions, users, bounce rate, goal completions)
  • Anomaly Detector — detects unusual traffic drops or spikes
  • Dashboard — live traffic metrics on D4 (SEO Performance) and D3 (Overview)
  • Ads Analyst — attribution data linking ad spend to conversions

Tenants connect their GA4 property via the same Google OAuth flow used for Search Console. The required scope is different (Analytics readonly), so a separate connection is needed unless combined in a single consent screen.


Config Structure

OAuth flow

scope: https://www.googleapis.com/auth/analytics.readonly Stored in integrations: provider: 'google_analytics' api_key: encrypt(refresh_token) metadata: { propertyId, accessToken, accessTokenExpiresAt, accountId }

Integration record

interface GA4IntegrationMetadata { propertyId: string; // GA4 property ID (numeric, e.g. "123456789") accountId: string; // GA4 account ID accessToken: string; accessTokenExpiresAt: string; }

Platform OAuth credentials (env vars)

GOOGLE_CLIENT_ID=xxxxxxxx.apps.googleusercontent.com GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxxxxxxx GOOGLE_REDIRECT_URI_GA=https://app.leadmetrics.io/api/auth/callback/google-analytics

Note: GA4 and Google Search Console use different redirect URIs to allow separate OAuth connections (different scopes).


OAuth Setup Flow

Tenant clicks "Connect Google Analytics" in Dashboard → Settings → Integrations Platform redirects to Google OAuth consent screen scope: https://www.googleapis.com/auth/analytics.readonly Google redirects back with authorization code Platform exchanges code for access_token + refresh_token Platform calls Admin API → lists GA4 accounts + properties Tenant selects their GA4 property from a dropdown Stored in integrations: provider: 'google_analytics' api_key: encrypt(refresh_token) metadata: { propertyId, accountId, accessToken, accessTokenExpiresAt }

Integration Pattern

Token refresh (getCredentials)

Access tokens expire after 1 hour. getCredentials() is called before every API call:

import { OAuth2Client } from 'google-auth-library'; async function getCredentials( integration: Integration, ): Promise<{ client_email?: string; private_key?: string; access_token?: string }> { const meta = integration.metadata as GA4IntegrationMetadata; if (isTokenExpired(meta.accessTokenExpiresAt)) { const oauth2Client = new OAuth2Client( process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, process.env.GOOGLE_REDIRECT_URI_GA, ); oauth2Client.setCredentials({ refresh_token: decrypt(integration.api_key) }); const { credentials } = await oauth2Client.refreshAccessToken(); const newMeta: GA4IntegrationMetadata = { ...meta, accessToken: credentials.access_token!, accessTokenExpiresAt: new Date(credentials.expiry_date!).toISOString(), }; await db.update(integrations) .set({ metadata: newMeta }) .where(eq(integrations.id, integration.id)); return { access_token: credentials.access_token! }; } return { access_token: meta.accessToken }; } function isTokenExpired(expiresAt: string): boolean { return Date.now() >= new Date(expiresAt).getTime() - 5 * 60 * 1000; // 5-min buffer }

Querying GA4 Data API

import { BetaAnalyticsDataClient } from '@google-analytics/data'; class GoogleAnalyticsTool { async runReport( integration: Integration, options: { startDate: string; // YYYY-MM-DD or 'NdaysAgo' endDate: string; // YYYY-MM-DD or 'today' dimensions: string[]; // e.g. ['date', 'sessionSource', 'sessionMedium'] metrics: string[]; // e.g. ['sessions', 'users', 'bounceRate'] limit?: number; }, ): Promise<GA4ReportRow[]> { const meta = integration.metadata as GA4IntegrationMetadata; const credentials = await this.getCredentials(integration); const client = new BetaAnalyticsDataClient({ credentials }); const [response] = await client.runReport({ property: `properties/${meta.propertyId}`, dateRanges: [{ startDate: options.startDate, endDate: options.endDate }], dimensions: options.dimensions.map(name => ({ name })), metrics: options.metrics.map(name => ({ name })), limit: options.limit ?? 1000, }); const dimensionHeaders = response.dimensionHeaders?.map(h => h.name ?? '') ?? []; const metricHeaders = response.metricHeaders?.map(h => h.name ?? '') ?? []; return (response.rows ?? []).map(row => { const dims: Record<string, string> = {}; const mets: Record<string, number> = {}; row.dimensionValues?.forEach((v, i) => { dims[dimensionHeaders[i]] = v.value ?? ''; }); row.metricValues?.forEach((v, i) => { mets[metricHeaders[i]] = Number(v.value ?? 0); }); return { dimensions: dims, metrics: mets }; }); } }

Common report queries

Monthly traffic overview:

await ga4.runReport(integration, { startDate: '2026-03-01', endDate: '2026-03-31', dimensions: ['date'], metrics: ['sessions', 'activeUsers', 'bounceRate', 'averageSessionDuration'], });

Traffic by channel:

await ga4.runReport(integration, { startDate: '30daysAgo', endDate: 'today', dimensions: ['sessionDefaultChannelGrouping'], metrics: ['sessions', 'conversions', 'totalRevenue'], });

Goal completions (conversions):

await ga4.runReport(integration, { startDate: '30daysAgo', endDate: 'today', dimensions: ['eventName'], metrics: ['eventCount', 'conversions'], });

Higher-level helper methods

These convenience wrappers are called by the Report Writer control plane and the Anomaly Detector worker. They use runReport() internally and return typed, pre-shaped objects.

/** * Fetch monthly traffic overview with month-over-month comparison. * Used by Report Writer to build the GA4 traffic section. */ async getMonthlyOverview( integration: Integration, options: { month: string }, // e.g. '2026-03' ): Promise<{ sessions: MetricWithComparison; users: MetricWithComparison; newUsers: MetricWithComparison; bounceRate: MetricWithComparison; avgSessionDuration: MetricWithComparison; conversions: MetricWithComparison; conversionRate: MetricWithComparison; }> { const [year, mon] = options.month.split('-').map(Number); const prevMonth = mon === 1 ? `${year - 1}-12` : `${year}-${String(mon - 1).padStart(2, '0')}`; const toRange = (m: string) => ({ start: `${m}-01`, end: lastDayOf(m), // helper: returns e.g. '2026-03-31' }); const curr = toRange(options.month); const prev = toRange(prevMonth); const [currRows, prevRows] = await Promise.all([ this.runReport(integration, { startDate: curr.start, endDate: curr.end, dimensions: [], metrics: ['sessions', 'activeUsers', 'newUsers', 'bounceRate', 'averageSessionDuration', 'conversions', 'sessionConversionRate'], limit: 1, }), this.runReport(integration, { startDate: prev.start, endDate: prev.end, dimensions: [], metrics: ['sessions', 'activeUsers', 'newUsers', 'bounceRate', 'averageSessionDuration', 'conversions', 'sessionConversionRate'], limit: 1, }), ]); const c = currRows[0]?.metrics ?? {}; const p = prevRows[0]?.metrics ?? {}; const compare = (key: string): MetricWithComparison => ({ current: c[key] ?? 0, previous: p[key] ?? 0, changePct: p[key] ? ((c[key] - p[key]) / p[key]) * 100 : null, }); return { sessions: compare('sessions'), users: compare('activeUsers'), newUsers: compare('newUsers'), bounceRate: compare('bounceRate'), avgSessionDuration: compare('averageSessionDuration'), conversions: compare('conversions'), conversionRate: compare('sessionConversionRate'), }; } /** * Traffic breakdown by channel grouping. * Used by Report Writer for the channel attribution section. */ async getChannelBreakdown( integration: Integration, options: { startDate: string; endDate: string }, ): Promise<{ channel: string; sessions: number; conversions: number }[]> { const rows = await this.runReport(integration, { startDate: options.startDate, endDate: options.endDate, dimensions: ['sessionDefaultChannelGrouping'], metrics: ['sessions', 'conversions'], }); return rows .map(r => ({ channel: r.dimensions['sessionDefaultChannelGrouping'] ?? 'Unknown', sessions: r.metrics['sessions'] ?? 0, conversions: r.metrics['conversions'] ?? 0, })) .sort((a, b) => b.sessions - a.sessions); } /** * Top landing pages by sessions and conversion rate. * Used by Report Writer for the content performance section. */ async getTopLandingPages( integration: Integration, options: { startDate: string; endDate: string; limit?: number }, ): Promise<{ page: string; sessions: number; conversionRate: number }[]> { const rows = await this.runReport(integration, { startDate: options.startDate, endDate: options.endDate, dimensions: ['landingPage'], metrics: ['sessions', 'sessionConversionRate'], limit: options.limit ?? 10, }); return rows.map(r => ({ page: r.dimensions['landingPage'] ?? '/', sessions: r.metrics['sessions'] ?? 0, conversionRate: r.metrics['sessionConversionRate'] ?? 0, })); } /** * Conversion events configured in the GA4 property. * Used by Report Writer for goal completions section. */ async getGoalCompletions( integration: Integration, options: { startDate: string; endDate: string }, ): Promise<{ goalName: string; completions: number }[]> { const rows = await this.runReport(integration, { startDate: options.startDate, endDate: options.endDate, dimensions: ['eventName'], metrics: ['conversions'], }); return rows .filter(r => r.metrics['conversions'] > 0) .map(r => ({ goalName: r.dimensions['eventName'] ?? 'unknown', completions: r.metrics['conversions'] ?? 0, })) .sort((a, b) => b.completions - a.completions); } /** * Real-time active users in the last 30 minutes. * Used by the Dashboard live traffic widget. */ async getRealtimeActiveUsers(integration: Integration): Promise<number> { const meta = integration.metadata as GA4IntegrationMetadata; const credentials = await getCredentials(integration); const client = new BetaAnalyticsDataClient({ credentials }); const [response] = await client.runRealtimeReport({ property: `properties/${meta.propertyId}`, metrics: [{ name: 'activeUsers' }], }); return Number(response.rows?.[0]?.metricValues?.[0]?.value ?? 0); } /** * List all GA4 properties the OAuth token has access to. * Used during the integration setup step — tenant selects their property. */ async listProperties(integration: Integration): Promise<{ accountId: string; accountName: string; propertyId: string; propertyName: string; websiteUrl?: string; }[]> { const credentials = await getCredentials(integration); const analyticsAdmin = google.analyticsadmin({ version: 'v1beta', auth: buildOAuth2(credentials) }); const accountsResponse = await analyticsAdmin.accounts.list(); const results: Awaited<ReturnType<GoogleAnalyticsTool['listProperties']>> = []; for (const account of accountsResponse.data.accounts ?? []) { const propsResponse = await analyticsAdmin.properties.list({ filter: `parent:${account.name}`, }); for (const prop of propsResponse.data.properties ?? []) { results.push({ accountId: account.name?.replace('accounts/', '') ?? '', accountName: account.displayName ?? '', propertyId: prop.name?.replace('properties/', '') ?? '', propertyName: prop.displayName ?? '', websiteUrl: prop.websiteUrl ?? undefined, }); } } return results; } async verify(integration: Integration): Promise<void> { const meta = integration.metadata as GA4IntegrationMetadata; const credentials = await getCredentials(integration); const client = new BetaAnalyticsDataClient({ credentials }); const [md] = await client.getMetadata({ name: `properties/${meta.propertyId}/metadata` }); if (!md) throw new Error(`GA4 property ${meta.propertyId} not accessible`); } }

GA4 vs Universal Analytics

Leadmetrics supports only GA4 — Universal Analytics (UA) was sunset by Google on July 1, 2024. All tenant properties must have been migrated to GA4. The integration UI warns tenants if they attempt to connect a UA property ID (format: UA-XXXXXX-X vs GA4’s numeric format).


Test Cases

Unit tests (packages/tools/src/google-analytics.test.ts)

TestApproach
runReport() calls client.runReport with correct paramsMock BetaAnalyticsDataClient; assert property, dateRanges, dimensions, metrics
runReport() maps dimension headers to row valuesFeed mock response; assert dimensions object keyed by header name
runReport() maps metric headers to numeric valuesAssert metric values are Number-parsed
runReport() returns empty array when rows nullMock { rows: null }; assert []
getCredentials() refreshes token when within 5-min expiry windowMock expired accessTokenExpiresAt; assert refreshAccessToken called and DB updated
getCredentials() skips refresh when token still validMock future expiry; assert refreshAccessToken NOT called
getMonthlyOverview() computes changePct correctlyMock curr=100 prev=80; assert changePct ≈ 25
getMonthlyOverview() returns changePct: null when previous is 0Mock prev=0; assert null not NaN
getChannelBreakdown() sorts by sessions descendingFeed unsorted mock rows; assert sorted output
getTopLandingPages() defaults limit to 10Mock runReport; assert limit: 10 passed
getGoalCompletions() filters out rows with 0 conversionsFeed rows with mix of 0 and >0; assert 0-conversion rows absent
getRealtimeActiveUsers() returns 0 when rows absentMock { rows: undefined }; assert 0 returned
listProperties() flattens accounts + propertiesMock 2 accounts × 2 properties; assert 4 results
verify() throws when property not accessibleMock getMetadata returns null; assert throws
Throws on invalid property IDMock client.runReport throws 404; assert propagated

Integration tests

TestApproach
Monthly sessions for known test propertyUse test credentials + test GA4 property; assert sessions > 0
Channel breakdown has expected groupsAssert Organic Search, Direct appear in results

© 2026 Leadmetrics — Internal use only