fix(ga4): honor full date ranges instead of clamping to 90 days (#563)

This commit is contained in:
Ben Senescu 2026-08-28 16:15:22 -04:00 committed by GitHub
parent bb099ad65a
commit ad2d28ea6f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 55 additions and 22 deletions

View File

@ -127,12 +127,14 @@ common input:
| `offset` | Non-negative integer; default 0 |
With no explicit dates, the range is the last 28 complete days in the GA4
property time zone. Explicit ranges are inclusive. The report builder caps the
end at the last complete property day and moves the start forward when the
range exceeds 90 days. The response returns requested and resolved dates plus
`end_date_clamped` or `start_date_clamped` warnings. Invalid date formats,
reversed dates, and a single date without its pair return `validation_error`
before an API call.
property time zone. Explicit ranges are inclusive and honored in full; there is
no maximum range. The report builder caps the end at the last complete property
day. The response returns requested and resolved dates plus an
`end_date_clamped` warning, which the text output also states. The organic
overview trend is capped at 1,000 rows and reports `trend_truncated` (also
stated in the text) when a range exceeds that. Invalid date
formats, reversed dates, and a single date without its pair return
`validation_error` before an API call.
Only these tool-specific inputs are accepted:

View File

@ -36,7 +36,7 @@ describe("Ga4OrganicOverviewService", () => {
mocks.getByProjectId.mockResolvedValue(connection);
});
it("returns an equal-length comparison and weekly trend", async () => {
it("returns an equal-length comparison and flags a truncated trend", async () => {
mocks.runReport
.mockResolvedValueOnce({
dimensionHeaders: [],
@ -93,7 +93,7 @@ describe("Ga4OrganicOverviewService", () => {
]),
},
],
rowCount: 1,
rowCount: 1200,
});
const result = await Ga4OrganicOverviewService.getOrganicOverview(
{
@ -120,6 +120,7 @@ describe("Ga4OrganicOverviewService", () => {
sessions: 100,
});
expect(result.diagnostics).toEqual([]);
expect(result.warnings).toEqual(["trend_truncated"]);
expect(mocks.runReport).toHaveBeenCalledTimes(3);
});

View File

@ -142,7 +142,12 @@ async function getOrganicOverview(
reports: reports.map((report) => report.reportMetadata),
},
quota: trendReport.quota ?? current.quota,
warnings: dateRange.warnings,
warnings: [
...dateRange.warnings,
...(trendReport.totalRowCount > trendReport.rows.length
? ["trend_truncated"]
: []),
],
};
} catch (error) {
mapGa4ReportError(error);

View File

@ -134,7 +134,7 @@ describe("Ga4ReportingService", () => {
});
});
it("clamps explicit dates and nulls restricted metrics", async () => {
it("clamps a future endDate, keeps a long startDate, and nulls restricted metrics", async () => {
mocks.runReport.mockResolvedValue({
...landingHeaders,
rows: [
@ -168,10 +168,10 @@ describe("Ga4ReportingService", () => {
);
expect(result.request.resolvedDateRange).toEqual({
startDate: "2026-05-08",
startDate: "2025-01-01",
endDate: "2026-08-05",
});
expect(result.warnings).toEqual(["end_date_clamped", "start_date_clamped"]);
expect(result.warnings).toEqual(["end_date_clamped"]);
expect(result.rows[0]?.purchaseRevenue).toBeNull();
});

View File

@ -87,17 +87,12 @@ export function resolveGa4DateRange(
-1,
);
let endDate = requestedDateRange?.endDate ?? lastCompleteDay;
let startDate = requestedDateRange?.startDate ?? shiftGa4Date(endDate, -27);
const startDate = requestedDateRange?.startDate ?? shiftGa4Date(endDate, -27);
const warnings: string[] = [];
if (endDate > lastCompleteDay) {
endDate = lastCompleteDay;
warnings.push("end_date_clamped");
}
const ninetyDayFloor = shiftGa4Date(endDate, -89);
if (startDate < ninetyDayFloor) {
startDate = ninetyDayFloor;
warnings.push("start_date_clamped");
}
if (startDate > endDate) {
throw new Ga4ReportError(
"validation_error",

View File

@ -253,6 +253,7 @@ describe("Google Analytics MCP tools", () => {
},
comparison: {},
trend: [],
warnings: [],
});
mocks.getMeasurementHealth.mockResolvedValue({
status: "ok",

View File

@ -257,6 +257,15 @@ function emptyOrganicHint(result: Ga4ReportResult): string {
: " This report is limited to Organic Search.";
}
function endDateClampNote(result: {
warnings: string[];
request: { resolvedDateRange: { endDate: string } };
}) {
return result.warnings.includes("end_date_clamped")
? ` The requested endDate was moved back to ${result.request.resolvedDateRange.endDate}, the last complete Analytics day.`
: "";
}
function reportText(label: string, result: Ga4ReportResult) {
const range = result.request.resolvedDateRange;
const comparison = result.comparison
@ -272,7 +281,7 @@ function reportText(label: string, result: Ga4ReportResult) {
const paginate = result.pageInfo.hasMore
? " More rows are available; call again with offset to page through them."
: "";
const summary = `${label}: ${result.rowCount} of ${result.totalRowCount} rows for ${range.startDate} through ${range.endDate}.${comparison}${diagnostics}${limited}${emptyOrganicHint(result)}${paginate}`;
const summary = `${label}: ${result.rowCount} of ${result.totalRowCount} rows for ${range.startDate} through ${range.endDate}.${endDateClampNote(result)}${comparison}${diagnostics}${limited}${emptyOrganicHint(result)}${paginate}`;
if (result.rows.length === 0) return summary;
return `${summary}\n${formatMcpTable(result.rows, reportTableColumns(result))}`;
}
@ -280,7 +289,10 @@ function reportText(label: string, result: Ga4ReportResult) {
function overviewText(result: Ga4OverviewResult) {
const range = result.request.resolvedDateRange;
const previousRange = result.request.previousDateRange;
const summary = `Organic overview for ${range.startDate} through ${range.endDate}, compared with ${previousRange.startDate} through ${previousRange.endDate}.`;
const trendTruncated = result.warnings.includes("trend_truncated")
? ` The trend was cut at ${result.trend.length} rows; use trend=weekly or a shorter date range for the full series.`
: "";
const summary = `Organic overview for ${range.startDate} through ${range.endDate}, compared with ${previousRange.startDate} through ${previousRange.endDate}.${endDateClampNote(result)}${trendTruncated}`;
if (!result.current) {
return `${summary} No Organic Search rows for this date range.`;
}

View File

@ -552,6 +552,21 @@ describe("MCP tool text output (service-backed tools)", () => {
);
});
it("get_google_analytics_organic_landing_pages states an end-date clamp", async () => {
mocks.runGa4Report.mockResolvedValue(
makeGa4ReportResult({ warnings: ["end_date_clamped"] }),
);
const result = await getGoogleAnalyticsOrganicLandingPagesTool.handler(
{ projectId: "project_1", limit: 100, offset: 0 },
toolContext,
);
expect(textContent(result)).toEqual(
"Organic landing pages: 0 of 0 rows for 2026-07-09 through 2026-08-05. The requested endDate was moved back to 2026-08-05, the last complete Analytics day. This report is limited to Organic Search.",
);
});
it("get_google_analytics_traffic_acquisition does not mention Organic Search when empty", async () => {
mocks.runGa4Report.mockResolvedValue(
makeGa4ReportResult({
@ -587,6 +602,7 @@ describe("MCP tool text output (service-backed tools)", () => {
resolvedDateRange: { startDate: "2026-07-09", endDate: "2026-08-05" },
previousDateRange: { startDate: "2026-06-11", endDate: "2026-07-08" },
},
warnings: [],
current: {
sessions: 120,
activeUsers: 80,
@ -629,13 +645,14 @@ describe("MCP tool text output (service-backed tools)", () => {
);
});
it("get_google_analytics_organic_overview names Organic Search when there is no current row", async () => {
it("get_google_analytics_organic_overview states a truncated trend and names Organic Search when there is no current row", async () => {
mocks.getOrganicOverview.mockResolvedValue({
status: "ok",
request: {
resolvedDateRange: { startDate: "2026-07-09", endDate: "2026-08-05" },
previousDateRange: { startDate: "2026-06-11", endDate: "2026-07-08" },
},
warnings: ["trend_truncated"],
current: null,
previous: null,
comparison: {},
@ -648,7 +665,7 @@ describe("MCP tool text output (service-backed tools)", () => {
);
expect(textContent(result)).toEqual(
"Organic overview for 2026-07-09 through 2026-08-05, compared with 2026-06-11 through 2026-07-08. No Organic Search rows for this date range.",
"Organic overview for 2026-07-09 through 2026-08-05, compared with 2026-06-11 through 2026-07-08. The trend was cut at 0 rows; use trend=weekly or a shorter date range for the full series. No Organic Search rows for this date range.",
);
});
});