Skip to content
Closed
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
2 changes: 2 additions & 0 deletions api/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions api/config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions api/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export interface Config {
httpHeaders?: Record<string, string>;
prometheusUsername?: string;
prometheusPassword?: string;
posthogApiKey?: string;
posthogHost?: string;
}

const config: Record<Env, Config> = {
Expand Down Expand Up @@ -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;
Expand All @@ -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 {
Expand Down
57 changes: 57 additions & 0 deletions api/config/posthog.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
if (client === undefined) return;
await client.shutdown();
client = undefined;
};
2 changes: 1 addition & 1 deletion api/config/prometheus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
4 changes: 3 additions & 1 deletion api/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);
});

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
28 changes: 28 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

74 changes: 74 additions & 0 deletions test/posthog.integration.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
Loading