Skip to content
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
19 changes: 17 additions & 2 deletions projects/blog-site/src/runtime/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,23 @@ export function createBlogApp(

app.use(cookieParser());
app.use(createVisitorIdMiddleware({ generateVisitorId: deps.generateVisitorId, secure: deps.secureCookies }));
app.use(createClickAttributionMiddleware({ now: deps.now, secure: deps.secureCookies }));
app.use(createAnalyticsMiddleware({ logger: deps.analyticsLogger, salt: deps.salt, now: deps.now }));

// The blog serves its shell assets from a separate static-asset origin (never
// through Express) and has no /view scheme-variant route, so no request path
// is ever a static asset and every landing path is already canonical.
const isStaticAssetPath = () => false;
const canonicalizeLandingPath = (path: string) => path;
app.use(
createClickAttributionMiddleware({
now: deps.now,
secure: deps.secureCookies,
isStaticAssetPath,
canonicalizeLandingPath,
}),
);
app.use(
createAnalyticsMiddleware({ logger: deps.analyticsLogger, salt: deps.salt, now: deps.now, isStaticAssetPath }),
);

const base = initBase(config);
const blogPosts = initBlogPosts();
Expand Down
24 changes: 23 additions & 1 deletion projects/hutch/Pulumi.prod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,28 @@ config:
b4248a499303636a,
cae1bf1ec4181321,
41174e927f157da7,
4b7117ffaf8a8d4c
4b7117ffaf8a8d4c,
275464cfd65e28a7,
b83406f00fd64b21,
48c6982510e6bcc9,
88594160212cdbdf,
f5921c92dcd21618,
f16b53a0519b3632,
2860ed8349a9326a,
e9dd888c93de4608,
a4406141f80be403,
43fb748de42b9620,
06b078573bbaf6f3,
573c2ff2b7939f2f,
a1814fdfe4229a14,
b4a154b234943575,
c754c936e8fdeec6,
362e13462b08322b,
f9c1a522f591fcf1,
3ed5982c96b1e184,
ced21dd8e16cf221,
583b92ae3a8d2bea,
e39b171d9e2b7fdf,
ac278da65b1433b6
]
encryptionsalt: v1:l9Qbc2Rjk0w=:v1:atrW398hQVu5eMhu:NQ2XG9vAOXAa+XxkU2+ksKYFAJJLTA==
2 changes: 2 additions & 0 deletions projects/hutch/src/runtime/lambda.main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import serverless from "serverless-http";
import { HutchLogger, consoleLogger } from "@packages/hutch-logger";
import { logger as requestLogger } from "./domain/logger";
import { createAnalyticsMiddleware, hashIp } from "@packages/web-analytics";
import { isStaticAssetRequestPath } from "./web/static-asset-paths";
import { createBanMiddleware } from "./web/middleware/ban";
import { type BotBlockEvent, createBlockNaiveBotMiddleware } from "./web/middleware/naive-bot";
import { logAndRespondOnError } from "./web/middleware/error-handler";
Expand All @@ -28,6 +29,7 @@ const analytics = createAnalyticsMiddleware({
logger: analyticsLogger,
salt,
now: () => new Date(),
isStaticAssetPath: isStaticAssetRequestPath,
});

const application = express()
Expand Down
13 changes: 11 additions & 2 deletions projects/hutch/src/runtime/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ import {
import { initAuthRoutes } from "./web/auth/auth.page";
import type { BotDefenseEvent } from "./web/auth/auth.page";
import type { ConversionEvent } from "./conversions";
import { CLIENT_DIST_MOUNT_PATH, isStaticAssetRequestPath } from "./web/static-asset-paths";
import { canonicalizeViewLandingPath } from "./web/pages/view/view-path";
import { initGoogleAuthRoutes } from "./web/auth/google-auth.page";
import { initAppleAuthRoutes } from "./web/auth/apple-auth.page";
import { initResolveLogin } from "@packages/web-session";
Expand Down Expand Up @@ -427,13 +429,20 @@ export function createApp(dependencies: AppDependencies): Express {
app.use(cookieParser());
app.use(changelogDismissMiddleware);
app.use(createVisitorIdMiddleware({ generateVisitorId: randomUUID, secure: secureCookies }));
app.use(createClickAttributionMiddleware({ now: dependencies.now, secure: secureCookies }));
app.use(
createClickAttributionMiddleware({
now: dependencies.now,
secure: secureCookies,
isStaticAssetPath: isStaticAssetRequestPath,
canonicalizeLandingPath: canonicalizeViewLandingPath,
}),
);

// Same-origin client bundles — the Lambda packaging step copies
// src/runtime/web/client-dist/ into the bundle, so `__dirname/web/client-dist`
// resolves both in dev (tsx → src/runtime/) and in prod (Lambda → /var/task/).
app.use(
"/client-dist",
CLIENT_DIST_MOUNT_PATH,
express.static(resolve(__dirname, "web", "client-dist"), {
maxAge: "5m",
fallthrough: false,
Expand Down
2 changes: 2 additions & 0 deletions projects/hutch/src/runtime/test-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { useTestServer as useServerForFixture } from "@packages/web-test-harness
import { createApp } from "./server";
import type { GetChangelogBanner } from "./web/changelog-banner-source";
import { initFoundingAllocation } from "./web/shared/founding-progress/founding-allocation";
import { isStaticAssetRequestPath } from "./web/static-asset-paths";
import { type AnalyticsEvent, createAnalyticsMiddleware } from "@packages/web-analytics";
import { DEFAULT_INBOX_ALIAS } from "@packages/domain/inbox";
import type { UserId } from "@packages/domain/user";
Expand Down Expand Up @@ -248,6 +249,7 @@ export function createTestApp(
logger: analyticsBundle.logger,
salt: "test-analytics-salt",
now: fixture.shared.now,
isStaticAssetPath: isStaticAssetRequestPath,
}))
.use(createApp({ ...flattenFixtureToAppDependencies(fixture, analyticsBundle), ...overrides }));
return {
Expand Down
36 changes: 35 additions & 1 deletion projects/hutch/src/runtime/web/pages/view/view-path.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { parseViewPath, viewPathFor } from "./view-path";
import { canonicalizeViewLandingPath, parseViewPath, viewPathFor } from "./view-path";

function parse(decodedAndEncoded: string): ReturnType<typeof parseViewPath>;
function parse(args: { rawPath: string; encodedPath: string }): ReturnType<typeof parseViewPath>;
Expand Down Expand Up @@ -67,6 +67,40 @@ describe("viewPathFor", () => {
});
});

describe("canonicalizeViewLandingPath", () => {
it("collapses a /view/https:/ landing path to the scheme-less canonical", () => {
expect(canonicalizeViewLandingPath("/view/https:/fagnerbrack.com/learn-sql")).toBe(
"/view/fagnerbrack.com/learn-sql",
);
});

it("collapses a /view/https:// landing path to the scheme-less canonical", () => {
expect(canonicalizeViewLandingPath("/view/https://fagnerbrack.com/learn-sql")).toBe(
"/view/fagnerbrack.com/learn-sql",
);
});

it("keeps the explicit http:// scheme for a /view/http:/ landing path", () => {
expect(canonicalizeViewLandingPath("/view/http:/example.com/post")).toBe(
"/view/http://example.com/post",
);
});

it("leaves an already-canonical /view path unchanged", () => {
expect(canonicalizeViewLandingPath("/view/fagnerbrack.com/learn-sql")).toBe(
"/view/fagnerbrack.com/learn-sql",
);
});

it("leaves a non-/view path unchanged", () => {
expect(canonicalizeViewLandingPath("/queue")).toBe("/queue");
});

it("leaves the root path unchanged", () => {
expect(canonicalizeViewLandingPath("/")).toBe("/");
});
});

describe("parseViewPath", () => {
it("treats a plain host/path as an https article", () => {
expect(parse("example.com/post")).toEqual({
Expand Down
12 changes: 12 additions & 0 deletions projects/hutch/src/runtime/web/pages/view/view-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ export function parseViewPath(input: ParseViewPathInput): ParseViewPathResult {
return { kind: "render", articleUrl: `https://${rawPath}` };
}

/** Canonicalizes a `/view/<scheme>://...` landing path to the same scheme-less
* (https) / literal-`http://` form `parseViewPath` redirects to, collapsing the
* `https:/` and `https://` variants that pollute click-attribution's
* `landing_path`. Used at cookie-set time, where the routing 301 cannot help
* because the cookie must be written before headers are sent. Non-`/view`
* scheme-bearing paths are returned unchanged. */
export function canonicalizeViewLandingPath(path: string): string {
const match = /^\/view\/(https?):\/{1,2}(.+)$/i.exec(path);
if (!match) return path;
return match[1].toLowerCase() === "http" ? `/view/http://${match[2]}` : `/view/${match[2]}`;
}

/** Re-encode `%25` (literal `%`), `?`, and `#` so the canonical survives
* Express's `decodeURIComponent` on the wildcard param. `%25` is the URL
* constructor's encoding of a literal percent sign; double-encoding it to
Expand Down
32 changes: 32 additions & 0 deletions projects/hutch/src/runtime/web/static-asset-paths.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { CLIENT_DIST_MOUNT_PATH, isStaticAssetRequestPath } from "./static-asset-paths";

describe("isStaticAssetRequestPath", () => {
it("exposes the /client-dist mount path as a single source of truth for server.ts", () => {
expect(CLIENT_DIST_MOUNT_PATH).toBe("/client-dist");
});

it.each([
"/client-dist",
"/client-dist/toast.client.js",
"/client-dist/view-paywall.client.js.map",
"/toast.client.js",
"/extension-suggestion-banner.client.js",
"/progress-bar.client.js.map",
"/apple-touch-icon.png",
"/apple-touch-icon-120x120-precomposed.png",
])("classifies %s as a static asset", (path) => {
expect(isStaticAssetRequestPath(path)).toBe(true);
});

it.each([
"/",
"/queue",
"/view/fagnerbrack.com/learn-sql-9aceb0bdee03",
"/view/example.com/image.png",
"/fagnerbrack.com/photo.png",
"/example.com",
"/https:/fagnerbrack.com/post",
])("does not classify reader-shaped path %s as a static asset", (path) => {
expect(isStaticAssetRequestPath(path)).toBe(false);
});
});
21 changes: 21 additions & 0 deletions projects/hutch/src/runtime/web/static-asset-paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export const CLIENT_DIST_MOUNT_PATH = "/client-dist";

const APPLE_TOUCH_ICON_PATH = /^\/apple-touch-icon(?:-\d+x\d+)?(?:-precomposed)?\.png$/;

/** A one-segment `*.client.js` path can only be a mount-trimmed client-dist
* bundle: express.static responds terminally, so the router never restores the
* `/client-dist` prefix onto req.url. A reader permalink is always
* `/<host>/<slug>` (2+ segments) and no hostname ends in `.js`, so this shape is
* never a real page — a constraint the express types cannot express. */
const TRIMMED_BUNDLE_PATH = /^\/[a-z0-9-]+\.client\.js(\.map)?$/;

/** True when a request path targets a static asset served outside the page
* router (the /client-dist mount and the apple-touch-icon redirect in
* server.ts), so analytics and click-attribution can exclude it. Deliberately
* not a bare extension regex: reader paths mirror arbitrary source URLs and may
* end `.png`/`.html`. */
export function isStaticAssetRequestPath(path: string): boolean {
if (path === CLIENT_DIST_MOUNT_PATH || path.startsWith(`${CLIENT_DIST_MOUNT_PATH}/`)) return true;
if (APPLE_TOUCH_ICON_PATH.test(path)) return true;
return TRIMMED_BUNDLE_PATH.test(path);
}
63 changes: 60 additions & 3 deletions src/packages/web-analytics/src/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,21 +51,34 @@ function createRes(statusCode = 200): Response & EventEmitter {
return emitter;
}

function captureEvents(req: Partial<Request>, res: Response & EventEmitter): AnalyticsEvent[] {
function captureEvents(
req: Partial<Request>,
res: Response & EventEmitter,
beforeFinish?: () => void,
isStaticAssetPath: (path: string) => boolean = () => false,
): AnalyticsEvent[] {
const { captured, logger } = createCapturingLogger();
const middleware = createAnalyticsMiddleware({
logger,
salt: "test-salt",
now: () => new Date("2026-04-21T10:00:00.000Z"),
isStaticAssetPath,
});
const next: NextFunction = () => {};
middleware(req as Request, res, next);
beforeFinish?.();
res.emit("finish");
return captured;
}

function runMiddleware(req: Partial<Request>, res: Response & EventEmitter): AnalyticsPageview[] {
return captureEvents(req, res).filter((e): e is AnalyticsPageview => e.event === "pageview");
function runMiddleware(
req: Partial<Request>,
res: Response & EventEmitter,
isStaticAssetPath?: (path: string) => boolean,
): AnalyticsPageview[] {
return captureEvents(req, res, undefined, isStaticAssetPath).filter(
(e): e is AnalyticsPageview => e.event === "pageview",
);
}

function runMiddlewareClicks(req: Partial<Request>, res: Response & EventEmitter): AnalyticsClick[] {
Expand Down Expand Up @@ -195,6 +208,44 @@ describe("createAnalyticsMiddleware", () => {
});
});

describe("createAnalyticsMiddleware — asset & path hygiene", () => {
it("drops a request the injected isStaticAssetPath flags — asset classification is supplied by the host app (hutch's static routes), not built into the shared middleware", () => {
const events = runMiddleware(
createReq({ path: "/client-dist/toast.client.js" }),
createRes(200),
(path) => path.startsWith("/client-dist/"),
);
expect(events).toEqual([]);
});

it("logs a path the injected isStaticAssetPath does not flag, even one ending in a file extension — extension alone never drops a pageview", () => {
const [event] = runMiddleware(createReq({ path: "/view/fagnerbrack.com/photo.png" }), createRes(200));
expect(event.path).toBe("/view/fagnerbrack.com/photo.png");
});

it("does not count a 301 redirect leg as a pageview — the destination logs its own", () => {
expect(runMiddleware(createReq({ path: "/view/https:/example.com/post" }), createRes(301))).toEqual([]);
});

it("does not count a 302 redirect leg as a pageview", () => {
expect(runMiddleware(createReq({ path: "/view" }), createRes(302))).toEqual([]);
});

it("does not count an informational 1xx response as a pageview", () => {
expect(runMiddleware(createReq({ path: "/queue" }), createRes(199))).toEqual([]);
});

it("snapshots req.path at middleware entry so a finish-time mount-trim mutation cannot corrupt the logged pageview path", () => {
const req = createReq({ path: "/view/fagnerbrack.com/learn-sql" });
const res = createRes(200);
const pageviews = captureEvents(req, res, () => {
Object.defineProperty(req, "path", { value: "/fagnerbrack.com/learn-sql", configurable: true });
}).filter((e): e is AnalyticsPageview => e.event === "pageview");
expect(pageviews).toHaveLength(1);
expect(pageviews[0].path).toBe("/view/fagnerbrack.com/learn-sql");
});
});

describe("createAnalyticsMiddleware — internal click events", () => {
const internalQuery = { utm_source: "queue", utm_medium: "internal", utm_content: "subscribe" };

Expand Down Expand Up @@ -248,6 +299,12 @@ describe("createAnalyticsMiddleware — internal click events", () => {
expect(runMiddleware(req, createRes(200))).toEqual([]);
});

it("still counts a click on a 303 redirect (POST-action → See Other) even though 3xx is no longer a pageview", () => {
const req = createReq({ method: "POST", path: "/queue/save", query: internalQuery });
expect(runMiddlewareClicks(req, createRes(303))).toHaveLength(1);
expect(runMiddleware(req, createRes(303))).toEqual([]);
});

it("does not count a click when the response is a 4xx/5xx error", () => {
expect(runMiddlewareClicks(createReq({ query: internalQuery }), createRes(404))).toEqual([]);
});
Expand Down
13 changes: 10 additions & 3 deletions src/packages/web-analytics/src/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,10 +266,16 @@ export type AnalyticsEvent =
| ViewOpenedEvent
| ViewSaveIntentEvent;

function shouldLog(params: { req: Request; path: string; statusCode: number }): boolean {
function shouldLog(params: {
req: Request;
path: string;
statusCode: number;
isStaticAssetPath: (path: string) => boolean;
}): boolean {
if (params.req.method !== "GET") return false;
if (SKIP_PATHS.has(params.path)) return false;
if (params.statusCode >= 400) return false;
if (params.isStaticAssetPath(params.path)) return false;
if (params.statusCode < 200 || params.statusCode >= 300) return false;
if (isbot(params.req.get("user-agent"))) return false;
if (params.req.get("hx-request") === "true") return false;
return true;
Expand Down Expand Up @@ -403,6 +409,7 @@ export function createAnalyticsMiddleware(deps: {
logger: HutchLogger.Typed<AnalyticsEvent>;
salt: string;
now: () => Date;
isStaticAssetPath: (path: string) => boolean;
}): RequestHandler {
return (req: Request, res: Response, next: NextFunction) => {
/** Capture the request path up front. A sub-router mount (e.g. blog-site's
Expand All @@ -426,7 +433,7 @@ export function createAnalyticsMiddleware(deps: {
is_authenticated: req.userId ? 1 : 0,
});
}
if (!shouldLog({ req, path, statusCode: res.statusCode })) return;
if (!shouldLog({ req, path, statusCode: res.statusCode, isStaticAssetPath: deps.isStaticAssetPath })) return;
const userAgent = req.get("user-agent");
deps.logger.info({
stream: STREAMS.analytics,
Expand Down
Loading
Loading