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 browser version tracking #135

Merged
merged 3 commits into from
Jan 4, 2025
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
1 change: 1 addition & 0 deletions packages/server/app/analytics/__tests__/collect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ describe("collectRequestHandler", () => {
"Chrome", // browser name
"",
"example", // site id
"51.x.x.x", // browser version
],
doubles: [
1, // new visitor
Expand Down
8 changes: 8 additions & 0 deletions packages/server/app/analytics/collect.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { UAParser } from "ua-parser-js";

import type { RequestInit } from "@cloudflare/workers-types";
import { maskBrowserVersion } from "~/lib/utils";

// Cookieless visitor/session tracking
// Uses the approach described here: https://notes.normally.com/cookieless-unique-visitor-counts/
Expand Down Expand Up @@ -100,6 +101,10 @@ export function collectRequestHandler(request: Request, env: Env) {
ifModifiedSince ? new Date(ifModifiedSince) : null,
);

const browserVersion = maskBrowserVersion(
parsedUserAgent.getBrowser().version,
);

const data: DataPoint = {
siteId: params.sid,
host: params.h,
Expand All @@ -111,6 +116,7 @@ export function collectRequestHandler(request: Request, env: Env) {
// user agent stuff
userAgent: userAgent,
browserName: parsedUserAgent.getBrowser().name,
browserVersion: browserVersion,
deviceModel: parsedUserAgent.getDevice().model,
};

Expand Down Expand Up @@ -157,6 +163,7 @@ interface DataPoint {
country?: string;
referrer?: string;
browserName?: string;
browserVersion?: string;
deviceModel?: string;

// doubles
Expand All @@ -183,6 +190,7 @@ export function writeDataPoint(
data.browserName || "", // blob6
data.deviceModel || "", // blob7
data.siteId || "", // blob8
data.browserVersion || "", // blob9
],
doubles: [data.newVisitor || 0, data.newSession || 0, data.bounce],
};
Expand Down
20 changes: 19 additions & 1 deletion packages/server/app/analytics/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ function filtersToSql(filters: SearchFilters) {
"path",
"referrer",
"browserName",
"browserVersion",
"country",
"deviceModel",
];
Expand Down Expand Up @@ -654,6 +655,23 @@ export class AnalyticsEngineAPI {
);
}

async getCountByBrowserVersion(
siteId: string,
interval: string,
tz?: string,
filters: SearchFilters = {},
page: number = 1,
): Promise<[browser: string, visitors: number][]> {
return this.getVisitorCountByColumn(
siteId,
"browserVersion",
interval,
tz,
filters,
page,
);
}

async getCountByDevice(
siteId: string,
interval: string,
Expand Down Expand Up @@ -725,7 +743,7 @@ export class AnalyticsEngineAPI {
earliestBounce: Date | null;
}> {
const query = `
SELECT
SELECT
MIN(timestamp) as earliestEvent,
${ColumnMappings.bounce} as isBounce
FROM metricsDataset
Expand Down
1 change: 1 addition & 0 deletions packages/server/app/analytics/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const ColumnMappings = {
browserName: "blob6",
deviceModel: "blob7",
siteId: "blob8",
browserVersion: "blob9",

/**
* doubles
Expand Down
25 changes: 23 additions & 2 deletions packages/server/app/lib/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { getFiltersFromSearchParams, getDateTimeRange } from "../utils";
import {
getFiltersFromSearchParams,
getDateTimeRange,
maskBrowserVersion,
} from "../utils";
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
Expand All @@ -11,14 +15,15 @@ dayjs.extend(timezone);
describe("getFiltersFromSearchParams", () => {
test("it should return an object with the correct keys", () => {
const searchParams = new URLSearchParams(
"?path=/about&referrer=google.com&deviceModel=iphone&country=us&browserName=chrome",
"?path=/about&referrer=google.com&deviceModel=iphone&country=us&browserName=chrome&browserVersion=118",
);
expect(getFiltersFromSearchParams(searchParams)).toEqual({
path: "/about",
referrer: "google.com",
deviceModel: "iphone",
country: "us",
browserName: "chrome",
browserVersion: "118",
});
});

Expand Down Expand Up @@ -90,3 +95,19 @@ describe("getDateTimeRange", () => {
expect(london.startDate).toEqual(new Date("2024-01-15T00:00:00Z"));
});
});

describe("maskBrowserVersion", () => {
const browserVersions = [
["Microsoft Edge", "119.0.0.0", "119.x.x.x"],
["Google Chrome", "119.0.0.0", "119.x.x.x"],
["Mozilla Firefox", "119.0", "119.x"],
["Safari", "605.1.15", "605.x.x"],
["DuckDuckGo", "5", "5"],
["Brave", "129.0.6668.54", "129.x.x.x"],
["Opera", "117.0.0.0", "117.x.x.x"],
];

test.each(browserVersions)("%s", (_, version, expected) => {
expect(maskBrowserVersion(version)).toEqual(expected);
});
});
1 change: 1 addition & 0 deletions packages/server/app/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ export interface SearchFilters {
deviceModel?: string;
country?: string;
browserName?: string;
browserVersion?: string;
}
18 changes: 18 additions & 0 deletions packages/server/app/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ interface SearchFilters {
deviceModel?: string;
country?: string;
browserName?: string;
browserVersion?: string;
}

export function getFiltersFromSearchParams(searchParams: URLSearchParams) {
Expand All @@ -46,6 +47,9 @@ export function getFiltersFromSearchParams(searchParams: URLSearchParams) {
if (searchParams.has("browserName")) {
filters.browserName = searchParams.get("browserName") || "";
}
if (searchParams.has("browserVersion")) {
filters.browserVersion = searchParams.get("browserVersion") || "";
}

return filters;
}
Expand Down Expand Up @@ -108,3 +112,17 @@ export function getDateTimeRange(interval: string, tz: string) {
endDate: localEndDateTime.toDate(),
};
}

export function maskBrowserVersion(version?: string) {
if (!version) return version;

const majorEnd = version.indexOf(".");

if (majorEnd != -1) {
version =
version.substring(0, majorEnd) +
version.slice(majorEnd).replaceAll(/\.[^.]+/g, ".x");
}

return version;
}
12 changes: 12 additions & 0 deletions packages/server/app/routes/__tests__/dashboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,12 @@ describe("Dashboard route", () => {
return { countsByProperty: [] };
},
},
{
path: "/resources/browserversion",
loader: () => {
return { countsByProperty: [] };
},
},
],
},
]);
Expand Down Expand Up @@ -382,6 +388,12 @@ describe("Dashboard route", () => {
};
},
},
{
path: "/resources/browserversion",
loader: () => {
return { countsByProperty: [] };
},
},
],
},
]);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// @vitest-environment jsdom
import {
vi,
test,
describe,
beforeEach,
afterEach,
expect,
Mock,
} from "vitest";
import "vitest-dom/extend-expect";

import { loader } from "../resources.browserversion";
import { createFetchResponse, getDefaultContext } from "./testutils";

describe("Resources/Browserversion route", () => {
let fetch: Mock;

beforeEach(() => {
fetch = global.fetch = vi.fn();
});

afterEach(() => {
vi.restoreAllMocks();
});

describe("loader", () => {
test("returns valid json", async () => {
fetch.mockResolvedValueOnce(
createFetchResponse({
data: [
{ blob6: "Chrome", blob9: "118", count: "5" },
{ blob6: "Chrome", blob9: "117", count: "15" },
{ blob6: "Chrome", blob9: "116", count: "1" },
],
}),
);

const response = await loader({
...getDefaultContext(),
// @ts-expect-error we don't need to provide all the properties of the request object
request: {
url: "http://localhost:3000/resources/browserversion?browserName=Chrome", // need browserName query param
},
});

const json = await response;
expect(json).toEqual({
countsByProperty: [
["118", 5],
["117", 15],
["116", 1],
],
page: 1,
});
});
});
});
25 changes: 18 additions & 7 deletions packages/server/app/routes/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import { ReferrerCard } from "./resources.referrer";
import { PathsCard } from "./resources.paths";
import { BrowserCard } from "./resources.browser";
import { BrowserVersionCard } from "./resources.browserversion";
import { CountryCard } from "./resources.country";
import { DeviceCard } from "./resources.device";

Expand Down Expand Up @@ -250,13 +251,23 @@ export default function Dashboard() {
/>
</div>
<div className="grid md:grid-cols-3 gap-4 mb-4">
<BrowserCard
siteId={data.siteId}
interval={data.interval}
filters={data.filters}
onFilterChange={handleFilterChange}
timezone={userTimezone}
/>
{data.filters && data.filters.browserName ? (
<BrowserVersionCard
siteId={data.siteId}
interval={data.interval}
filters={data.filters}
onFilterChange={handleFilterChange}
timezone={userTimezone}
/>
) : (
<BrowserCard
siteId={data.siteId}
interval={data.interval}
filters={data.filters}
onFilterChange={handleFilterChange}
timezone={userTimezone}
/>
)}

<CountryCard
siteId={data.siteId}
Expand Down
56 changes: 56 additions & 0 deletions packages/server/app/routes/resources.browserversion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { useFetcher } from "@remix-run/react";

import type { LoaderFunctionArgs } from "@remix-run/cloudflare";

import { getFiltersFromSearchParams, paramsFromUrl } from "~/lib/utils";
import PaginatedTableCard from "~/components/PaginatedTableCard";
import { SearchFilters } from "~/lib/types";

export async function loader({ context, request }: LoaderFunctionArgs) {
const { analyticsEngine } = context;

const { interval, site, page = 1 } = paramsFromUrl(request.url);
const url = new URL(request.url);
const tz = url.searchParams.get("timezone") || "UTC";
const filters = getFiltersFromSearchParams(url.searchParams);

return {
countsByProperty: await analyticsEngine.getCountByBrowserVersion(
site,
interval,
tz,
filters,
Number(page),
),
page: Number(page),
};
}

export const BrowserVersionCard = ({
siteId,
interval,
filters,
onFilterChange,
timezone,
}: {
siteId: string;
interval: string;
filters: SearchFilters;
onFilterChange: (filters: SearchFilters) => void;
timezone: string;
}) => {
return (
<PaginatedTableCard
siteId={siteId}
interval={interval}
columnHeaders={[`${filters.browserName} Versions`, "Visitors"]}
dataFetcher={useFetcher<typeof loader>()}
loaderUrl="/resources/browserversion"
onClick={(browserVersion) =>
onFilterChange({ ...filters, browserVersion })
}
filters={filters}
timezone={timezone}
/>
);
};
Loading