From 0fb24047842007dafd57726780c78359b89782ea Mon Sep 17 00:00:00 2001 From: Ben Vinegar <2153+benvinegar@users.noreply.github.com> Date: Wed, 11 Dec 2024 18:27:14 -0500 Subject: [PATCH] Backwards compatibility for when bounce rate is unavailable --- app/analytics/__tests__/query.test.ts | 24 ++++++ app/analytics/query.ts | 52 +++++++++++ app/routes/__tests__/resources.stats.test.tsx | 86 ++++++++++++++++++- app/routes/resources.stats.tsx | 53 ++++++++++-- 4 files changed, 205 insertions(+), 10 deletions(-) diff --git a/app/analytics/__tests__/query.test.ts b/app/analytics/__tests__/query.test.ts index f078cfd5..18d8be89 100644 --- a/app/analytics/__tests__/query.test.ts +++ b/app/analytics/__tests__/query.test.ts @@ -339,6 +339,30 @@ describe("AnalyticsEngineAPI", () => { }); }); }); + + describe("getEarliestEvents", () => { + test("returns both earliest event and bounce dates when found", async () => { + const mockEventTimestamp = "2024-01-01T10:00:00Z"; + const mockBounceTimestamp = "2024-01-01T12:00:00Z"; + + // Mock responses for both queries + fetch.mockResolvedValueOnce( + createFetchResponse({ + ok: true, + data: [ + { earliestEvent: mockBounceTimestamp, isBounce: 1 }, + { earliestEvent: mockEventTimestamp, isBounce: 0 }, + ], + }), + ); + + const result = await api.getEarliestEvents("test-site"); + expect(result).toEqual({ + earliestEvent: new Date(mockEventTimestamp), + earliestBounce: new Date(mockBounceTimestamp), + }); + }); + }); }); describe("intervalToSql", () => { diff --git a/app/analytics/query.ts b/app/analytics/query.ts index a39c1799..74a4bb95 100644 --- a/app/analytics/query.ts +++ b/app/analytics/query.ts @@ -650,4 +650,56 @@ export class AnalyticsEngineAPI { ); return returnPromise; } + + async getEarliestEvents(siteId: string): Promise<{ + earliestEvent: Date | null; + earliestBounce: Date | null; + }> { + const query = ` + SELECT + MIN(timestamp) as earliestEvent, + ${ColumnMappings.bounce} as isBounce + FROM metricsDataset + WHERE ${ColumnMappings.siteId} = '${siteId}' + GROUP by isBounce + `; + + type SelectionSet = { + earliestEvent: string; + isBounce: number; + }; + const queryResult = this.query(query); + const returnPromise = new Promise<{ + earliestEvent: Date | null; + earliestBounce: Date | null; + }>((resolve, reject) => { + (async () => { + const response = await queryResult; + + if (!response.ok) { + reject(response.statusText); + return; + } + + const responseData = + (await response.json()) as AnalyticsQueryResult; + + const data = responseData.data; + + const earliestEvent = data.filter( + (row) => row["isBounce"] === 0, + )[0]["earliestEvent"]; + const earliestBounce = data.filter( + (row) => row["isBounce"] === 1, + )[0]["earliestEvent"]; + + resolve({ + earliestEvent: new Date(earliestEvent), + earliestBounce: new Date(earliestBounce), + }); + })(); + }); + + return returnPromise; + } } diff --git a/app/routes/__tests__/resources.stats.test.tsx b/app/routes/__tests__/resources.stats.test.tsx index 2b0371ae..192d0240 100644 --- a/app/routes/__tests__/resources.stats.test.tsx +++ b/app/routes/__tests__/resources.stats.test.tsx @@ -1,16 +1,33 @@ -import { describe, test, expect, vi } from "vitest"; +import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; import { loader } from "../resources.stats"; describe("resources.stats loader", () => { - test("returns formatted stats from analytics engine", async () => { - const mockGetCounts = vi.fn().mockResolvedValue({ + let mockGetCounts: any; + beforeEach(() => { + mockGetCounts = vi.fn().mockResolvedValue({ views: 1000, visitors: 250, + bounces: 125, + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + test("returns formatted stats from analytics engine", async () => { + vi.setSystemTime(new Date("2023-01-01T06:00:00").getTime()); + + const mockGetEarliestEvents = vi.fn().mockResolvedValue({ + // earliest event and earliest bounce are the same + earliestEvent: new Date("2023-01-01T00:00:00Z"), + earliestBounce: new Date("2023-01-01T00:00:00Z"), }); const context = { analyticsEngine: { getCounts: mockGetCounts, + getEarliestEvents: mockGetEarliestEvents, }, }; @@ -31,6 +48,69 @@ describe("resources.stats loader", () => { expect(data).toEqual({ views: 1000, visitors: 250, + bounceRate: "50%", + }); + }); + + test("if bounce data isn't complete for the given interval, show n/a", async () => { + // set system time as jan 8th + vi.setSystemTime(new Date("2023-01-08T00:00:00").getTime()); + + const mockGetEarliestEvents = vi.fn().mockResolvedValue({ + earliestEvent: new Date("2023-01-01T00:00:00Z"), + earliestBounce: new Date("2023-01-04T00:00:00Z"), // Jan 4 + }); + + const context = { + analyticsEngine: { + getCounts: mockGetCounts, + getEarliestEvents: mockGetEarliestEvents, + }, + }; + + const request = new Request( + // 7 day interval (specified in query string) + "https://example.com/resources/stats?site=test-site&interval=7d&timezone=UTC", + ); + + const response = await loader({ context, request } as any); + const data = await response.json(); + + expect(data).toEqual({ + views: 1000, + visitors: 250, + bounceRate: "n/a", + }); + }); + + test("if bounce data *IS* complete for the given interval, show it", async () => { + // set system time as jan 8th + vi.setSystemTime(new Date("2023-01-08T00:00:00").getTime()); + + const mockGetEarliestEvents = vi.fn().mockResolvedValue({ + earliestEvent: new Date("2023-01-01T00:00:00Z"), + earliestBounce: new Date("2023-01-04T00:00:00Z"), // Jan 4 -- well before Jan 8th minus 1 day interval + }); + + const context = { + analyticsEngine: { + getCounts: mockGetCounts, + getEarliestEvents: mockGetEarliestEvents, + }, + }; + + const request = new Request( + // 1 day interval (specified in query string) + "https://example.com/resources/stats?site=test-site&interval=1d&timezone=UTC", + ); + + const response = await loader({ context, request } as any); + const data = await response.json(); + + expect(data).toEqual({ + views: 1000, + visitors: 250, + bounceRate: "50%", }); }); }); diff --git a/app/routes/resources.stats.tsx b/app/routes/resources.stats.tsx index 0f09f0a8..eacdce9c 100644 --- a/app/routes/resources.stats.tsx +++ b/app/routes/resources.stats.tsx @@ -1,6 +1,10 @@ import type { LoaderFunctionArgs } from "@remix-run/cloudflare"; import { json } from "@remix-run/cloudflare"; -import { getFiltersFromSearchParams, paramsFromUrl } from "~/lib/utils"; +import { + getDateTimeRange, + getFiltersFromSearchParams, + paramsFromUrl, +} from "~/lib/utils"; import { useEffect } from "react"; import { useFetcher } from "@remix-run/react"; import { Card } from "~/components/ui/card"; @@ -13,12 +17,49 @@ export async function loader({ context, request }: LoaderFunctionArgs) { const tz = url.searchParams.get("timezone") || "UTC"; const filters = getFiltersFromSearchParams(url.searchParams); + // intentionally parallelize queries by deferring await + const earliestEvents = analyticsEngine.getEarliestEvents(site); const counts = await analyticsEngine.getCounts(site, interval, tz, filters); + const { earliestEvent, earliestBounce } = await earliestEvents; + const { startDate } = getDateTimeRange(interval, tz); + + // FOR BACKWARDS COMPATIBILITY, ONLY CALCULATE BOUNCE RATE IF WE HAVE + // DATE FOR THE ENTIRE QUERY PERIOD + // ----------------------------------------------------------------------------- + // bounce rate is a later-introduced metric that may not have been recorded for + // the full duration of the queried Counterscale dataset (not possible to backfill + // data we dont have!) + + // so, cannot reliably show "bounce rate" if bounce data was unavailable for a portion + // of the query period + + // to figure if we can give an answer or not, we inspect the earliest bounce/earliest event data, + // and decide if our dataset is "complete" for the given interval + let bounceRate; + + if ( + counts.visitors > 0 && + earliestBounce !== null && + earliestEvent !== null && + (earliestEvent.getTime() == earliestBounce.getTime() || // earliest event recorded a bounce -- any query is fine + earliestBounce < startDate) // earliest bounce occurred before start of query period -- this query is fine + ) { + bounceRate = (counts.bounces / counts.visitors).toLocaleString( + "en-US", + { + style: "percent", + minimumFractionDigits: 0, + }, + ); + } else { + bounceRate = "n/a"; + } + return json({ views: counts.views, visitors: counts.visitors, - bounces: counts.bounces, + bounceRate: bounceRate, }); } @@ -35,7 +76,7 @@ export const StatsCard = ({ }) => { const dataFetcher = useFetcher(); - const { views, visitors, bounces } = dataFetcher.data || {}; + const { views, visitors, bounceRate } = dataFetcher.data || {}; const countFormatter = Intl.NumberFormat("en", { notation: "compact" }); useEffect(() => { @@ -71,10 +112,8 @@ export const StatsCard = ({
-
Bounces
-
- {bounces ? countFormatter.format(bounces) : "-"} -
+
Bounce Rate
+
{bounceRate}