From 097816e36c17e646f462d50ed91a2625cf00e8c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Mon, 9 Oct 2023 13:39:07 +0100 Subject: [PATCH 1/2] feat: Modularise analytics endpoints --- api.planx.uk/modules/analytics/controller.ts | 26 +++++ api.planx.uk/modules/analytics/docs.yaml | 25 +++++ api.planx.uk/modules/analytics/index.test.ts | 99 ++++++++++++++++++++ api.planx.uk/modules/analytics/routes.ts | 22 +++++ api.planx.uk/modules/analytics/service.ts | 57 +++++++++++ api.planx.uk/server.ts | 63 +------------ 6 files changed, 231 insertions(+), 61 deletions(-) create mode 100644 api.planx.uk/modules/analytics/controller.ts create mode 100644 api.planx.uk/modules/analytics/docs.yaml create mode 100644 api.planx.uk/modules/analytics/index.test.ts create mode 100644 api.planx.uk/modules/analytics/routes.ts create mode 100644 api.planx.uk/modules/analytics/service.ts 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..312660c303 --- /dev/null +++ b/api.planx.uk/modules/analytics/docs.yaml @@ -0,0 +1,25 @@ +openapi: 3.1.0 +info: + title: Planâś• API + version: 0.1.0 +tags: + - name: analytics +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": + description: Successful response - no awaited response from server + /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": + description: Successful response - no awaited response from server 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", From e44dfd9cd6f8bc9fa91648313577296192fd524a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Tue, 10 Oct 2023 09:03:51 +0100 Subject: [PATCH 2/2] docs: Improve response description --- api.planx.uk/modules/analytics/docs.yaml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/api.planx.uk/modules/analytics/docs.yaml b/api.planx.uk/modules/analytics/docs.yaml index 312660c303..bfd6f25e0a 100644 --- a/api.planx.uk/modules/analytics/docs.yaml +++ b/api.planx.uk/modules/analytics/docs.yaml @@ -4,6 +4,10 @@ info: 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: @@ -13,7 +17,7 @@ paths: - analytics responses: "204": - description: Successful response - no awaited response from server + $ref: "#/components/responses/AnalyticsResponse" /analytics/log-user-resume: post: summary: Log user resume @@ -22,4 +26,4 @@ paths: - analytics responses: "204": - description: Successful response - no awaited response from server + $ref: "#/components/responses/AnalyticsResponse"