Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding Bounce Tracking #116

Merged
merged 16 commits into from
Dec 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 88 additions & 7 deletions app/analytics/__tests__/collect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ describe("collectRequestHandler", () => {
],
doubles: [
1, // new visitor
1, // new session
0, // DEAD COLUMN (was session)
1, // new visit, so bounce
],
indexes: [
"example", // site id is index
Expand All @@ -94,7 +95,8 @@ describe("collectRequestHandler", () => {
"doubles",
[
1, // new visitor
1, // new session
0, // DEAD COLUMN (was session)
1, // new visit, so bounce
],
);
});
Expand Down Expand Up @@ -122,7 +124,8 @@ describe("collectRequestHandler", () => {
"doubles",
[
0, // NOT a new visitor
0, // NOT a new session
0, // DEAD COLUMN (was session)
0, // NOT first or second visit
],
);
});
Expand Down Expand Up @@ -155,8 +158,8 @@ describe("collectRequestHandler", () => {
"doubles",
[
1, // new visitor because a new day began
0, // NOT a new session because continuation of earlier session (< 30 mins)
// (session logic doesn't care if a new day began or not)
0, // DEAD COLUMN (was session)
1, // new visitor so bounce counted
],
);
});
Expand Down Expand Up @@ -184,7 +187,8 @@ describe("collectRequestHandler", () => {
"doubles",
[
1, // new visitor because > 30 days passed
1, // new session because > 30 minutes passed
0, // DEAD COLUMN (was session)
1, // new visitor so bounce
],
);
});
Expand Down Expand Up @@ -212,7 +216,84 @@ describe("collectRequestHandler", () => {
"doubles",
[
1, // new visitor because > 24 hours passed
1, // new session because > 30 minutes passed
0, // DEAD COLUMN (was session)
1, // new visitor so bounce
],
);
});

test("if-modified-since is one second after midnight", () => {
const env = {
WEB_COUNTER_AE: {
writeDataPoint: vi.fn(),
} as AnalyticsEngineDataset,
} as Env;

const midnight = new Date();
midnight.setHours(0, 0, 0, 0);

vi.setSystemTime(midnight.getTime());

const midnightPlusOneSecond = new Date(midnight.getTime());
midnightPlusOneSecond.setSeconds(
midnightPlusOneSecond.getSeconds() + 1,
);

const request = httpMocks.createRequest(
// @ts-expect-error - we're mocking the request object
generateRequestParams({
"if-modified-since": midnightPlusOneSecond.toUTCString(),
}),
);

collectRequestHandler(request as any, env);

const writeDataPoint = env.WEB_COUNTER_AE.writeDataPoint;
expect((writeDataPoint as Mock).mock.calls[0][0]).toHaveProperty(
"doubles",
[
0, // NOT a new visitor
0, // DEAD COLUMN (was session)
-1, // First visit after the initial visit so decrement bounce
],
);
});

test("if-modified-since is two seconds after midnight", () => {
const env = {
WEB_COUNTER_AE: {
writeDataPoint: vi.fn(),
} as AnalyticsEngineDataset,
} as Env;

const midnightPlusOneSecond = new Date();
midnightPlusOneSecond.setHours(0, 0, 1, 0);

vi.setSystemTime(midnightPlusOneSecond.getTime());

const midnightPlusTwoSeconds = new Date(
midnightPlusOneSecond.getTime(),
);
midnightPlusTwoSeconds.setSeconds(
midnightPlusTwoSeconds.getSeconds() + 1,
);

const request = httpMocks.createRequest(
// @ts-expect-error - we're mocking the request object
generateRequestParams({
"if-modified-since": midnightPlusTwoSeconds.toUTCString(),
}),
);

collectRequestHandler(request as any, env);

const writeDataPoint = env.WEB_COUNTER_AE.writeDataPoint;
expect((writeDataPoint as Mock).mock.calls[0][0]).toHaveProperty(
"doubles",
[
0, // NOT a new visitor
0, // DEAD COLUMN (was session)
0, // After the second visit so no bounce
],
);
});
Expand Down
58 changes: 50 additions & 8 deletions app/analytics/__tests__/query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,24 +187,24 @@ describe("AnalyticsEngineAPI", () => {
});

describe("getCounts", () => {
test("should return an object with view, visit, and visitor counts", async () => {
test("should return an object with view, visitor, and bounce counts", async () => {
fetch.mockResolvedValue(
createFetchResponse({
data: [
{
count: 3,
isVisit: 1,
isVisitor: 0,
isBounce: 1,
},
{
count: 2,
isVisit: 0,
isVisitor: 0,
isBounce: 0,
},
{
count: 1,
isVisit: 0,
isVisitor: 1,
isBounce: -1,
},
],
}),
Expand All @@ -216,8 +216,8 @@ describe("AnalyticsEngineAPI", () => {
expect(fetch).toHaveBeenCalled();
expect(await result).toEqual({
views: 6,
visits: 3,
visitors: 1,
bounces: 2,
});
});
});
Expand Down Expand Up @@ -324,21 +324,63 @@ describe("AnalyticsEngineAPI", () => {
).toEqual(
"SELECT blob4, " +
"double1 as isVisitor, " +
"double2 as isVisit, " +
"double3 as isBounce, " +
"SUM(_sample_interval) as count " +
"FROM metricsDataset WHERE timestamp >= NOW() - INTERVAL '7' DAY AND timestamp < NOW() AND blob8 = 'example.com' AND blob4 = 'CA' " +
"GROUP BY blob4, double1, double2 " +
"GROUP BY blob4, double1, double3 " +
"ORDER BY count DESC LIMIT 10",
);
expect(await result).toEqual({
CA: {
views: 3,
visitors: 0,
visits: 0,
bounces: 0,
},
});
});
});

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),
});
});

test("returns only earliest event when no bounces found", async () => {
const mockEventTimestamp = "2024-01-01T10:00:00Z";

// Mock responses for both queries
fetch.mockResolvedValueOnce(
createFetchResponse({
ok: true,
data: [{ earliestEvent: mockEventTimestamp, isBounce: 0 }],
}),
);

const result = await api.getEarliestEvents("test-site");
expect(result).toEqual({
earliestEvent: new Date(mockEventTimestamp),
earliestBounce: null,
});
});
});
});

describe("intervalToSql", () => {
Expand Down
72 changes: 54 additions & 18 deletions app/analytics/collect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,55 @@ import type { RequestInit } from "@cloudflare/workers-types";
// Cookieless visitor/session tracking
// Uses the approach described here: https://notes.normally.com/cookieless-unique-visitor-counts/

function getMidnightDate(): Date {
const midnight = new Date();
midnight.setHours(0, 0, 0, 0);
return midnight;
}

function getNextLastModifiedDate(current: Date | null): Date {
// in case date is an 'Invalid Date'
if (current && isNaN(current.getTime())) {
current = null;
}

const midnight = getMidnightDate();

// check if new day, if it is then set to midnight
let next = current ? current : midnight;
next = midnight.getTime() - next.getTime() > 0 ? midnight : next;

// increment counter
next.setSeconds(next.getSeconds() + 1);
return next;
}

function getBounceValue(nextLastModifiedDate: Date | null): number {
if (!nextLastModifiedDate) {
return 0;
}

const midnight = getMidnightDate();

// NOTE: minus one because this is the response last modified date
const visits =
(nextLastModifiedDate.getTime() - midnight.getTime()) / 1000 - 1;

switch (visits) {
case 0:
return 1;
case 1:
return -1;
default:
return 0;
}
}

function checkVisitorSession(ifModifiedSince: string | null): {
newVisitor: boolean;
newSession: boolean;
} {
let newVisitor = true;
let newSession = true;

const minutesUntilSessionResets = 30;
if (ifModifiedSince) {
// check today is a new day vs ifModifiedSince
const today = new Date();
Expand All @@ -25,18 +66,9 @@ function checkVisitorSession(ifModifiedSince: string | null): {
// if ifModifiedSince is today, this is not a new visitor
newVisitor = false;
}

// check ifModifiedSince is less than 30 mins ago
if (
Date.now() - new Date(ifModifiedSince).getTime() <
minutesUntilSessionResets * 60 * 1000
) {
// this is a continuation of the same session
newSession = false;
}
}

return { newVisitor, newSession };
return { newVisitor };
}

function extractParamsFromQueryString(requestUrl: string): {
Expand All @@ -62,8 +94,10 @@ export function collectRequestHandler(request: Request, env: Env) {

parsedUserAgent.getBrowser().name;

const { newVisitor, newSession } = checkVisitorSession(
request.headers.get("if-modified-since"),
const ifModifiedSince = request.headers.get("if-modified-since");
const { newVisitor } = checkVisitorSession(ifModifiedSince);
const nextLastModifiedDate = getNextLastModifiedDate(
ifModifiedSince ? new Date(ifModifiedSince) : null,
);

const data: DataPoint = {
Expand All @@ -72,7 +106,8 @@ export function collectRequestHandler(request: Request, env: Env) {
path: params.p,
referrer: params.r,
newVisitor: newVisitor ? 1 : 0,
newSession: newSession ? 1 : 0,
newSession: 0, // dead column
bounce: newVisitor ? 1 : getBounceValue(nextLastModifiedDate),
// user agent stuff
userAgent: userAgent,
browserName: parsedUserAgent.getBrowser().name,
Expand Down Expand Up @@ -104,7 +139,7 @@ export function collectRequestHandler(request: Request, env: Env) {
Expires: "Mon, 01 Jan 1990 00:00:00 GMT",
"Cache-Control": "no-cache",
Pragma: "no-cache",
"Last-Modified": new Date().toUTCString(),
"Last-Modified": nextLastModifiedDate.toUTCString(),
Tk: "N", // not tracking
},
status: 200,
Expand All @@ -127,6 +162,7 @@ interface DataPoint {
// doubles
newVisitor: number;
newSession: number;
bounce: number;
}

// NOTE: Cloudflare Analytics Engine has limits on total number of bytes, number of fields, etc.
Expand All @@ -148,7 +184,7 @@ export function writeDataPoint(
data.deviceModel || "", // blob7
data.siteId || "", // blob8
],
doubles: [data.newVisitor || 0, data.newSession || 0],
doubles: [data.newVisitor || 0, data.newSession || 0, data.bounce],
};

if (!analyticsEngine) {
Expand Down
Loading
Loading