From 557d87ffa4c11f1210f9305a9890487a38b2fa89 Mon Sep 17 00:00:00 2001 From: Nick Arnold Date: Tue, 23 Jul 2024 07:52:47 -0600 Subject: [PATCH] feat: track city and region of site visitors resolves #85 --- app/analytics/__tests__/collect.test.ts | 4 +++ app/analytics/collect.ts | 15 ++++++++- app/analytics/query.ts | 34 ++++++++++++++++++++ app/analytics/schema.ts | 2 ++ app/routes/__tests__/dashboard.test.tsx | 40 +++++++++++++++++++++++ app/routes/dashboard.tsx | 13 +++++--- app/routes/resources.city.tsx | 42 +++++++++++++++++++++++++ app/routes/resources.region.tsx | 42 +++++++++++++++++++++++++ 8 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 app/routes/resources.city.tsx create mode 100644 app/routes/resources.region.tsx diff --git a/app/analytics/__tests__/collect.test.ts b/app/analytics/__tests__/collect.test.ts index 77219e7..f3b9d3e 100644 --- a/app/analytics/__tests__/collect.test.ts +++ b/app/analytics/__tests__/collect.test.ts @@ -29,6 +29,8 @@ function generateRequestParams(headers: Record) { // Cloudflare-specific request properties cf: { country: "US", + region: "Colorado", + city: "Denver", }, }; } @@ -65,6 +67,8 @@ describe("collectRequestHandler", () => { "Chrome", // browser name "", "example", // site id + "Colorado", // region + "Denver", // city ], doubles: [ 1, // new visitor diff --git a/app/analytics/collect.ts b/app/analytics/collect.ts index 7dd059f..a05b924 100644 --- a/app/analytics/collect.ts +++ b/app/analytics/collect.ts @@ -78,10 +78,19 @@ export function collectRequestHandler(request: Request, env: Env) { // 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; + const cfIncomingRequestProperties = (request as RequestInit).cf; + const city = cfIncomingRequestProperties?.city; + if (typeof city === "string") { + data.city = city; + } + const country = cfIncomingRequestProperties?.country; if (typeof country === "string") { data.country = country; } + const region = cfIncomingRequestProperties?.region; + if (typeof region === "string") { + data.region = region; + } writeDataPoint(env.WEB_COUNTER_AE, data); @@ -116,7 +125,9 @@ interface DataPoint { host?: string | undefined; userAgent?: string; path?: string; + city?: string; country?: string; + region?: string; referrer?: string; browserName?: string; deviceModel?: string; @@ -144,6 +155,8 @@ export function writeDataPoint( data.browserName || "", // blob6 data.deviceModel || "", // blob7 data.siteId || "", // blob8 + data.region || "", // blob9 + data.city || "", // blob10 ], doubles: [data.newVisitor || 0, data.newSession || 0], }; diff --git a/app/analytics/query.ts b/app/analytics/query.ts index 19a2a39..7b5039b 100644 --- a/app/analytics/query.ts +++ b/app/analytics/query.ts @@ -501,6 +501,23 @@ export class AnalyticsEngineAPI { }); } + async getCountByCity( + siteId: string, + interval: string, + tz?: string, + filters: SearchFilters = {}, + page: number = 1, + ) { + return this.getVisitorCountByColumn( + siteId, + "city", + interval, + tz, + filters, + page, + ); + } + async getCountByCountry( siteId: string, interval: string, @@ -518,6 +535,23 @@ export class AnalyticsEngineAPI { ); } + async getCountByRegion( + siteId: string, + interval: string, + tz?: string, + filters: SearchFilters = {}, + page: number = 1, + ) { + return this.getVisitorCountByColumn( + siteId, + "region", + interval, + tz, + filters, + page, + ); + } + async getCountByReferrer( siteId: string, interval: string, diff --git a/app/analytics/schema.ts b/app/analytics/schema.ts index 4beb097..f380b12 100644 --- a/app/analytics/schema.ts +++ b/app/analytics/schema.ts @@ -22,6 +22,8 @@ export const ColumnMappings = { browserName: "blob6", deviceModel: "blob7", siteId: "blob8", + region: "blob9", + city: "blob10", /** * doubles diff --git a/app/routes/__tests__/dashboard.test.tsx b/app/routes/__tests__/dashboard.test.tsx index bd4d891..3433786 100644 --- a/app/routes/__tests__/dashboard.test.tsx +++ b/app/routes/__tests__/dashboard.test.tsx @@ -301,12 +301,24 @@ describe("Dashboard route", () => { return json({ countsByProperty: [] }); }, }, + { + path: "/resources/city", + loader: () => { + return json({ countsByProperty: [] }); + }, + }, { path: "/resources/country", loader: () => { return json({ countsByProperty: [] }); }, }, + { + path: "/resources/region", + loader: () => { + return json({ countsByProperty: [] }); + }, + }, { path: "/resources/device", loader: () => { @@ -323,7 +335,9 @@ describe("Dashboard route", () => { expect(screen.getByText("Path")).toBeInTheDocument(); expect(screen.getByText("Referrer")).toBeInTheDocument(); expect(screen.getByText("Browser")).toBeInTheDocument(); + expect(screen.getByText("City")).toBeInTheDocument(); expect(screen.getByText("Country")).toBeInTheDocument(); + expect(screen.getByText("Region")).toBeInTheDocument(); expect(screen.getByText("Device")).toBeInTheDocument(); }); @@ -396,6 +410,18 @@ describe("Dashboard route", () => { }); }, }, + { + path: "/resources/city", + loader: () => { + return json({ + countsByProperty: [ + ["Chicago", 100], + ["Denver", 80], + ["San Diego", 60], + ], + }); + }, + }, { path: "/resources/country", loader: () => { @@ -408,6 +434,18 @@ describe("Dashboard route", () => { }); }, }, + { + path: "/resources/region", + loader: () => { + return json({ + countsByProperty: [ + ["California", 100], + ["Colorado", 80], + ["Illinois", 60], + ], + }); + }, + }, { path: "/resources/device", loader: () => { @@ -436,7 +474,9 @@ describe("Dashboard route", () => { expect(screen.getByText("/about")).toBeInTheDocument(); expect(screen.getByText("Chrome")).toBeInTheDocument(); expect(screen.getByText("google.com")).toBeInTheDocument(); + expect(screen.getByText("Denver")).toBeInTheDocument(); expect(screen.getByText("Canada")).toBeInTheDocument(); // assert converted CA -> Canada + expect(screen.getByText("California")).toBeInTheDocument(); expect(screen.getByText("Mobile")).toBeInTheDocument(); }); }); diff --git a/app/routes/dashboard.tsx b/app/routes/dashboard.tsx index 709d286..681b7ff 100644 --- a/app/routes/dashboard.tsx +++ b/app/routes/dashboard.tsx @@ -19,6 +19,8 @@ import { ReferrerCard } from "./resources.referrer"; import { PathsCard } from "./resources.paths"; import { BrowserCard } from "./resources.browser"; import { CountryCard } from "./resources.country"; +import { RegionCard } from "./resources.region"; +import { CityCard } from "./resources.city"; import { DeviceCard } from "./resources.device"; import TimeSeriesChart from "~/components/TimeSeriesChart"; @@ -319,7 +321,7 @@ export default function Dashboard() { onFilterChange={handleFilterChange} /> -
+
- - - +
+ + +
diff --git a/app/routes/resources.city.tsx b/app/routes/resources.city.tsx new file mode 100644 index 0000000..5b47a8a --- /dev/null +++ b/app/routes/resources.city.tsx @@ -0,0 +1,42 @@ +import { useFetcher } from "@remix-run/react"; + +import type { LoaderFunctionArgs } from "@remix-run/cloudflare"; +import { json } from "@remix-run/cloudflare"; + +import { paramsFromUrl } from "~/lib/utils"; +import PaginatedTableCard from "~/components/PaginatedTableCard"; + +export async function loader({ context, request }: LoaderFunctionArgs) { + const { analyticsEngine } = context; + + const { interval, site, page = 1 } = paramsFromUrl(request.url); + const tz = context.requestTimezone as string; + + return json({ + countsByProperty: await analyticsEngine.getCountByCity( + site, + interval, + tz, + Number(page), + ), + page: Number(page), + }); +} + +export const CityCard = ({ + siteId, + interval, +}: { + siteId: string; + interval: string; +}) => { + return ( + ()} + loaderUrl="/resources/city" + /> + ); +}; diff --git a/app/routes/resources.region.tsx b/app/routes/resources.region.tsx new file mode 100644 index 0000000..5be5aa8 --- /dev/null +++ b/app/routes/resources.region.tsx @@ -0,0 +1,42 @@ +import { useFetcher } from "@remix-run/react"; + +import type { LoaderFunctionArgs } from "@remix-run/cloudflare"; +import { json } from "@remix-run/cloudflare"; + +import { paramsFromUrl } from "~/lib/utils"; +import PaginatedTableCard from "~/components/PaginatedTableCard"; + +export async function loader({ context, request }: LoaderFunctionArgs) { + const { analyticsEngine } = context; + + const { interval, site, page = 1 } = paramsFromUrl(request.url); + const tz = context.requestTimezone as string; + + return json({ + countsByProperty: await analyticsEngine.getCountByRegion( + site, + interval, + tz, + Number(page), + ), + page: Number(page), + }); +} + +export const RegionCard = ({ + siteId, + interval, +}: { + siteId: string; + interval: string; +}) => { + return ( + ()} + loaderUrl="/resources/region" + /> + ); +};