482 lines
15 KiB
TypeScript
482 lines
15 KiB
TypeScript
/* eslint-disable max-lines -- one client module per Google integration (gscClient precedent); GA4 spans the Admin and Data APIs */
|
|
import { z } from "zod";
|
|
import { getAuth } from "@/lib/auth";
|
|
import {
|
|
Ga4AdminApiError,
|
|
Ga4DataApiError,
|
|
Ga4MalformedResponseError,
|
|
Ga4TokenError,
|
|
} from "@/server/lib/ga4Errors";
|
|
import { GA4_OAUTH_PROVIDER_ID } from "@/shared/ga4";
|
|
|
|
const GA4_ADMIN_API_BASE = "https://analyticsadmin.googleapis.com/v1beta";
|
|
const GA4_ADMIN_ALPHA_API_BASE =
|
|
"https://analyticsadmin.googleapis.com/v1alpha";
|
|
const GOOGLE_USERINFO_URL = "https://openidconnect.googleapis.com/v1/userinfo";
|
|
const GA4_DATA_API_BASE = "https://analyticsdata.googleapis.com/v1beta";
|
|
const MAX_ACCOUNT_SUMMARY_PAGES = 100;
|
|
const MAX_ERROR_BODY_LENGTH = 8_000;
|
|
const propertyIdSchema = z.string().regex(/^properties\/\d+$/);
|
|
const dataStreamNameSchema = z
|
|
.string()
|
|
.regex(/^properties\/\d+\/dataStreams\/\d+$/);
|
|
|
|
const propertySummarySchema = z.object({
|
|
property: propertyIdSchema,
|
|
displayName: z.string(),
|
|
});
|
|
|
|
const accountSummarySchema = z.object({
|
|
account: z.string().regex(/^accounts\/\d+$/),
|
|
displayName: z.string(),
|
|
propertySummaries: z.array(propertySummarySchema).optional(),
|
|
});
|
|
|
|
const accountSummariesResponseSchema = z.object({
|
|
accountSummaries: z.array(accountSummarySchema).optional(),
|
|
nextPageToken: z.string().optional(),
|
|
});
|
|
|
|
const propertySchema = z.object({
|
|
name: propertyIdSchema,
|
|
displayName: z.string(),
|
|
timeZone: z.string().min(1),
|
|
currencyCode: z.string().min(1),
|
|
});
|
|
|
|
const dataStreamSchema = z.object({
|
|
name: dataStreamNameSchema,
|
|
type: z.string(),
|
|
displayName: z.string().default(""),
|
|
createTime: z.string().optional(),
|
|
updateTime: z.string().optional(),
|
|
webStreamData: z
|
|
.object({
|
|
measurementId: z.string().optional(),
|
|
defaultUri: z.string().optional(),
|
|
})
|
|
.optional(),
|
|
androidAppStreamData: z
|
|
.object({ packageName: z.string().optional() })
|
|
.optional(),
|
|
iosAppStreamData: z.object({ bundleId: z.string().optional() }).optional(),
|
|
});
|
|
const dataStreamsResponseSchema = z.object({
|
|
dataStreams: z.array(dataStreamSchema).optional(),
|
|
nextPageToken: z.string().optional(),
|
|
});
|
|
const enhancedMeasurementSettingsSchema = z.object({
|
|
// ProtoJSON omits scalar fields at their default values.
|
|
streamEnabled: z.boolean().default(false),
|
|
scrollsEnabled: z.boolean().default(false),
|
|
outboundClicksEnabled: z.boolean().default(false),
|
|
siteSearchEnabled: z.boolean().default(false),
|
|
videoEngagementEnabled: z.boolean().default(false),
|
|
fileDownloadsEnabled: z.boolean().default(false),
|
|
pageChangesEnabled: z.boolean().default(false),
|
|
formInteractionsEnabled: z.boolean().default(false),
|
|
searchQueryParameter: z.string().default(""),
|
|
uriQueryParameter: z.string().optional().default(""),
|
|
});
|
|
const keyEventSchema = z.object({
|
|
eventName: z.string(),
|
|
createTime: z.string().optional(),
|
|
deletable: z.boolean().optional(),
|
|
custom: z.boolean().optional(),
|
|
countingMethod: z.string(),
|
|
defaultValue: z
|
|
.object({ numericValue: z.number(), currencyCode: z.string() })
|
|
.optional(),
|
|
});
|
|
const keyEventsResponseSchema = z.object({
|
|
keyEvents: z.array(keyEventSchema).optional(),
|
|
nextPageToken: z.string().optional(),
|
|
});
|
|
const customDimensionSchema = z.object({
|
|
parameterName: z.string(),
|
|
displayName: z.string(),
|
|
description: z.string().optional().default(""),
|
|
scope: z.string(),
|
|
disallowAdsPersonalization: z.boolean().optional().default(false),
|
|
});
|
|
const customDimensionsResponseSchema = z.object({
|
|
customDimensions: z.array(customDimensionSchema).optional(),
|
|
nextPageToken: z.string().optional(),
|
|
});
|
|
const customMetricSchema = z.object({
|
|
parameterName: z.string(),
|
|
displayName: z.string(),
|
|
description: z.string().optional().default(""),
|
|
measurementUnit: z.string(),
|
|
scope: z.string(),
|
|
restrictedMetricType: z.array(z.string()).optional().default([]),
|
|
});
|
|
const customMetricsResponseSchema = z.object({
|
|
customMetrics: z.array(customMetricSchema).optional(),
|
|
nextPageToken: z.string().optional(),
|
|
});
|
|
|
|
type Ga4PropertySummary = {
|
|
propertyId: string;
|
|
displayName: string;
|
|
accountDisplayName: string;
|
|
};
|
|
|
|
type Ga4Property = z.infer<typeof propertySchema>;
|
|
|
|
async function getGa4AccessToken(opts: {
|
|
userId: string;
|
|
ga4AccountId: string;
|
|
}): Promise<string> {
|
|
let result: { accessToken?: string } | undefined;
|
|
try {
|
|
result = await getAuth().api.getAccessToken({
|
|
body: {
|
|
providerId: GA4_OAUTH_PROVIDER_ID,
|
|
userId: opts.userId,
|
|
accountId: opts.ga4AccountId,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
throw new Ga4TokenError(
|
|
"Could not mint a Google Analytics access token.",
|
|
error,
|
|
);
|
|
}
|
|
if (!result?.accessToken) {
|
|
throw new Ga4TokenError("Google Analytics returned no access token.");
|
|
}
|
|
return result.accessToken;
|
|
}
|
|
|
|
function adminMessageForStatus(status: number): string {
|
|
if (status === 401) return "Google Analytics connection expired.";
|
|
if (status === 403) {
|
|
return "Google Analytics denied access. Check the account's property access and enabled APIs.";
|
|
}
|
|
if (status === 429) return "Google Analytics rate limit reached.";
|
|
return `Google Analytics Admin API error (${status}).`;
|
|
}
|
|
|
|
function isAbortError(error: unknown): boolean {
|
|
return error instanceof Error && error.name === "AbortError";
|
|
}
|
|
|
|
function memoizedGa4AccessToken(opts: {
|
|
userId: string;
|
|
ga4AccountId: string;
|
|
}) {
|
|
let accessTokenPromise: Promise<string> | undefined;
|
|
return () => (accessTokenPromise ??= getGa4AccessToken(opts));
|
|
}
|
|
|
|
/** Read-only Admin API client used only for account/property discovery. */
|
|
export function createGa4AdminClient(opts: {
|
|
userId: string;
|
|
ga4AccountId: string;
|
|
}) {
|
|
const accessToken = memoizedGa4AccessToken(opts);
|
|
|
|
async function request(url: string): Promise<unknown> {
|
|
const token = await accessToken();
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(url, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
} catch (error) {
|
|
if (isAbortError(error)) throw error;
|
|
throw new Ga4AdminApiError(
|
|
0,
|
|
"Google Analytics Admin API is temporarily unavailable.",
|
|
);
|
|
}
|
|
if (!response.ok) {
|
|
throw new Ga4AdminApiError(
|
|
response.status,
|
|
adminMessageForStatus(response.status),
|
|
);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
function propertyUrl(base: string, propertyId: string, child: string): URL {
|
|
const canonicalId = propertyIdSchema.parse(propertyId);
|
|
return new URL(`${base}/${canonicalId}/${child}`);
|
|
}
|
|
|
|
return {
|
|
async getUserInfoEmail(): Promise<string | null> {
|
|
const data = z
|
|
.object({ email: z.string().email().optional() })
|
|
.parse(await request(GOOGLE_USERINFO_URL));
|
|
return data.email ?? null;
|
|
},
|
|
|
|
async listProperties(): Promise<Ga4PropertySummary[]> {
|
|
const properties: Ga4PropertySummary[] = [];
|
|
let pageToken: string | undefined;
|
|
|
|
for (let page = 0; page < MAX_ACCOUNT_SUMMARY_PAGES; page += 1) {
|
|
const url = new URL(`${GA4_ADMIN_API_BASE}/accountSummaries`);
|
|
url.searchParams.set("pageSize", "200");
|
|
if (pageToken) url.searchParams.set("pageToken", pageToken);
|
|
const response = accountSummariesResponseSchema.parse(
|
|
await request(url.toString()),
|
|
);
|
|
for (const account of response.accountSummaries ?? []) {
|
|
for (const property of account.propertySummaries ?? []) {
|
|
properties.push({
|
|
propertyId: property.property,
|
|
displayName: property.displayName,
|
|
accountDisplayName: account.displayName,
|
|
});
|
|
}
|
|
}
|
|
|
|
pageToken = response.nextPageToken || undefined;
|
|
if (!pageToken) return properties;
|
|
}
|
|
|
|
throw new Error(
|
|
"Google Analytics property discovery exceeded 100 pages.",
|
|
);
|
|
},
|
|
|
|
async getProperty(propertyId: string): Promise<Ga4Property> {
|
|
const canonicalId = propertyIdSchema.parse(propertyId);
|
|
return propertySchema.parse(
|
|
await request(`${GA4_ADMIN_API_BASE}/${canonicalId}`),
|
|
);
|
|
},
|
|
|
|
async listDataStreams(propertyId: string) {
|
|
const url = propertyUrl(
|
|
GA4_ADMIN_ALPHA_API_BASE,
|
|
propertyId,
|
|
"dataStreams",
|
|
);
|
|
url.searchParams.set("pageSize", "200");
|
|
const response = dataStreamsResponseSchema.parse(
|
|
await request(url.toString()),
|
|
);
|
|
return response.dataStreams ?? [];
|
|
},
|
|
|
|
async getEnhancedMeasurementSettings(streamName: string) {
|
|
const canonicalName = dataStreamNameSchema.parse(streamName);
|
|
return enhancedMeasurementSettingsSchema.parse(
|
|
await request(
|
|
`${GA4_ADMIN_ALPHA_API_BASE}/${canonicalName}/enhancedMeasurementSettings`,
|
|
),
|
|
);
|
|
},
|
|
|
|
async listKeyEvents(propertyId: string) {
|
|
const url = propertyUrl(GA4_ADMIN_API_BASE, propertyId, "keyEvents");
|
|
url.searchParams.set("pageSize", "200");
|
|
const response = keyEventsResponseSchema.parse(
|
|
await request(url.toString()),
|
|
);
|
|
return response.keyEvents ?? [];
|
|
},
|
|
|
|
async listCustomDimensions(propertyId: string) {
|
|
const url = propertyUrl(
|
|
GA4_ADMIN_API_BASE,
|
|
propertyId,
|
|
"customDimensions",
|
|
);
|
|
url.searchParams.set("pageSize", "200");
|
|
const response = customDimensionsResponseSchema.parse(
|
|
await request(url.toString()),
|
|
);
|
|
return response.customDimensions ?? [];
|
|
},
|
|
|
|
async listCustomMetrics(propertyId: string) {
|
|
const url = propertyUrl(GA4_ADMIN_API_BASE, propertyId, "customMetrics");
|
|
url.searchParams.set("pageSize", "200");
|
|
const response = customMetricsResponseSchema.parse(
|
|
await request(url.toString()),
|
|
);
|
|
return response.customMetrics ?? [];
|
|
},
|
|
};
|
|
}
|
|
|
|
const quotaStatusSchema = z.object({
|
|
consumed: z.number().int(),
|
|
remaining: z.number().int(),
|
|
});
|
|
|
|
const propertyQuotaSchema = z.object({
|
|
tokensPerDay: quotaStatusSchema.optional(),
|
|
tokensPerHour: quotaStatusSchema.optional(),
|
|
concurrentRequests: quotaStatusSchema.optional(),
|
|
serverErrorsPerProjectPerHour: quotaStatusSchema.optional(),
|
|
potentiallyThresholdedRequestsPerHour: quotaStatusSchema.optional(),
|
|
tokensPerProjectPerHour: quotaStatusSchema.optional(),
|
|
});
|
|
|
|
const responseMetadataSchema = z.object({
|
|
dataLossFromOtherRow: z.boolean().optional(),
|
|
samplingMetadatas: z
|
|
.array(
|
|
z.object({
|
|
samplesReadCount: z.string(),
|
|
samplingSpaceSize: z.string(),
|
|
}),
|
|
)
|
|
.optional(),
|
|
schemaRestrictionResponse: z
|
|
.object({
|
|
activeMetricRestrictions: z
|
|
.array(
|
|
z.object({
|
|
metricName: z.string(),
|
|
restrictedMetricTypes: z.array(z.string()).optional(),
|
|
}),
|
|
)
|
|
.optional(),
|
|
})
|
|
.optional(),
|
|
currencyCode: z.string().optional(),
|
|
timeZone: z.string().optional(),
|
|
emptyReason: z.string().optional(),
|
|
subjectToThresholding: z.boolean().optional(),
|
|
});
|
|
|
|
const runReportResponseSchema = z.object({
|
|
dimensionHeaders: z.array(z.object({ name: z.string() })).optional(),
|
|
metricHeaders: z
|
|
.array(z.object({ name: z.string(), type: z.string().optional() }))
|
|
.optional(),
|
|
rows: z
|
|
.array(
|
|
z.object({
|
|
dimensionValues: z.array(z.object({ value: z.string() })).optional(),
|
|
metricValues: z.array(z.object({ value: z.string() })).optional(),
|
|
}),
|
|
)
|
|
.optional(),
|
|
rowCount: z.number().int().nonnegative().optional(),
|
|
metadata: responseMetadataSchema.optional(),
|
|
propertyQuota: propertyQuotaSchema.optional(),
|
|
kind: z.string().optional(),
|
|
});
|
|
|
|
const googleErrorSchema = z.object({
|
|
error: z.object({
|
|
details: z
|
|
.array(
|
|
z.object({
|
|
reason: z.string().optional(),
|
|
metadata: z.object({ service: z.string().optional() }).optional(),
|
|
}),
|
|
)
|
|
.optional(),
|
|
}),
|
|
});
|
|
|
|
export type Ga4RunReportResponse = z.infer<typeof runReportResponseSchema>;
|
|
|
|
export type Ga4RunReportRequest = {
|
|
dateRanges: Array<{ startDate: string; endDate: string }>;
|
|
dimensions: Array<{ name: string }>;
|
|
metrics: Array<{ name: string }>;
|
|
dimensionFilter?: unknown;
|
|
metricFilter?: unknown;
|
|
offset: string;
|
|
limit: string;
|
|
orderBys: Array<{
|
|
metric?: { metricName: string };
|
|
dimension?: { dimensionName: string };
|
|
desc?: boolean;
|
|
}>;
|
|
keepEmptyRows: false;
|
|
returnPropertyQuota: true;
|
|
};
|
|
|
|
function safeRetryAfter(response: Response): number | null {
|
|
const value = response.headers.get("retry-after");
|
|
if (!value || !/^\d+$/.test(value)) return null;
|
|
return Math.min(Number(value), 86_400);
|
|
}
|
|
|
|
function dataMessageForStatus(status: number): string {
|
|
if (status === 400) return "Google Analytics rejected this report.";
|
|
if (status === 401) return "Google Analytics connection expired.";
|
|
if (status === 403) return "Google Analytics denied access to this property.";
|
|
if (status === 429) return "Google Analytics reporting quota was exhausted.";
|
|
return "Google Analytics reporting is temporarily unavailable.";
|
|
}
|
|
|
|
export function createGa4DataClient(opts: {
|
|
userId: string;
|
|
ga4AccountId: string;
|
|
propertyId: string;
|
|
}) {
|
|
const propertyId = propertyIdSchema.parse(opts.propertyId);
|
|
const accessToken = memoizedGa4AccessToken(opts);
|
|
|
|
return {
|
|
async runReport(
|
|
request: Ga4RunReportRequest,
|
|
): Promise<Ga4RunReportResponse> {
|
|
const token = await accessToken();
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(`${GA4_DATA_API_BASE}/${propertyId}:runReport`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(request),
|
|
});
|
|
} catch (error) {
|
|
if (isAbortError(error)) throw error;
|
|
throw new Ga4DataApiError(
|
|
0,
|
|
"Google Analytics reporting is temporarily unavailable.",
|
|
);
|
|
}
|
|
if (!response.ok) {
|
|
const body = await response
|
|
.text()
|
|
.then((responseBody) => responseBody.slice(0, MAX_ERROR_BODY_LENGTH))
|
|
.catch(() => "");
|
|
let upstreamReason: string | null = null;
|
|
try {
|
|
const parsed = googleErrorSchema.safeParse(JSON.parse(body));
|
|
if (parsed.success) {
|
|
upstreamReason =
|
|
parsed.data.error.details?.find(
|
|
(detail) =>
|
|
detail.metadata?.service === "analyticsdata.googleapis.com",
|
|
)?.reason ??
|
|
parsed.data.error.details?.find((detail) => detail.reason)
|
|
?.reason ??
|
|
null;
|
|
}
|
|
} catch {
|
|
// Non-JSON error pages intentionally collapse to status-only errors.
|
|
}
|
|
throw new Ga4DataApiError(
|
|
response.status,
|
|
dataMessageForStatus(response.status),
|
|
safeRetryAfter(response),
|
|
upstreamReason,
|
|
);
|
|
}
|
|
|
|
try {
|
|
return runReportResponseSchema.parse(await response.json());
|
|
} catch {
|
|
throw new Ga4MalformedResponseError();
|
|
}
|
|
},
|
|
};
|
|
}
|