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-analyticsNote: 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)
| Test | Approach |
|---|---|
runReport() calls client.runReport with correct params | Mock BetaAnalyticsDataClient; assert property, dateRanges, dimensions, metrics |
runReport() maps dimension headers to row values | Feed mock response; assert dimensions object keyed by header name |
runReport() maps metric headers to numeric values | Assert metric values are Number-parsed |
runReport() returns empty array when rows null | Mock { rows: null }; assert [] |
getCredentials() refreshes token when within 5-min expiry window | Mock expired accessTokenExpiresAt; assert refreshAccessToken called and DB updated |
getCredentials() skips refresh when token still valid | Mock future expiry; assert refreshAccessToken NOT called |
getMonthlyOverview() computes changePct correctly | Mock curr=100 prev=80; assert changePct ≈ 25 |
getMonthlyOverview() returns changePct: null when previous is 0 | Mock prev=0; assert null not NaN |
getChannelBreakdown() sorts by sessions descending | Feed unsorted mock rows; assert sorted output |
getTopLandingPages() defaults limit to 10 | Mock runReport; assert limit: 10 passed |
getGoalCompletions() filters out rows with 0 conversions | Feed rows with mix of 0 and >0; assert 0-conversion rows absent |
getRealtimeActiveUsers() returns 0 when rows absent | Mock { rows: undefined }; assert 0 returned |
listProperties() flattens accounts + properties | Mock 2 accounts × 2 properties; assert 4 results |
verify() throws when property not accessible | Mock getMetadata returns null; assert throws |
| Throws on invalid property ID | Mock client.runReport throws 404; assert propagated |
Integration tests
| Test | Approach |
|---|---|
| Monthly sessions for known test property | Use test credentials + test GA4 property; assert sessions > 0 |
| Channel breakdown has expected groups | Assert Organic Search, Direct appear in results |
Related
- Google Search Console Provider — organic keyword performance
- Report Writer Agent — GA4 data in monthly reports
- Google Ads Provider — ad attribution data
- Tool Integration Layer — OAuth management