Skip to content

Commit

Permalink
Merge pull request #116 from S-Makrod/bounce-tracking
Browse files Browse the repository at this point in the history
Adding Bounce Tracking
  • Loading branch information
benvinegar authored Dec 12, 2024
2 parents eb9be10 + bd4c687 commit db24a2b
Show file tree
Hide file tree
Showing 8 changed files with 404 additions and 69 deletions.
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

0 comments on commit db24a2b

Please sign in to comment.