metatron-open-seo/src/server/features/ga4/services/Ga4ReportNormalization.ts

120 lines
4.0 KiB
TypeScript

import {
type Ga4RunReportRequest,
type Ga4RunReportResponse,
} from "@/server/lib/ga4Client";
import { Ga4MalformedResponseError } from "@/server/lib/ga4Errors";
type Ga4QuotaStatus = { consumed: number; remaining: number };
export type Ga4Quota = {
tokensPerDay?: Ga4QuotaStatus;
tokensPerHour?: Ga4QuotaStatus;
concurrentRequests?: Ga4QuotaStatus;
serverErrorsPerProjectPerHour?: Ga4QuotaStatus;
potentiallyThresholdedRequestsPerHour?: Ga4QuotaStatus;
tokensPerProjectPerHour?: Ga4QuotaStatus;
};
export type Ga4ReportMetadata = {
dataLossFromOtherRow: boolean;
subjectToThresholding: boolean;
sampling: Array<{
samplesReadCount: string;
samplingSpaceSize: string;
}>;
restrictedMetrics: Array<{
metricName: string;
restrictedMetricTypes: string[];
}>;
emptyReason: string | null;
hasLimitedData: boolean;
};
export type NormalizedGa4Report = {
rows: Array<Record<string, string | number | null>>;
totalRowCount: number;
reportMetadata: Ga4ReportMetadata;
quota: Ga4Quota | null;
};
function parseFiniteMetric(value: string): number {
const parsed = Number(value);
if (!Number.isFinite(parsed)) throw new Ga4MalformedResponseError();
return parsed;
}
export function normalizeGa4Response(
response: Ga4RunReportResponse,
request: Ga4RunReportRequest,
): NormalizedGa4Report {
const expectedDimensions = request.dimensions.map(({ name }) => name);
const expectedMetrics = request.metrics.map(({ name }) => name);
const dimensions = (response.dimensionHeaders ?? []).map(({ name }) => name);
const metrics = (response.metricHeaders ?? []).map(({ name }) => name);
// GA4 omits headers and rows entirely (rather than echoing the requested
// headers with zero rows) when the date range has no data on record for
// the property at all — e.g. a previous-period comparison window that
// falls before the property's creation date. Treat that as a
// legitimately empty report instead of a malformed response.
const isHeaderlessEmptyResponse =
response.dimensionHeaders === undefined &&
response.metricHeaders === undefined &&
(response.rows?.length ?? 0) === 0;
if (
!isHeaderlessEmptyResponse &&
(dimensions.join("\0") !== expectedDimensions.join("\0") ||
metrics.join("\0") !== expectedMetrics.join("\0"))
) {
throw new Ga4MalformedResponseError();
}
const restrictedMetrics =
response.metadata?.schemaRestrictionResponse?.activeMetricRestrictions?.map(
(restriction) => ({
metricName: restriction.metricName,
restrictedMetricTypes: restriction.restrictedMetricTypes ?? [],
}),
) ?? [];
const restrictedNames = new Set(
restrictedMetrics.map((restriction) => restriction.metricName),
);
const rows = (response.rows ?? []).map((row) => {
if (
(row.dimensionValues?.length ?? 0) !== dimensions.length ||
(row.metricValues?.length ?? 0) !== metrics.length
) {
throw new Ga4MalformedResponseError();
}
const normalized: Record<string, string | number | null> = {};
dimensions.forEach((name, index) => {
normalized[name] = row.dimensionValues?.[index]?.value ?? "";
});
metrics.forEach((name, index) => {
normalized[name] = restrictedNames.has(name)
? null
: parseFiniteMetric(row.metricValues?.[index]?.value ?? "");
});
return normalized;
});
const sampling = response.metadata?.samplingMetadatas ?? [];
const reportMetadata = {
dataLossFromOtherRow: response.metadata?.dataLossFromOtherRow ?? false,
subjectToThresholding: response.metadata?.subjectToThresholding ?? false,
sampling,
restrictedMetrics,
emptyReason: response.metadata?.emptyReason ?? null,
hasLimitedData:
(response.metadata?.dataLossFromOtherRow ?? false) ||
(response.metadata?.subjectToThresholding ?? false) ||
sampling.length > 0 ||
restrictedMetrics.length > 0,
};
return {
rows,
totalRowCount: response.rowCount ?? rows.length,
reportMetadata,
quota: response.propertyQuota ?? null,
};
}