diff --git a/api.planx.uk/modules/analytics/controller.ts b/api.planx.uk/modules/analytics/controller.ts new file mode 100644 index 0000000000..d47ec08e60 --- /dev/null +++ b/api.planx.uk/modules/analytics/controller.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; +import { trackAnalyticsLogExit } from "./service"; +import { ValidatedRequestHandler } from "../../shared/middleware/validate"; + +export const logAnalyticsSchema = z.object({ + query: z.object({ + analyticsLogId: z.string(), + }), +}); + +export type LogAnalytics = ValidatedRequestHandler< + typeof logAnalyticsSchema, + Record +>; + +export const logUserExitController: LogAnalytics = async (req, res) => { + const { analyticsLogId } = req.query; + trackAnalyticsLogExit({ id: Number(analyticsLogId), isUserExit: true }); + res.status(204).send(); +}; + +export const logUserResumeController: LogAnalytics = async (req, res) => { + const { analyticsLogId } = req.query; + trackAnalyticsLogExit({ id: Number(analyticsLogId), isUserExit: false }); + res.status(204).send(); +}; diff --git a/api.planx.uk/modules/analytics/docs.yaml b/api.planx.uk/modules/analytics/docs.yaml new file mode 100644 index 0000000000..bfd6f25e0a --- /dev/null +++ b/api.planx.uk/modules/analytics/docs.yaml @@ -0,0 +1,29 @@ +openapi: 3.1.0 +info: + title: Planâś• API + version: 0.1.0 +tags: + - name: analytics +components: + responses: + AnalyticsResponse: + description: Successful response with no content. Not awaited from server as endpoint is called via the Beacon API +paths: + /analytics/log-user-exit: + post: + summary: Log user exit + description: Capture an analytic event which represents a user exiting a service + tags: + - analytics + responses: + "204": + $ref: "#/components/responses/AnalyticsResponse" + /analytics/log-user-resume: + post: + summary: Log user resume + description: Capture an analytic event which represents a user resuming a service + tags: + - analytics + responses: + "204": + $ref: "#/components/responses/AnalyticsResponse" diff --git a/api.planx.uk/modules/analytics/index.test.ts b/api.planx.uk/modules/analytics/index.test.ts new file mode 100644 index 0000000000..8ec20b9d44 --- /dev/null +++ b/api.planx.uk/modules/analytics/index.test.ts @@ -0,0 +1,99 @@ +import supertest from "supertest"; +import app from "../../server"; +import { queryMock } from "../../tests/graphqlQueryMock"; + +describe("Logging analytics", () => { + beforeEach(() => { + queryMock.mockQuery({ + name: "SetAnalyticsEndedDate", + matchOnVariables: false, + data: { + update_analytics_by_pk: { + id: 12345, + }, + }, + }); + }); + + it("validates that analyticsLogId is present in the query", async () => { + await supertest(app) + .post("/analytics/log-user-exit") + .query({}) + .expect(400) + .then((res) => { + expect(res.body).toHaveProperty("issues"); + expect(res.body).toHaveProperty("name", "ZodError"); + }); + }); + + it("logs a user exit", async () => { + queryMock.mockQuery({ + name: "UpdateAnalyticsLogUserExit", + variables: { + id: 123, + user_exit: true, + }, + data: { + update_analytics_logs_by_pk: { + analytics_id: 12345, + }, + }, + }); + + await supertest(app) + .post("/analytics/log-user-exit") + .query({ analyticsLogId: "123" }) + .expect(204) + .then((res) => { + expect(res.body).toEqual({}); + }); + }); + + it("logs a user resume", async () => { + queryMock.mockQuery({ + name: "UpdateAnalyticsLogUserExit", + variables: { + id: 456, + user_exit: false, + }, + data: { + update_analytics_logs_by_pk: { + analytics_id: 12345, + }, + }, + }); + + await supertest(app) + .post("/analytics/log-user-resume") + .query({ analyticsLogId: "456" }) + .expect(204) + .then((res) => { + expect(res.body).toEqual({}); + }); + }); + + it("handles errors whilst writing analytics records", async () => { + queryMock.mockQuery({ + name: "UpdateAnalyticsLogUserExit", + matchOnVariables: false, + data: { + update_analytics_logs_by_pk: { + analytics_id: 12345, + }, + }, + graphqlErrors: [ + { + message: "Something went wrong", + }, + ], + }); + + await supertest(app) + .post("/analytics/log-user-resume") + .query({ analyticsLogId: "456" }) + .expect(204) + .then((res) => { + expect(res.body).toEqual({}); + }); + }); +}); diff --git a/api.planx.uk/modules/analytics/routes.ts b/api.planx.uk/modules/analytics/routes.ts new file mode 100644 index 0000000000..8bab98574f --- /dev/null +++ b/api.planx.uk/modules/analytics/routes.ts @@ -0,0 +1,22 @@ +import { validate } from "./../../shared/middleware/validate"; +import { Router } from "express"; +import { + logAnalyticsSchema, + logUserExitController, + logUserResumeController, +} from "./controller"; + +const router = Router(); + +router.post( + "/log-user-exit", + validate(logAnalyticsSchema), + logUserExitController, +); +router.post( + "/log-user-resume", + validate(logAnalyticsSchema), + logUserResumeController, +); + +export default router; diff --git a/api.planx.uk/modules/analytics/service.ts b/api.planx.uk/modules/analytics/service.ts new file mode 100644 index 0000000000..01018a2eb7 --- /dev/null +++ b/api.planx.uk/modules/analytics/service.ts @@ -0,0 +1,57 @@ +import { gql } from "graphql-request"; +import { adminGraphQLClient as adminClient } from "../../hasura"; + +export const trackAnalyticsLogExit = async ({ + id, + isUserExit, +}: { + id: number; + isUserExit: boolean; +}) => { + try { + const result = await adminClient.request( + gql` + mutation UpdateAnalyticsLogUserExit($id: bigint!, $user_exit: Boolean) { + update_analytics_logs_by_pk( + pk_columns: { id: $id } + _set: { user_exit: $user_exit } + ) { + id + user_exit + analytics_id + } + } + `, + { + id, + user_exit: isUserExit, + }, + ); + + const analyticsId = result.update_analytics_logs_by_pk.analytics_id; + await adminClient.request( + gql` + mutation SetAnalyticsEndedDate($id: bigint!, $ended_at: timestamptz) { + update_analytics_by_pk( + pk_columns: { id: $id } + _set: { ended_at: $ended_at } + ) { + id + } + } + `, + { + id: analyticsId, + ended_at: isUserExit ? new Date().toISOString() : null, + }, + ); + } catch (e) { + // We need to catch this exception here otherwise the exception would become an unhandled rejection which brings down the whole node.js process + console.error( + "There's been an error while recording metrics for analytics but because this thread is non-blocking we didn't reject the request", + (e as Error).stack, + ); + } + + return; +}; diff --git a/api.planx.uk/server.ts b/api.planx.uk/server.ts index bb8e293c08..dbfbe68536 100644 --- a/api.planx.uk/server.ts +++ b/api.planx.uk/server.ts @@ -70,6 +70,7 @@ import teamRoutes from "./modules/team/routes"; import miscRoutes from "./modules/misc/routes"; import userRoutes from "./modules/user/routes"; import webhookRoutes from "./modules/webhooks/routes"; +import analyticsRoutes from "./modules/analytics/routes"; import { useSwaggerDocs } from "./docs"; import { Role } from "@opensystemslab/planx-core/types"; @@ -187,6 +188,7 @@ app.use(miscRoutes); app.use("/user", userRoutes); app.use("/team", teamRoutes); app.use("/webhooks", webhookRoutes); +app.use("/analytics", analyticsRoutes); app.use("/gis", router); @@ -373,67 +375,6 @@ app.get( privateDownloadController, ); -const trackAnalyticsLogExit = async (id: number, isUserExit: boolean) => { - try { - const result = await adminClient.request( - gql` - mutation UpdateAnalyticsLogUserExit($id: bigint!, $user_exit: Boolean) { - update_analytics_logs_by_pk( - pk_columns: { id: $id } - _set: { user_exit: $user_exit } - ) { - id - user_exit - analytics_id - } - } - `, - { - id, - user_exit: isUserExit, - }, - ); - - const analytics_id = result.update_analytics_logs_by_pk.analytics_id; - await adminClient.request( - gql` - mutation SetAnalyticsEndedDate($id: bigint!, $ended_at: timestamptz) { - update_analytics_by_pk( - pk_columns: { id: $id } - _set: { ended_at: $ended_at } - ) { - id - } - } - `, - { - id: analytics_id, - ended_at: isUserExit ? new Date().toISOString() : null, - }, - ); - } catch (e) { - // We need to catch this exception here otherwise the exception would become an unhandle rejection which brings down the whole node.js process - console.error( - "There's been an error while recording metrics for analytics but because this thread is non-blocking we didn't reject the request", - (e as Error).stack, - ); - } - - return; -}; - -app.post("/analytics/log-user-exit", async (req, res) => { - const analyticsLogId = Number(req.query.analyticsLogId); - if (analyticsLogId > 0) trackAnalyticsLogExit(analyticsLogId, true); - res.send(); -}); - -app.post("/analytics/log-user-resume", async (req, res) => { - const analyticsLogId = Number(req.query.analyticsLogId); - if (analyticsLogId > 0) trackAnalyticsLogExit(analyticsLogId, false); - res.send(); -}); - assert(process.env.GOVUK_NOTIFY_API_KEY); app.post( "/send-email/:template",