diff --git a/apps/analytics/app/api/event/route.ts b/apps/analytics/app/api/event/route.ts index abd65540..bc1708f2 100644 --- a/apps/analytics/app/api/event/route.ts +++ b/apps/analytics/app/api/event/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import insertEvent from "~/db/insertEvent"; -import { DispatchableEventSchema } from "@codaco/analytics"; +import { AnalyticsEventSchema } from "@codaco/analytics"; // Allow CORS requests from anywhere. const corsHeaders = { @@ -13,7 +13,7 @@ export const runtime = "edge"; export async function POST(request: NextRequest) { const event = (await request.json()) as unknown; - const parsedEvent = DispatchableEventSchema.safeParse(event); + const parsedEvent = AnalyticsEventSchema.safeParse(event); if (!parsedEvent.success) { return NextResponse.json( @@ -24,12 +24,6 @@ export async function POST(request: NextRequest) { const formattedEvent = { ...parsedEvent.data, - ...(parsedEvent.data.type === "Error" && { - type: "Error", - message: parsedEvent.data.error.message, - name: parsedEvent.data.error.name, - stack: parsedEvent.data.error.stack, - }), timestamp: new Date(parsedEvent.data.timestamp), // Convert back into a date object }; diff --git a/packages/analytics/package.json b/packages/analytics/package.json index 50de2c9c..a62c6568 100644 --- a/packages/analytics/package.json +++ b/packages/analytics/package.json @@ -1,6 +1,6 @@ { "name": "@codaco/analytics", - "version": "4.0.0", + "version": "5.0.0", "module": "./dist/index.mjs", "types": "./dist/index.d.mts", "author": "Complex Data Collective ", diff --git a/packages/analytics/src/index.ts b/packages/analytics/src/index.ts index b0cb2fb5..a54c8bbe 100644 --- a/packages/analytics/src/index.ts +++ b/packages/analytics/src/index.ts @@ -12,34 +12,38 @@ export const eventTypes = [ "DataExported", ] as const; -// Properties that everything has in common. -const SharedEventAndErrorSchema = z.object({ - metadata: z.record(z.unknown()).optional(), -}); - const EventSchema = z.object({ type: z.enum(eventTypes), }); const ErrorSchema = z.object({ type: z.literal("Error"), - error: z - .object({ - message: z.string(), - name: z.string(), - stack: z.string().optional(), - }) - .strict(), + message: z.string(), + name: z.string(), + stack: z.string().optional(), + cause: z.string().optional(), +}); + +const SharedEventAndErrorSchema = z.object({ + metadata: z.record(z.unknown()).optional(), }); -// Raw events are the events that are sent trackEvent. +/** + * Raw events are the events that are sent trackEvent. They are either general + * events or errors. We discriminate on the `type` property to determine which + * schema to use, and then merge the shared properties. + */ export const RawEventSchema = z.discriminatedUnion("type", [ SharedEventAndErrorSchema.merge(EventSchema), SharedEventAndErrorSchema.merge(ErrorSchema), ]); export type RawEvent = z.infer; -// Trackable events are the events that are sent to the route handler. +/** + * Trackable events are the events that are sent to the route handler. The + * `trackEvent` function adds the timestamp to ensure it is not inaccurate + * due to network latency or processing time. + */ const TrackablePropertiesSchema = z.object({ timestamp: z.string(), }); @@ -50,29 +54,35 @@ export const TrackableEventSchema = z.intersection( ); export type TrackableEvent = z.infer; -// Dispatchable events are the events that are sent to the platform. +/** + * Dispatchable events are the events that are sent to the platform. The route + * handler injects the installationId and countryISOCode properties. + */ const DispatchablePropertiesSchema = z.object({ installationId: z.string(), countryISOCode: z.string(), }); -export const DispatchableEventSchema = z.intersection( +/** + * The final schema for an analytics event. This is the schema that is used to + * validate the event before it is inserted into the database. It is the + * intersection of the trackable event and the dispatchable properties. + */ +export const AnalyticsEventSchema = z.intersection( TrackableEventSchema, DispatchablePropertiesSchema ); -export type DispatchableEvent = z.infer; - -type RouteHandlerConfiguration = { - platformUrl?: string; - installationId: string; - maxMindClient: WebServiceClient; -}; +export type analyticsEvent = z.infer; export const createRouteHandler = ({ platformUrl = "https://analytics.networkcanvas.com", installationId, maxMindClient, -}: RouteHandlerConfiguration) => { +}: { + platformUrl?: string; + installationId: string; + maxMindClient: WebServiceClient; +}) => { return async (request: NextRequest) => { try { const incomingEvent = (await request.json()) as unknown; @@ -91,32 +101,38 @@ export const createRouteHandler = ({ } // We don't want failures in third party services to prevent us from - // tracking analytics events. + // tracking analytics events, so we'll catch any errors and log them + // and continue with an 'Unknown' country code. let countryISOCode = "Unknown"; try { const ip = await fetch("https://api64.ipify.org").then((res) => res.text() ); + + if (!ip) { + throw new Error("Could not fetch IP address"); + } + const { country } = await maxMindClient.country(ip); countryISOCode = country?.isoCode ?? "Unknown"; } catch (e) { console.error("Geolocation failed:", e); } - const dispatchableEvent: DispatchableEvent = { + const analyticsEvent: analyticsEvent = { ...trackableEvent.data, installationId, countryISOCode, }; - // Forward to microservice + // Forward to backend const response = await fetch(`${platformUrl}/api/event`, { keepalive: true, method: "POST", headers: { "Content-Type": "application/json", }, - body: JSON.stringify(dispatchableEvent), + body: JSON.stringify(analyticsEvent), }); if (!response.ok) { @@ -134,7 +150,7 @@ export const createRouteHandler = ({ error = `Analytics platform returned an internal server error. Please check the platform logs.`; } - console.info("⚠️ Analytics platform rejected event."); + console.info(`⚠️ Analytics platform rejected event: ${error}`); return Response.json( { error, @@ -156,22 +172,22 @@ export const createRouteHandler = ({ }; }; -type ConsumerConfiguration = { - enabled?: boolean; - endpoint?: string; -}; - -export type EventTrackerReturn = { - error: string | null; - success: boolean; -}; - export const makeEventTracker = - ({ enabled = false, endpoint = "/api/analytics" }: ConsumerConfiguration) => - async (event: RawEvent): Promise => { - // If analytics is disabled don't send analytics events. + ({ + enabled = false, + endpoint = "/api/analytics", + }: { + enabled?: boolean; + endpoint?: string; + }) => + async ( + event: RawEvent + ): Promise<{ + error: string | null; + success: boolean; + }> => { if (!enabled) { - console.log("Analytics disabled, not sending event"); + console.log("Analytics disabled - event not sent."); return { error: null, success: true }; }