Skip to content

Commit

Permalink
Backwards compatibility for when bounce rate is unavailable
Browse files Browse the repository at this point in the history
  • Loading branch information
benvinegar committed Dec 11, 2024
1 parent 98a6716 commit 0fb2404
Show file tree
Hide file tree
Showing 4 changed files with 205 additions and 10 deletions.
24 changes: 24 additions & 0 deletions app/analytics/__tests__/query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
52 changes: 52 additions & 0 deletions app/analytics/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SelectionSet>;

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;
}
}
86 changes: 83 additions & 3 deletions app/routes/__tests__/resources.stats.test.tsx
Original file line number Diff line number Diff line change
@@ -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;

Check warning on line 5 in app/routes/__tests__/resources.stats.test.tsx

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
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,
},
};

Expand All @@ -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);

Check warning on line 76 in app/routes/__tests__/resources.stats.test.tsx

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
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);

Check warning on line 107 in app/routes/__tests__/resources.stats.test.tsx

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
const data = await response.json();

expect(data).toEqual({
views: 1000,
visitors: 250,
bounceRate: "50%",
});
});
});
53 changes: 46 additions & 7 deletions app/routes/resources.stats.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
});
}

Expand All @@ -35,7 +76,7 @@ export const StatsCard = ({
}) => {
const dataFetcher = useFetcher<typeof loader>();

const { views, visitors, bounces } = dataFetcher.data || {};
const { views, visitors, bounceRate } = dataFetcher.data || {};
const countFormatter = Intl.NumberFormat("en", { notation: "compact" });

useEffect(() => {
Expand Down Expand Up @@ -71,10 +112,8 @@ export const StatsCard = ({
</div>
</div>
<div>
<div className="text-md sm:text-lg">Bounces</div>
<div className="text-4xl">
{bounces ? countFormatter.format(bounces) : "-"}
</div>
<div className="text-md sm:text-lg">Bounce Rate</div>
<div className="text-4xl">{bounceRate}</div>
</div>
</div>
</div>
Expand Down

0 comments on commit 0fb2404

Please sign in to comment.