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

Track city and region of site visitors #86

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
4 changes: 4 additions & 0 deletions app/analytics/__tests__/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: 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);

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
36 changes: 36 additions & 0 deletions app/analytics/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,9 @@ function filtersToSql(filters: SearchFilters) {
"path",
"referrer",
"browserName",
"city",
"country",
"region",
"deviceModel",
];

Expand Down Expand Up @@ -501,6 +503,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,
Expand All @@ -518,6 +537,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,
Expand Down
2 changes: 2 additions & 0 deletions app/analytics/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export const ColumnMappings = {
browserName: "blob6",
deviceModel: "blob7",
siteId: "blob8",
region: "blob9",
city: "blob10",

/**
* doubles
Expand Down
2 changes: 2 additions & 0 deletions app/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,7 @@ export interface SearchFilters {
referrer?: string;
deviceModel?: string;
country?: string;
region?: string;
city?: string;
browserName?: string;
}
8 changes: 8 additions & 0 deletions app/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ interface SearchFilters {
referrer?: string;
deviceModel?: string;
country?: string;
region?: string;
city?: string;
browserName?: string;
}

Expand All @@ -37,6 +39,12 @@ export function getFiltersFromSearchParams(searchParams: URLSearchParams) {
if (searchParams.has("country")) {
filters.country = searchParams.get("country") || "";
}
if (searchParams.has("region")) {
filters.region = searchParams.get("region") || "";
}
if (searchParams.has("city")) {
filters.city = searchParams.get("city") || "";
}
if (searchParams.has("browserName")) {
filters.browserName = searchParams.get("browserName") || "";
}
Expand Down
40 changes: 40 additions & 0 deletions app/routes/__tests__/dashboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => {
Expand All @@ -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();
});

Expand Down Expand Up @@ -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: () => {
Expand All @@ -408,6 +434,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 @@ -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();
});
});
21 changes: 18 additions & 3 deletions app/routes/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -319,22 +321,35 @@ export default function Dashboard() {
onFilterChange={handleFilterChange}
/>
</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}
filters={data.filters}
onFilterChange={handleFilterChange}
/>

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

<DeviceCard
<RegionCard
siteId={data.siteId}
interval={data.interval}
filters={data.filters}
onFilterChange={handleFilterChange}
/>
<CityCard
siteId={data.siteId}
interval={data.interval}
filters={data.filters}
Expand Down
53 changes: 53 additions & 0 deletions app/routes/resources.city.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { useFetcher } from "@remix-run/react";

import type { LoaderFunctionArgs } from "@remix-run/cloudflare";
import { json } 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 tz = context.requestTimezone as string;

const url = new URL(request.url);
const filters = getFiltersFromSearchParams(new URL(url).searchParams);

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

export const CityCard = ({
siteId,
interval,
filters,
onFilterChange,
}: {
siteId: string;
interval: string;
filters: SearchFilters;
onFilterChange: (filters: SearchFilters) => void;
}) => {
return (
<PaginatedTableCard
siteId={siteId}
interval={interval}
columnHeaders={["City", "Visitors"]}
dataFetcher={useFetcher<typeof loader>()}
loaderUrl="/resources/city"
filters={filters}
onClick={(city: string) => onFilterChange({ ...filters, city })}
/>
);
};
3 changes: 1 addition & 2 deletions app/routes/resources.referrer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@ 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";

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

export async function loader({ context, request }: LoaderFunctionArgs) {
Expand Down
Loading