Skip to content

Commit

Permalink
feat: track city and region of site visitors
Browse files Browse the repository at this point in the history
resolves benvinegar#85
  • Loading branch information
nickarnold authored and Nick Arnold committed Jul 23, 2024
1 parent 676139f commit 7b83ce3
Show file tree
Hide file tree
Showing 8 changed files with 176 additions and 4 deletions.
4 changes: 4 additions & 0 deletions app/analytics/collect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ function generateRequestParams(headers: Record<string, string>) {
// Cloudflare-specific request properties
cf: {
country: "US",
region: "Colorado",
city: "Denver",
},
};
}
Expand Down Expand Up @@ -65,6 +67,8 @@ describe("collectRequestHandler", () => {
"Chrome", // browser name
"",
"example", // site id
"Colorado", // region
"Denver", // city
],
doubles: [
1, // new visitor
Expand Down
15 changes: 14 additions & 1 deletion app/analytics/collect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,19 @@ export function collectRequestHandler(request: Request, env: Environment) {

// 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);

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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],
};
Expand Down
24 changes: 24 additions & 0 deletions app/analytics/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,15 @@ export class AnalyticsEngineAPI {
);
}

async getCountByCity(
siteId: string,
interval: string,
tz?: string,
page: number = 1,
) {
return this.getVisitorCountByColumn(siteId, "city", interval, tz, page);
}

async getCountByCountry(
siteId: string,
interval: string,
Expand All @@ -493,6 +502,21 @@ export class AnalyticsEngineAPI {
);
}

async getCountByRegion(
siteId: string,
interval: string,
tz?: string,
page: number = 1,
) {
return this.getVisitorCountByColumn(
siteId,
"region",
interval,
tz,
page,
);
}

async getCountByReferrer(
siteId: string,
interval: string,
Expand Down
2 changes: 2 additions & 0 deletions app/analytics/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export const ColumnMappings = {
browserName: "blob6",
deviceModel: "blob7",
siteId: "blob8",
region: "blob9",
city: "blob10",

/**
* doubles
Expand Down
40 changes: 40 additions & 0 deletions app/routes/dashboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -286,12 +286,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: () => {
Expand All @@ -308,7 +320,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();
});

Expand Down Expand Up @@ -378,6 +392,18 @@ describe("Dashboard route", () => {
});
},
},
{
path: "/resources/city",
loader: () => {
return json({
countsByProperty: [
["Chicago", 100],
["Denver", 80],
["San Diego", 60],
],
});
},
},
{
path: "/resources/country",
loader: () => {
Expand All @@ -390,6 +416,18 @@ describe("Dashboard route", () => {
});
},
},
{
path: "/resources/region",
loader: () => {
return json({
countsByProperty: [
["California", 100],
["Colorado", 80],
["Illinois", 60],
],
});
},
},
{
path: "/resources/device",
loader: () => {
Expand Down Expand Up @@ -418,7 +456,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();
});
});
11 changes: 8 additions & 3 deletions app/routes/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,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";
Expand Down Expand Up @@ -286,18 +288,21 @@ export default function Dashboard() {
interval={data.interval}
/>
</div>
<div className="grid md:grid-cols-3 gap-4 mb-4">
<div className="grid md:grid-cols-2 gap-4 mb-4">
<BrowserCard
siteId={data.siteId}
interval={data.interval}
/>

<DeviceCard siteId={data.siteId} interval={data.interval} />
</div>
<div className="grid md:grid-cols-3 gap-4 mb-4">
<CountryCard
siteId={data.siteId}
interval={data.interval}
/>

<DeviceCard siteId={data.siteId} interval={data.interval} />
<RegionCard siteId={data.siteId} interval={data.interval} />
<CityCard siteId={data.siteId} interval={data.interval} />
</div>
</div>
</div>
Expand Down
42 changes: 42 additions & 0 deletions app/routes/resources.city.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<PaginatedTableCard
siteId={siteId}
interval={interval}
columnHeaders={["City", "Visitors"]}
dataFetcher={useFetcher<typeof loader>()}
loaderUrl="/resources/city"
/>
);
};
42 changes: 42 additions & 0 deletions app/routes/resources.region.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<PaginatedTableCard
siteId={siteId}
interval={interval}
columnHeaders={["Region", "Visitors"]}
dataFetcher={useFetcher<typeof loader>()}
loaderUrl="/resources/region"
/>
);
};

0 comments on commit 7b83ce3

Please sign in to comment.