diff --git a/packages/server/app/analytics/__tests__/collect.test.ts b/packages/server/app/analytics/__tests__/collect.test.ts index b89170c7..c06b3b14 100644 --- a/packages/server/app/analytics/__tests__/collect.test.ts +++ b/packages/server/app/analytics/__tests__/collect.test.ts @@ -66,6 +66,7 @@ describe("collectRequestHandler", () => { "Chrome", // browser name "", "example", // site id + "51.x.x.x", // browser version ], doubles: [ 1, // new visitor diff --git a/packages/server/app/analytics/collect.ts b/packages/server/app/analytics/collect.ts index 82df4cab..b8e4a39d 100644 --- a/packages/server/app/analytics/collect.ts +++ b/packages/server/app/analytics/collect.ts @@ -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/ @@ -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, @@ -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, }; @@ -157,6 +163,7 @@ interface DataPoint { country?: string; referrer?: string; browserName?: string; + browserVersion?: string; deviceModel?: string; // doubles @@ -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], }; diff --git a/packages/server/app/analytics/query.ts b/packages/server/app/analytics/query.ts index 9e119836..d5f7936e 100644 --- a/packages/server/app/analytics/query.ts +++ b/packages/server/app/analytics/query.ts @@ -131,6 +131,7 @@ function filtersToSql(filters: SearchFilters) { "path", "referrer", "browserName", + "browserVersion", "country", "deviceModel", ]; @@ -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, @@ -725,7 +743,7 @@ export class AnalyticsEngineAPI { earliestBounce: Date | null; }> { const query = ` - SELECT + SELECT MIN(timestamp) as earliestEvent, ${ColumnMappings.bounce} as isBounce FROM metricsDataset diff --git a/packages/server/app/analytics/schema.ts b/packages/server/app/analytics/schema.ts index a943d080..19b3890d 100644 --- a/packages/server/app/analytics/schema.ts +++ b/packages/server/app/analytics/schema.ts @@ -22,6 +22,7 @@ export const ColumnMappings = { browserName: "blob6", deviceModel: "blob7", siteId: "blob8", + browserVersion: "blob9", /** * doubles diff --git a/packages/server/app/lib/__tests__/utils.test.ts b/packages/server/app/lib/__tests__/utils.test.ts index 0c55f5a2..0c8f9937 100644 --- a/packages/server/app/lib/__tests__/utils.test.ts +++ b/packages/server/app/lib/__tests__/utils.test.ts @@ -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"; @@ -11,7 +15,7 @@ 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", @@ -19,6 +23,7 @@ describe("getFiltersFromSearchParams", () => { deviceModel: "iphone", country: "us", browserName: "chrome", + browserVersion: "118", }); }); @@ -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); + }); +}); diff --git a/packages/server/app/lib/types.ts b/packages/server/app/lib/types.ts index f0c86e76..83eb9cd1 100644 --- a/packages/server/app/lib/types.ts +++ b/packages/server/app/lib/types.ts @@ -4,4 +4,5 @@ export interface SearchFilters { deviceModel?: string; country?: string; browserName?: string; + browserVersion?: string; } diff --git a/packages/server/app/lib/utils.ts b/packages/server/app/lib/utils.ts index 0a0836c4..aa877e7a 100644 --- a/packages/server/app/lib/utils.ts +++ b/packages/server/app/lib/utils.ts @@ -26,6 +26,7 @@ interface SearchFilters { deviceModel?: string; country?: string; browserName?: string; + browserVersion?: string; } export function getFiltersFromSearchParams(searchParams: URLSearchParams) { @@ -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; } @@ -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; +} diff --git a/packages/server/app/routes/__tests__/dashboard.test.tsx b/packages/server/app/routes/__tests__/dashboard.test.tsx index fec9c803..d601e86c 100644 --- a/packages/server/app/routes/__tests__/dashboard.test.tsx +++ b/packages/server/app/routes/__tests__/dashboard.test.tsx @@ -261,6 +261,12 @@ describe("Dashboard route", () => { return { countsByProperty: [] }; }, }, + { + path: "/resources/browserversion", + loader: () => { + return { countsByProperty: [] }; + }, + }, ], }, ]); @@ -382,6 +388,12 @@ describe("Dashboard route", () => { }; }, }, + { + path: "/resources/browserversion", + loader: () => { + return { countsByProperty: [] }; + }, + }, ], }, ]); diff --git a/packages/server/app/routes/__tests__/resources.browserversion.test.tsx b/packages/server/app/routes/__tests__/resources.browserversion.test.tsx new file mode 100644 index 00000000..5adf54bc --- /dev/null +++ b/packages/server/app/routes/__tests__/resources.browserversion.test.tsx @@ -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, + }); + }); + }); +}); diff --git a/packages/server/app/routes/dashboard.tsx b/packages/server/app/routes/dashboard.tsx index f9954105..a6740f24 100644 --- a/packages/server/app/routes/dashboard.tsx +++ b/packages/server/app/routes/dashboard.tsx @@ -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"; @@ -250,13 +251,23 @@ export default function Dashboard() { />
- + {data.filters && data.filters.browserName ? ( + + ) : ( + + )} void; + timezone: string; +}) => { + return ( + ()} + loaderUrl="/resources/browserversion" + onClick={(browserVersion) => + onFilterChange({ ...filters, browserVersion }) + } + filters={filters} + timezone={timezone} + /> + ); +};