Skip to content

Commit

Permalink
Adding server side collection support
Browse files Browse the repository at this point in the history
  • Loading branch information
mackenly committed Sep 4, 2024
1 parent 676139f commit 4871e6f
Show file tree
Hide file tree
Showing 3 changed files with 148 additions and 55 deletions.
22 changes: 22 additions & 0 deletions app/analytics/collect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,4 +215,26 @@ describe("collectRequestHandler", () => {
],
);
});

test("a PATCH request should return 405", () => {
const env = {
WEB_COUNTER_AE: {
writeDataPoint: vi.fn(),
} as CFAnalyticsEngine,
} as Environment;

const request = httpMocks.createRequest({
method: "PATCH",
url: "https://example.com",
// Cloudflare-specific request properties
cf: {
country: "US",
},
});

const response = collectRequestHandler(request, env);

expect(response.status).toBe(405);
expect(response.statusText).toBe("Method Not Allowed");
});
});
171 changes: 118 additions & 53 deletions app/analytics/collect.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { UAParser } from "ua-parser-js";

import type { RequestInit } from "@cloudflare/workers-types";
import type { RequestInit, Request } from "@cloudflare/workers-types";

function checkVisitorSession(ifModifiedSince: string | null): {
newVisitor: boolean;
Expand Down Expand Up @@ -52,60 +52,125 @@ function extractParamsFromQueryString(requestUrl: string): {
}

export function collectRequestHandler(request: Request, env: Environment) {
const params = extractParamsFromQueryString(request.url);

const userAgent = request.headers.get("user-agent") || undefined;
const parsedUserAgent = new UAParser(userAgent);

parsedUserAgent.getBrowser().name;

const { newVisitor, newSession } = checkVisitorSession(
request.headers.get("if-modified-since"),
);

const data: DataPoint = {
siteId: params.sid,
host: params.h,
path: params.p,
referrer: params.r,
newVisitor: newVisitor ? 1 : 0,
newSession: newSession ? 1 : 0,
// user agent stuff
userAgent: userAgent,
browserName: parsedUserAgent.getBrowser().name,
deviceModel: parsedUserAgent.getDevice().model,
};

// NOTE: location is derived from Cloudflare-specific request properties
// see: https://developers.cloudflare.com/workers/runtime-apis/request/#incomingrequestcfproperties
const country = (request as RequestInit).cf?.country;
if (typeof country === "string") {
data.country = country;
}

writeDataPoint(env.WEB_COUNTER_AE, data);

// encode 1x1 transparent gif
const gif = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
const gifData = atob(gif);
const gifLength = gifData.length;
const arrayBuffer = new ArrayBuffer(gifLength);
const uintArray = new Uint8Array(arrayBuffer);
for (let i = 0; i < gifLength; i++) {
uintArray[i] = gifData.charCodeAt(i);
switch (request.method) {
case "GET": {
const params = extractParamsFromQueryString(request.url);

const userAgent = request.headers.get("user-agent") || undefined;
const parsedUserAgent = new UAParser(userAgent);

parsedUserAgent.getBrowser().name;

const { newVisitor, newSession } = checkVisitorSession(
request.headers.get("if-modified-since"),
);

const data: DataPoint = {
siteId: params.sid,
host: params.h,
path: params.p,
referrer: params.r,
newVisitor: newVisitor ? 1 : 0,
newSession: newSession ? 1 : 0,
// user agent stuff
userAgent: userAgent,
browserName: parsedUserAgent.getBrowser().name,
deviceModel: parsedUserAgent.getDevice().model,
};

// NOTE: location is derived from Cloudflare-specific request properties
// see: https://developers.cloudflare.com/workers/runtime-apis/request/#incomingrequestcfproperties
const country = (request as RequestInit).cf?.country;
if (typeof country === "string") {
data.country = country;
}

writeDataPoint(env.WEB_COUNTER_AE, data);

// encode 1x1 transparent gif
const gif =
"R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
const gifData = atob(gif);
const gifLength = gifData.length;
const arrayBuffer = new ArrayBuffer(gifLength);
const uintArray = new Uint8Array(arrayBuffer);
for (let i = 0; i < gifLength; i++) {
uintArray[i] = gifData.charCodeAt(i);
}

return new Response(arrayBuffer, {
headers: {
"Content-Type": "image/gif",
Expires: "Mon, 01 Jan 1990 00:00:00 GMT",
"Cache-Control": "no-cache",
Pragma: "no-cache",
"Last-Modified": new Date().toUTCString(),
Tk: "N", // not tracking
},
status: 200,
});
}
case "POST": {
const body: PostRequestBody =
request.json() as unknown as PostRequestBody;
if (!body) {
return new Response("Invalid request", { status: 400 });
}

if (
!body.siteId ||
!body.host ||
!body.userAgent ||
!body.path ||
!body.country ||
!body.referrer ||
!body.browserName ||
!body.deviceModel ||
!body.ifModifiedSince
) {
return new Response("Missing required fields", { status: 400 });
}

const { newVisitor, newSession } = checkVisitorSession(
body.ifModifiedSince,
);

const data: DataPoint = {
siteId: body.siteId,
host: body.host,
userAgent: body.userAgent,
path: body.path,
country: body.country,
referrer: body.referrer,
browserName: body.browserName,
deviceModel: body.deviceModel,
newVisitor: newVisitor ? 1 : 0,
newSession: newSession ? 1 : 0,
};

writeDataPoint(env.WEB_COUNTER_AE, data);

return new Response("OK", { status: 200 });
}
default: {
return new Response("Method not allowed", {
status: 405,
statusText: "Method Not Allowed",
});
}
}
}

return new Response(arrayBuffer, {
headers: {
"Content-Type": "image/gif",
Expires: "Mon, 01 Jan 1990 00:00:00 GMT",
"Cache-Control": "no-cache",
Pragma: "no-cache",
"Last-Modified": new Date().toUTCString(),
Tk: "N", // not tracking
},
status: 200,
});
interface PostRequestBody {
siteId: string;
host: string;
userAgent: string;
path: string;
country: string;
referrer: string;
browserName: string;
deviceModel: string;
ifModifiedSince: string;
}

interface DataPoint {
Expand Down
10 changes: 8 additions & 2 deletions server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type { RequestInit } from "@cloudflare/workers-types";
import type {
RequestInit,
Request as WorkerRequest,
} from "@cloudflare/workers-types";

import { getAssetFromKV } from "@cloudflare/kv-asset-handler";
import type { AppLoadContext } from "@remix-run/cloudflare";
Expand Down Expand Up @@ -28,7 +31,10 @@ export default {
const url = new URL(request.url);

if (url.pathname.startsWith("/collect")) {
return collectRequestHandler(request, env);
return collectRequestHandler(
request as unknown as WorkerRequest,
env,
);
}

const ttl = url.pathname.startsWith("/build/")
Expand Down

0 comments on commit 4871e6f

Please sign in to comment.