diff --git a/api/app.ts b/api/app.ts index 9b55671a..410d940d 100644 --- a/api/app.ts +++ b/api/app.ts @@ -3,6 +3,7 @@ import { Config } from "./config/config"; import logger from "./config/logger"; import { expressConfig } from "./config/express"; import { prometheusConfig } from "./config/prometheus"; +import { posthogConfig } from "./config/posthog"; import { routes } from "./config/routes"; import { rendererConfig } from "./config/renderer"; @@ -11,6 +12,7 @@ export default (config: Config): express.Express => { logger(app, config); expressConfig(app, config); prometheusConfig(app, config); + posthogConfig(app, config); routes(app); rendererConfig(app); return app; diff --git a/api/config/README.md b/api/config/README.md index 3e3bbd10..dd55ff13 100644 --- a/api/config/README.md +++ b/api/config/README.md @@ -40,6 +40,11 @@ GA_KEY PROMETHEUS_USERNAME PROMETHEUS_PASSWORD +# Inserts optional PostHog monitoring middleware +# Captures an `api_request` event per request (normalized path, method, status, duration) +POSTHOG_API_KEY # PostHog project API key. Monitoring is disabled if unset +POSTHOG_HOST # Optional PostHog instance host, e.g. https://eu.i.posthog.com + # Application defaults configuration NEAREST_RADIUS_DEFAULT # /postcodes?lon=&lat= radius default (metres, default 100) NEAREST_RADIUS_MAX # /postcodes?lon=&lat= radius cap (metres, default 2000) diff --git a/api/config/config.ts b/api/config/config.ts index 59ba63a3..ae43e539 100644 --- a/api/config/config.ts +++ b/api/config/config.ts @@ -46,6 +46,8 @@ export interface Config { httpHeaders?: Record; prometheusUsername?: string; prometheusPassword?: string; + posthogApiKey?: string; + posthogHost?: string; } const config: Record = { @@ -125,6 +127,8 @@ export const getConfig = (env?: Env): Config => { LOG_DESTINATION, PROMETHEUS_USERNAME, PROMETHEUS_PASSWORD, + POSTHOG_API_KEY, + POSTHOG_HOST, HTTP_HEADERS, URL_PREFIX, } = process.env; @@ -151,6 +155,9 @@ export const getConfig = (env?: Env): Config => { if (PROMETHEUS_PASSWORD !== undefined) cfg.prometheusPassword = PROMETHEUS_PASSWORD; + if (POSTHOG_API_KEY !== undefined) cfg.posthogApiKey = POSTHOG_API_KEY; + if (POSTHOG_HOST !== undefined) cfg.posthogHost = POSTHOG_HOST; + if (URL_PREFIX !== undefined) cfg.urlPrefix = URL_PREFIX; try { diff --git a/api/config/posthog.ts b/api/config/posthog.ts new file mode 100644 index 00000000..6f5ec026 --- /dev/null +++ b/api/config/posthog.ts @@ -0,0 +1,57 @@ +import { PostHog } from "posthog-node"; +import { Express } from "express"; +import { Config } from "./config"; +import { normalizePath } from "./prometheus"; + +let client: PostHog | undefined; + +/** + * Inserts optional PostHog monitoring middleware + * + * Captures an `api_request` event per request with the normalized path + * (e.g. /postcodes/:postcode), method, status code and duration + * + * Enabled by defining: + * - POSTHOG_API_KEY + * - POSTHOG_HOST (optional, e.g. https://eu.i.posthog.com) + */ +export const posthogConfig = ( + app: Express, + { posthogApiKey, posthogHost }: Config +): void => { + if (posthogApiKey === undefined) return; + + client = new PostHog(posthogApiKey, { + host: posthogHost, + }); + // Never let telemetry failures surface in the API + client.on("error", () => {}); + + app.use((request, response, next) => { + const start = process.hrtime.bigint(); + response.on("finish", () => { + const duration = Number(process.hrtime.bigint() - start) / 1e6; + client?.capture({ + distinctId: request.ip || "unknown", + event: "api_request", + properties: { + method: request.method, + path: normalizePath(request), + status: response.statusCode, + duration_ms: Math.round(duration * 100) / 100, + $process_person_profile: false, + }, + }); + }); + next(); + }); +}; + +/** + * Flushes queued events and closes the PostHog client + */ +export const shutdownPosthog = async (): Promise => { + if (client === undefined) return; + await client.shutdown(); + client = undefined; +}; diff --git a/api/config/prometheus.ts b/api/config/prometheus.ts index eab60e8a..657b7bde 100644 --- a/api/config/prometheus.ts +++ b/api/config/prometheus.ts @@ -30,7 +30,7 @@ const paths: [RegExp, string][] = [ * * e.g. /postcodes/sw1a2aa -> /postcodes/:postcode */ -const normalizePath = (request: Request): string => { +export const normalizePath = (request: Request): string => { for (const [regex, path] of paths) { if (regex.test(request.path)) return path; } diff --git a/api/server.ts b/api/server.ts index 7d522695..5809d599 100644 --- a/api/server.ts +++ b/api/server.ts @@ -3,6 +3,7 @@ const config = getConfig(); import App from "./app"; const app = App(config); import { logger } from "./app/lib/logger"; +import { shutdownPosthog } from "./config/posthog"; const { host } = config; const { port } = config; @@ -14,8 +15,9 @@ const closeSocket = (_: unknown, socket: any) => { server.on("clientError", closeSocket); server.on("connect", closeSocket); -process.on("SIGTERM", () => { +process.on("SIGTERM", async () => { logger.info("Quitting Postcode API"); + await shutdownPosthog(); process.exit(0); }); diff --git a/package.json b/package.json index 30ef8e00..a32257d2 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "pg": "~8.16.3", "pino": "~10.3.1", "postcode": "~5.1.0", + "posthog-node": "~5.46.1", "prom-client": "~15.1.3", "serve-favicon": "~2.5.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf1b8756..0a7885c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,9 @@ importers: postcode: specifier: ~5.1.0 version: 5.1.0 + posthog-node: + specifier: ~5.46.1 + version: 5.46.1 prom-client: specifier: ~15.1.3 version: 15.1.3 @@ -1981,6 +1984,12 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@posthog/core@1.45.1': + resolution: {integrity: sha512-tLtvzomavb2PPWdGYKsusyIzIeL2Px47v348Smibkay7sMy/83TyPk+Ptsp2NdeOgJsbuwSxWkR2+XA0aSCAaA==} + + '@posthog/types@1.398.0': + resolution: {integrity: sha512-sJMkl4k+u8yS/0fjHsKqE9xTdsAh30a2WvgChiptellnVoE0e8QJKFgqOMD2sk8FaEArPdeFklAhXvmENAt3Sg==} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -6586,6 +6595,15 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} + posthog-node@5.46.1: + resolution: {integrity: sha512-WjCqExq44pBdyg9MSsH6UAE0tNZ88p4aIuVFicgqhjf2Fbws6IhS4ioYUa4aBrbUPS9EDRXtBTtF5DpP1ml8Pw==} + engines: {node: ^20.20.0 || >=22.22.0} + peerDependencies: + rxjs: ^7.0.0 + peerDependenciesMeta: + rxjs: + optional: true + postman-code-generators@1.14.2: resolution: {integrity: sha512-qZAyyowfQAFE4MSCu2KtMGGQE/+oG1JhMZMJNMdZHYCSfQiVVeKxgk3oI4+KJ3d1y5rrm2D6C6x+Z+7iyqm+fA==} engines: {node: '>=12'} @@ -11153,6 +11171,12 @@ snapshots: '@polka/url@1.0.0-next.29': {} + '@posthog/core@1.45.1': + dependencies: + '@posthog/types': 1.398.0 + + '@posthog/types@1.398.0': {} + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -16488,6 +16512,10 @@ snapshots: dependencies: xtend: 4.0.2 + posthog-node@5.46.1: + dependencies: + '@posthog/core': 1.45.1 + postman-code-generators@1.14.2: dependencies: async: 3.2.2 diff --git a/test/posthog.integration.ts b/test/posthog.integration.ts new file mode 100644 index 00000000..19fc848a --- /dev/null +++ b/test/posthog.integration.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import request from "supertest"; +import { config, postcodesioApplication } from "./helper"; + +const capture = vi.fn(); +const shutdown = vi.fn(); + +vi.mock("posthog-node", () => ({ + PostHog: vi.fn(function (this: any) { + this.capture = capture; + this.shutdown = shutdown; + this.on = vi.fn(); + }), +})); + +import { PostHog } from "posthog-node"; +import { shutdownPosthog } from "../api/config/posthog"; + +describe("PostHog monitoring", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("when no API key is provided", () => { + it("does not instantiate a client or capture events", async () => { + const app = postcodesioApplication({ ...config }); + await request(app).get("/postcodes/foobar"); + expect(PostHog).not.toHaveBeenCalled(); + expect(capture).not.toHaveBeenCalled(); + }); + }); + + describe("when an API key is provided", () => { + const posthogApiKey = "phc_test"; + const posthogHost = "https://eu.i.posthog.com"; + + const application = () => + postcodesioApplication({ ...config, posthogApiKey, posthogHost }); + + it("instantiates a client with key and host", () => { + application(); + expect(PostHog).toHaveBeenCalledWith(posthogApiKey, { + host: posthogHost, + }); + }); + + it("captures an event with normalized path", async () => { + const app = application(); + await request(app).get("/postcodes/foobar"); + expect(capture).toHaveBeenCalledTimes(1); + const event = capture.mock.calls[0][0]; + expect(event.event).toBe("api_request"); + expect(event.properties.path).toBe("/postcodes/:postcode"); + expect(event.properties.method).toBe("GET"); + expect(event.properties.status).toBe(404); + expect(event.properties.duration_ms).toBeTypeOf("number"); + expect(event.properties.$process_person_profile).toBe(false); + expect(event.distinctId).toBeTypeOf("string"); + }); + + it("squashes unexpected paths to other", async () => { + const app = application(); + await request(app).get("/bogus"); + expect(capture).toHaveBeenCalledTimes(1); + expect(capture.mock.calls[0][0].properties.path).toBe("other"); + }); + + it("flushes the client on shutdown", async () => { + application(); + await shutdownPosthog(); + expect(shutdown).toHaveBeenCalled(); + }); + }); +});