From 604e8cafde77552fa8ce4d20b8aa6447476ed003 Mon Sep 17 00:00:00 2001 From: Lucas Faria Date: Fri, 6 Nov 2026 20:38:25 -0300 Subject: [PATCH 1/3] fix(mcp): keep the KPI comparison window stable so it can be cached The KPI and Users tiles query a doubled comparison window whose start was computed to the current millisecond, so every dashboard load sent new filters and the server recomputed both queries (5-12s each when measured), while every other tile came back from cache in ~300ms. Returning to the dashboard tab paid that cost each time. The window now opens on the start of its bucket, so repeat loads hit the cache. The prior period's oldest bucket becomes whole, which can raise the prior-period values (and so the deltas) by part of one bucket. A window shorter than one bucket keeps its start, so events before the selected start never count as current. Tests: jest products/mcp_analytics/frontend 746 passed. New cases: two loads 71s apart send identical KPI and Users windows (-7d, -24h, -90d); a 14:00-14:30 custom range keeps its 13:30 start. Both fail on master. --- .../mcpDashboardOverviewLogic.test.ts | 39 +++++++++++++++++++ .../frontend/mcpDashboardOverviewLogic.ts | 30 ++++++++------ 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/products/mcp_analytics/frontend/mcpDashboardOverviewLogic.test.ts b/products/mcp_analytics/frontend/mcpDashboardOverviewLogic.test.ts index 333642dcfcac..e2bdfd3fe5f9 100644 --- a/products/mcp_analytics/frontend/mcpDashboardOverviewLogic.test.ts +++ b/products/mcp_analytics/frontend/mcpDashboardOverviewLogic.test.ts @@ -593,6 +593,45 @@ describe('mcpDashboardOverviewLogic', () => { expect(dayjs(usersTile?.filters.dateRange.date_from).isValid()).toBe(true) }) + // The server caches a query by its exact filters, so a window that moves with the clock makes + // every load recompute the two slowest tiles. + it.each(['-7d', '-24h', '-90d'])('keeps the comparison window stable between %s loads', async (dateFrom) => { + jest.useFakeTimers({ now: new Date('2026-11-06T14:23:41.517Z'), advanceTimers: true }) + const logic = mcpDashboardOverviewLogic() + logic.mount() + const windowOf = (callIndex: number): string[] => + reloadCallsSince(callIndex) + .filter((call) => call.query?.includes('AS bucket') || call.query?.includes('current_users')) + .map((call) => call.filters.dateRange.date_from) + + await expectLogic(logic, () => logic.actions.setDateFilter(dateFrom, null)).toFinishAllListeners() + const first = windowOf(mockApi.query.mock.calls.length - 9) + jest.setSystemTime(new Date('2026-11-06T14:24:52.004Z')) + const callsBefore = mockApi.query.mock.calls.length + await expectLogic(logic, () => logic.actions.reloadAll()).toFinishAllListeners() + jest.useRealTimers() + + expect(first).toHaveLength(2) + expect(windowOf(callsBefore)).toEqual(first) + }) + + // Rounding a window shorter than one bucket would reach into the selected period's first bucket and + // count events from before the selected start as current. + it('leaves a window shorter than one bucket where it starts', async () => { + const logic = mcpDashboardOverviewLogic() + logic.mount() + const callsBefore = mockApi.query.mock.calls.length + + await expectLogic(logic, () => + logic.actions.setDateFilter('2026-11-06T14:00:00Z', '2026-11-06T14:30:00Z') + ).toFinishAllListeners() + + const windows = reloadCallsSince(callsBefore) + .filter((call) => call.query?.includes('AS bucket') || call.query?.includes('current_users')) + .map((call) => dayjs(call.filters.dateRange.date_from).toISOString()) + expect(windows).toEqual(['2026-11-06T13:30:00.000Z', '2026-11-06T13:30:00.000Z']) + }) + it.each([[false], [true]])('passes filterTestAccounts=%s to every tile', async (enabled) => { const logic = mcpDashboardOverviewLogic() logic.mount() diff --git a/products/mcp_analytics/frontend/mcpDashboardOverviewLogic.ts b/products/mcp_analytics/frontend/mcpDashboardOverviewLogic.ts index 3b8d67e0fd25..507475ec95ac 100644 --- a/products/mcp_analytics/frontend/mcpDashboardOverviewLogic.ts +++ b/products/mcp_analytics/frontend/mcpDashboardOverviewLogic.ts @@ -14,13 +14,16 @@ import { loaders } from 'kea-loaders' import { actionToUrl, router, urlToAction } from 'kea-router' import api from 'lib/api' +import { dayjs } from 'lib/dayjs' import { getDefaultInterval } from 'lib/utils/dateFilters' import { type ComparisonWindow, buildBucketKeys, buildComparisonWindow, + BUCKET_FORMAT, lastBucketIsInProgress, normalizeBucket, + startOfBucket, } from 'lib/utils/timeBuckets' import { teamLogic } from 'scenes/teamLogic' import { urls } from 'scenes/urls' @@ -329,6 +332,19 @@ export function buildDailyActivity(rows: ActivityRow[], bucketKeys: string[]): D } } +// The window opens on a whole bucket so it stays the same between loads and the server can answer the +// KPI and Users queries from its cache. A window opening at the current millisecond is a new cache key +// on every load. A window shorter than one bucket keeps its start, because rounding it would reach into +// the selected period's first bucket and count earlier events as current. +function kpiComparisonWindow(dateFilter: DateFilter, timezone: string, interval: IntervalType): ComparisonWindow { + const window = buildComparisonWindow(dateFilter.dateFrom, dateFilter.dateTo, timezone, interval) + const opening = startOfBucket(dayjs(window.dateFrom).tz(timezone), interval) + if (opening.format(BUCKET_FORMAT) >= window.currentStartBucket) { + return window + } + return { ...window, dateFrom: opening.toISOString() } +} + // Merge the dashboard's active filters with a doubled comparison window's date range. // Shared by the KPI and Users loaders so both tiles are scoped to the exact same window — // the tile-parity the reload test asserts. Keep the two loaders reading from here so the @@ -803,12 +819,7 @@ export const mcpDashboardOverviewLogic = kea([ { loadKPIs: async (_: void, breakpoint) => { const { interval } = values - const kpiWindow = buildComparisonWindow( - values.dateFilter.dateFrom, - values.dateFilter.dateTo, - values.timezone, - interval - ) + const kpiWindow = kpiComparisonWindow(values.dateFilter, values.timezone, interval) const response = await queryLatest( { kind: NodeKind.HogQLQuery, @@ -827,12 +838,7 @@ export const mcpDashboardOverviewLogic = kea([ { loadPeriodTotals: async (_: void, breakpoint): Promise => { const { interval, timezone } = values - const kpiWindow = buildComparisonWindow( - values.dateFilter.dateFrom, - values.dateFilter.dateTo, - timezone, - interval - ) + const kpiWindow = kpiComparisonWindow(values.dateFilter, timezone, interval) // Split the doubled window at the selected period's start. currentStartBucket is // interval-aligned (buildComparisonWindow truncates to the bucket start), so comparing the raw // `timestamp` against toDateTime(bucket, tz) lands on the same instant as the KPI From 7d89095d0dc207216f01ed1a3df3eff05faf66b0 Mon Sep 17 00:00:00 2001 From: Lucas Faria Date: Fri, 6 Nov 2026 20:49:59 -0300 Subject: [PATCH 2/3] fix(mcp): round the KPI comparison window to the minute, not the bucket Rounding the window to a whole bucket made the previous period up to a bucket longer than the selected one: with day buckets just after midnight, flat traffic read as about -12% on the Sessions, Tool calls and Users deltas. Rounding to the minute keeps one cache key per minute and the two periods within a minute of each other. Tests: jest products/mcp_analytics/frontend 748 passed. New case: for -7d and -90d at 00:59, the previous period matches the selected one to within a minute (fails with bucket rounding: 23h longer). --- .../mcpDashboardOverviewLogic.test.ts | 20 ++++++++++++++++++- .../frontend/mcpDashboardOverviewLogic.ts | 15 ++++---------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/products/mcp_analytics/frontend/mcpDashboardOverviewLogic.test.ts b/products/mcp_analytics/frontend/mcpDashboardOverviewLogic.test.ts index e2bdfd3fe5f9..64be73bbd4ea 100644 --- a/products/mcp_analytics/frontend/mcpDashboardOverviewLogic.test.ts +++ b/products/mcp_analytics/frontend/mcpDashboardOverviewLogic.test.ts @@ -606,7 +606,7 @@ describe('mcpDashboardOverviewLogic', () => { await expectLogic(logic, () => logic.actions.setDateFilter(dateFrom, null)).toFinishAllListeners() const first = windowOf(mockApi.query.mock.calls.length - 9) - jest.setSystemTime(new Date('2026-11-06T14:24:52.004Z')) + jest.setSystemTime(new Date('2026-11-06T14:23:58.004Z')) const callsBefore = mockApi.query.mock.calls.length await expectLogic(logic, () => logic.actions.reloadAll()).toFinishAllListeners() jest.useRealTimers() @@ -617,6 +617,24 @@ describe('mcpDashboardOverviewLogic', () => { // Rounding a window shorter than one bucket would reach into the selected period's first bucket and // count events from before the selected start as current. + // A previous period longer than the selected one makes flat traffic read as a drop. Sub-day ranges + // keep the bounded skew documented on the period totals loader, so only whole-day ranges are pinned. + it.each(['-7d', '-90d'])('compares %s against a previous period of the same length', async (dateFrom) => { + jest.useFakeTimers({ now: new Date('2026-11-06T00:59:59.900Z'), advanceTimers: true }) + const logic = mcpDashboardOverviewLogic() + logic.mount() + const callsBefore = mockApi.query.mock.calls.length + await expectLogic(logic, () => logic.actions.setDateFilter(dateFrom, null)).toFinishAllListeners() + const now = dayjs() + jest.useRealTimers() + + const reloads = reloadCallsSince(callsBefore) + const totals = reloads.find((call) => call.query?.includes('current_users')) + const selectedStart = dayjs.utc(totals.query.match(/toDateTime\('([^']+)'/)[1]) + const previousMs = selectedStart.diff(dayjs(totals.filters.dateRange.date_from)) + expect(Math.abs(previousMs - now.diff(selectedStart))).toBeLessThan(60_000) + }) + it('leaves a window shorter than one bucket where it starts', async () => { const logic = mcpDashboardOverviewLogic() logic.mount() diff --git a/products/mcp_analytics/frontend/mcpDashboardOverviewLogic.ts b/products/mcp_analytics/frontend/mcpDashboardOverviewLogic.ts index 507475ec95ac..b3fff6c18402 100644 --- a/products/mcp_analytics/frontend/mcpDashboardOverviewLogic.ts +++ b/products/mcp_analytics/frontend/mcpDashboardOverviewLogic.ts @@ -20,10 +20,8 @@ import { type ComparisonWindow, buildBucketKeys, buildComparisonWindow, - BUCKET_FORMAT, lastBucketIsInProgress, normalizeBucket, - startOfBucket, } from 'lib/utils/timeBuckets' import { teamLogic } from 'scenes/teamLogic' import { urls } from 'scenes/urls' @@ -332,17 +330,12 @@ export function buildDailyActivity(rows: ActivityRow[], bucketKeys: string[]): D } } -// The window opens on a whole bucket so it stays the same between loads and the server can answer the -// KPI and Users queries from its cache. A window opening at the current millisecond is a new cache key -// on every load. A window shorter than one bucket keeps its start, because rounding it would reach into -// the selected period's first bucket and count earlier events as current. +// The window opens on a whole minute so the KPI and Users queries keep one cache key for that minute +// instead of a new one per millisecond. A minute is the most the previous period may grow: rounding to +// a whole bucket would make it up to a bucket longer than the selected period and skew every delta. function kpiComparisonWindow(dateFilter: DateFilter, timezone: string, interval: IntervalType): ComparisonWindow { const window = buildComparisonWindow(dateFilter.dateFrom, dateFilter.dateTo, timezone, interval) - const opening = startOfBucket(dayjs(window.dateFrom).tz(timezone), interval) - if (opening.format(BUCKET_FORMAT) >= window.currentStartBucket) { - return window - } - return { ...window, dateFrom: opening.toISOString() } + return { ...window, dateFrom: dayjs(window.dateFrom).startOf('minute').toISOString() } } // Merge the dashboard's active filters with a doubled comparison window's date range. From d3011b9e7b3145e0a7bdaf9a4621793dd853bd90 Mon Sep 17 00:00:00 2001 From: Lucas Faria Date: Fri, 6 Nov 2026 21:23:44 -0300 Subject: [PATCH 3/3] chore(mcp): put the short-window test comment back on its test --- .../mcp_analytics/frontend/mcpDashboardOverviewLogic.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/products/mcp_analytics/frontend/mcpDashboardOverviewLogic.test.ts b/products/mcp_analytics/frontend/mcpDashboardOverviewLogic.test.ts index 64be73bbd4ea..594cae1611a0 100644 --- a/products/mcp_analytics/frontend/mcpDashboardOverviewLogic.test.ts +++ b/products/mcp_analytics/frontend/mcpDashboardOverviewLogic.test.ts @@ -615,8 +615,6 @@ describe('mcpDashboardOverviewLogic', () => { expect(windowOf(callsBefore)).toEqual(first) }) - // Rounding a window shorter than one bucket would reach into the selected period's first bucket and - // count events from before the selected start as current. // A previous period longer than the selected one makes flat traffic read as a drop. Sub-day ranges // keep the bounded skew documented on the period totals loader, so only whole-day ranges are pinned. it.each(['-7d', '-90d'])('compares %s against a previous period of the same length', async (dateFrom) => { @@ -635,6 +633,8 @@ describe('mcpDashboardOverviewLogic', () => { expect(Math.abs(previousMs - now.diff(selectedStart))).toBeLessThan(60_000) }) + // Rounding a window shorter than one bucket would reach into the selected period's first bucket and + // count events from before the selected start as current. it('leaves a window shorter than one bucket where it starts', async () => { const logic = mcpDashboardOverviewLogic() logic.mount()