@codaco/analytics 3.1.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,185 +1,242 @@
1
- import type { NextRequest } from "next/server";
2
- import { WebServiceClient } from "@maxmind/geoip2-node";
3
- import { ensureError, getBaseUrl } from "./utils";
4
- import z from "zod";
5
-
6
- export const eventTypes = [
7
- "AppSetup",
8
- "ProtocolInstalled",
9
- "InterviewStarted",
10
- "InterviewCompleted",
11
- "DataExported",
12
- "Error",
13
- ] as const;
14
-
15
- export type EventType = (typeof eventTypes)[number];
16
- type EventTypeWithoutError = Exclude<EventType, "Error">;
17
-
18
- export const EventsSchema = z.object({
19
- type: z.enum(eventTypes),
20
- installationId: z.string(),
21
- timestamp: z.string(),
22
- isocode: z.string().optional(),
23
- error: z
24
- .object({
25
- message: z.string(),
26
- name: z.string(),
27
- stack: z.string().optional(),
28
- })
29
- .optional(),
30
- metadata: z.record(z.unknown()).optional(),
31
- });
32
-
33
- export type Event = z.infer<typeof EventsSchema>;
34
-
35
- export type AnalyticsEvent = {
36
- type: EventTypeWithoutError;
37
- metadata?: Record<string, unknown>;
38
- };
39
-
40
- export type AnalyticsError = {
41
- type: "Error";
42
- error: Error;
43
- metadata?: Record<string, unknown>;
44
- };
45
-
46
- export type AnalyticsEventOrError = AnalyticsEvent | AnalyticsError;
47
-
48
- export type AnalyticsEventOrErrorWithTimestamp = AnalyticsEventOrError & {
49
- timestamp: string;
50
- };
51
-
52
- type RouteHandlerConfiguration = {
53
- platformUrl?: string;
54
- installationId: string;
55
- maxMindClient: WebServiceClient;
56
- };
57
-
58
- export const createRouteHandler = ({
59
- platformUrl = "https://analytics.networkcanvas.com",
60
- installationId,
61
- maxMindClient,
62
- }: RouteHandlerConfiguration) => {
63
- return async (request: NextRequest) => {
64
- try {
65
- const event =
66
- (await request.json()) as AnalyticsEventOrErrorWithTimestamp;
67
-
68
- const ip = await fetch("https://api64.ipify.org").then((res) =>
69
- res.text()
70
- );
71
-
72
- const { country } = await maxMindClient.country(ip);
73
- const countryCode = country?.isoCode ?? "Unknown";
74
-
75
- const dispatchableEvent: Event = {
76
- ...event,
77
- installationId,
78
- isocode: countryCode,
79
- };
80
-
81
- // Forward to microservice
82
- const response = await fetch(`${platformUrl}/api/event`, {
83
- keepalive: true,
84
- method: "POST",
85
- headers: {
86
- "Content-Type": "application/json",
87
- },
88
- body: JSON.stringify(dispatchableEvent),
89
- });
90
-
91
- if (!response.ok) {
92
- if (response.status === 404) {
93
- console.error(
94
- `Analytics platform not found. Please specify a valid platform URL.`
95
- );
96
- } else if (response.status === 500) {
97
- console.error(
98
- `Internal server error on analytics platform when forwarding event: ${response.statusText}.`
99
- );
100
- } else {
101
- console.error(
102
- `General error when forwarding event: ${response.statusText}`
103
- );
104
- }
105
-
106
- return new Response(
107
- JSON.stringify({ error: "Internal Server Error" }),
108
- {
109
- status: 500,
110
- headers: {
111
- "Content-Type": "application/json",
112
- },
113
- }
114
- );
115
- }
116
-
117
- return new Response(
118
- JSON.stringify({ message: "Event forwarded successfully" }),
119
- {
120
- status: 200,
121
- headers: {
122
- "Content-Type": "application/json",
123
- },
124
- }
125
- );
126
- } catch (e) {
127
- const error = ensureError(e);
128
- console.error("Error in route handler:", error);
129
-
130
- // Return an appropriate error response
131
- return new Response(JSON.stringify({ error: "Internal Server Error" }), {
132
- status: 500,
133
- headers: {
134
- "Content-Type": "application/json",
135
- },
136
- });
137
- }
138
- };
139
- };
140
-
141
- export const makeEventTracker =
142
- (endpoint: string = "/api/analytics") =>
143
- async (event: AnalyticsEventOrError) => {
144
- const endpointWithHost = getBaseUrl() + endpoint;
145
-
146
- const eventWithTimeStamp = {
147
- ...event,
148
- timestamp: new Date(),
149
- };
150
-
151
- try {
152
- const response = await fetch(endpointWithHost, {
153
- method: "POST",
154
- keepalive: true,
155
- body: JSON.stringify(eventWithTimeStamp),
156
- headers: {
157
- "Content-Type": "application/json",
158
- },
159
- });
160
-
161
- if (!response.ok) {
162
- if (response.status === 404) {
163
- console.error(
164
- `Analytics endpoint not found, did you forget to add the route?`
165
- );
166
- return;
167
- }
168
-
169
- if (response.status === 500) {
170
- console.error(
171
- `Internal server error when sending analytics event: ${response.statusText}. Check the route handler implementation.`
172
- );
173
- return;
174
- }
175
-
176
- console.error(
177
- `General error sending analytics event: ${response.statusText}`
178
- );
179
- }
180
- } catch (e) {
181
- const error = ensureError(e);
182
-
183
- console.error("Internal error with analytics:", error.message);
184
- }
185
- };
1
+ import { type NextRequest } from "next/server";
2
+ import { WebServiceClient } from "@maxmind/geoip2-node";
3
+ import { ensureError, getBaseUrl } from "./utils";
4
+ import z from "zod";
5
+
6
+ // Todo: it would be great to work out a way to support arbitrary types here.
7
+ export const eventTypes = [
8
+ "AppSetup",
9
+ "ProtocolInstalled",
10
+ "InterviewStarted",
11
+ "InterviewCompleted",
12
+ "DataExported",
13
+ ] as const;
14
+
15
+ const EventSchema = z.object({
16
+ type: z.enum(eventTypes),
17
+ });
18
+
19
+ const ErrorSchema = z.object({
20
+ type: z.literal("Error"),
21
+ message: z.string(),
22
+ name: z.string(),
23
+ stack: z.string().optional(),
24
+ cause: z.string().optional(),
25
+ });
26
+
27
+ const SharedEventAndErrorSchema = z.object({
28
+ metadata: z.record(z.unknown()).optional(),
29
+ });
30
+
31
+ /**
32
+ * Raw events are the events that are sent trackEvent. They are either general
33
+ * events or errors. We discriminate on the `type` property to determine which
34
+ * schema to use, and then merge the shared properties.
35
+ */
36
+ export const RawEventSchema = z.discriminatedUnion("type", [
37
+ SharedEventAndErrorSchema.merge(EventSchema),
38
+ SharedEventAndErrorSchema.merge(ErrorSchema),
39
+ ]);
40
+ export type RawEvent = z.infer<typeof RawEventSchema>;
41
+
42
+ /**
43
+ * Trackable events are the events that are sent to the route handler. The
44
+ * `trackEvent` function adds the timestamp to ensure it is not inaccurate
45
+ * due to network latency or processing time.
46
+ */
47
+ const TrackablePropertiesSchema = z.object({
48
+ timestamp: z.string(),
49
+ });
50
+
51
+ export const TrackableEventSchema = z.intersection(
52
+ RawEventSchema,
53
+ TrackablePropertiesSchema
54
+ );
55
+ export type TrackableEvent = z.infer<typeof TrackableEventSchema>;
56
+
57
+ /**
58
+ * Dispatchable events are the events that are sent to the platform. The route
59
+ * handler injects the installationId and countryISOCode properties.
60
+ */
61
+ const DispatchablePropertiesSchema = z.object({
62
+ installationId: z.string(),
63
+ countryISOCode: z.string(),
64
+ });
65
+
66
+ /**
67
+ * The final schema for an analytics event. This is the schema that is used to
68
+ * validate the event before it is inserted into the database. It is the
69
+ * intersection of the trackable event and the dispatchable properties.
70
+ */
71
+ export const AnalyticsEventSchema = z.intersection(
72
+ TrackableEventSchema,
73
+ DispatchablePropertiesSchema
74
+ );
75
+ export type analyticsEvent = z.infer<typeof AnalyticsEventSchema>;
76
+
77
+ export const createRouteHandler = ({
78
+ platformUrl = "https://analytics.networkcanvas.com",
79
+ installationId,
80
+ maxMindClient,
81
+ }: {
82
+ platformUrl?: string;
83
+ installationId: string;
84
+ maxMindClient: WebServiceClient;
85
+ }) => {
86
+ return async (request: NextRequest) => {
87
+ try {
88
+ const incomingEvent = (await request.json()) as unknown;
89
+
90
+ // Validate the event
91
+ const trackableEvent = TrackableEventSchema.safeParse(incomingEvent);
92
+
93
+ if (!trackableEvent.success) {
94
+ console.error("Invalid event:", trackableEvent.error);
95
+ return new Response(JSON.stringify({ error: "Invalid event" }), {
96
+ status: 400,
97
+ headers: {
98
+ "Content-Type": "application/json",
99
+ },
100
+ });
101
+ }
102
+
103
+ // We don't want failures in third party services to prevent us from
104
+ // tracking analytics events, so we'll catch any errors and log them
105
+ // and continue with an 'Unknown' country code.
106
+ let countryISOCode = "Unknown";
107
+ try {
108
+ const ip = await fetch("https://api64.ipify.org").then((res) =>
109
+ res.text()
110
+ );
111
+
112
+ if (!ip) {
113
+ throw new Error("Could not fetch IP address");
114
+ }
115
+
116
+ const { country } = await maxMindClient.country(ip);
117
+ countryISOCode = country?.isoCode ?? "Unknown";
118
+ } catch (e) {
119
+ console.error("Geolocation failed:", e);
120
+ }
121
+
122
+ const analyticsEvent: analyticsEvent = {
123
+ ...trackableEvent.data,
124
+ installationId,
125
+ countryISOCode,
126
+ };
127
+
128
+ // Forward to backend
129
+ const response = await fetch(`${platformUrl}/api/event`, {
130
+ keepalive: true,
131
+ method: "POST",
132
+ headers: {
133
+ "Content-Type": "application/json",
134
+ },
135
+ body: JSON.stringify(analyticsEvent),
136
+ });
137
+
138
+ if (!response.ok) {
139
+ let error = `Analytics platform returned an unexpected error: ${response.statusText}`;
140
+
141
+ if (response.status === 400) {
142
+ error = `Analytics platform rejected the event as invalid. Please check the event schema`;
143
+ }
144
+
145
+ if (response.status === 404) {
146
+ error = `Analytics platform could not be reached. Please specify a valid platform URL, or check that the platform is online.`;
147
+ }
148
+
149
+ if (response.status === 500) {
150
+ error = `Analytics platform returned an internal server error. Please check the platform logs.`;
151
+ }
152
+
153
+ console.info(`⚠️ Analytics platform rejected event: ${error}`);
154
+ return Response.json(
155
+ {
156
+ error,
157
+ },
158
+ { status: 500 }
159
+ );
160
+ }
161
+ console.info("🚀 Analytics event sent to platform!");
162
+ return Response.json({ message: "Event forwarded successfully" });
163
+ } catch (e) {
164
+ const error = ensureError(e);
165
+ console.info("🚫 Internal error with sending analytics event.");
166
+
167
+ return Response.json(
168
+ { error: `Error in analytics route handler: ${error.message}` },
169
+ { status: 500 }
170
+ );
171
+ }
172
+ };
173
+ };
174
+
175
+ export const makeEventTracker =
176
+ ({
177
+ enabled = false,
178
+ endpoint = "/api/analytics",
179
+ }: {
180
+ enabled?: boolean;
181
+ endpoint?: string;
182
+ }) =>
183
+ async (
184
+ event: RawEvent
185
+ ): Promise<{
186
+ error: string | null;
187
+ success: boolean;
188
+ }> => {
189
+ if (!enabled) {
190
+ console.log("Analytics disabled - event not sent.");
191
+ return { error: null, success: true };
192
+ }
193
+
194
+ const endpointWithHost = getBaseUrl() + endpoint;
195
+
196
+ const eventWithTimeStamp: TrackableEvent = {
197
+ ...event,
198
+ timestamp: new Date().toJSON(),
199
+ };
200
+
201
+ try {
202
+ const response = await fetch(endpointWithHost, {
203
+ method: "POST",
204
+ keepalive: true,
205
+ body: JSON.stringify(eventWithTimeStamp),
206
+ headers: {
207
+ "Content-Type": "application/json",
208
+ },
209
+ });
210
+
211
+ if (!response.ok) {
212
+ if (response.status === 404) {
213
+ return {
214
+ error: `Analytics endpoint not found, did you forget to add the route?`,
215
+ success: false,
216
+ };
217
+ }
218
+
219
+ // createRouteHandler will return a 400 if the event failed schema validation.
220
+ if (response.status === 400) {
221
+ return {
222
+ error: `Invalid event sent to analytics endpoint: ${response.statusText}`,
223
+ success: false,
224
+ };
225
+ }
226
+
227
+ // createRouteHandler will return a 500 for all error states
228
+ return {
229
+ error: `Internal server error when sending analytics event: ${response.statusText}. Check the route handler implementation.`,
230
+ success: false,
231
+ };
232
+ }
233
+
234
+ return { error: null, success: true };
235
+ } catch (e) {
236
+ const error = ensureError(e);
237
+ return {
238
+ error: `Internal error when sending analytics event: ${error.message}`,
239
+ success: false,
240
+ };
241
+ }
242
+ };
package/src/utils.ts CHANGED
@@ -1,36 +1,36 @@
1
- // Helper function that ensures that a value is an Error
2
- export function ensureError(value: unknown): Error {
3
- if (!value) return new Error("No value was thrown");
4
-
5
- if (value instanceof Error) return value;
6
-
7
- // Test if value inherits from Error
8
- if (value.isPrototypeOf(Error)) return value as Error & typeof value;
9
-
10
- let stringified = "[Unable to stringify the thrown value]";
11
- try {
12
- stringified = JSON.stringify(value);
13
- } catch {}
14
-
15
- const error = new Error(
16
- `This value was thrown as is, not through an Error: ${stringified}`
17
- );
18
- return error;
19
- }
20
-
21
- export function getBaseUrl() {
22
- if (typeof window !== "undefined")
23
- // browser should use relative path
24
- return "";
25
-
26
- if (process.env.VERCEL_URL)
27
- // reference for vercel.com
28
- return `https://${process.env.VERCEL_URL}`;
29
-
30
- if (process.env.NEXT_PUBLIC_URL)
31
- // Manually set deployment URL from env
32
- return process.env.NEXT_PUBLIC_URL;
33
-
34
- // assume localhost
35
- return `http://127.0.0.1:3000`;
36
- }
1
+ // Helper function that ensures that a value is an Error
2
+ export function ensureError(value: unknown): Error {
3
+ if (!value) return new Error("No value was thrown");
4
+
5
+ if (value instanceof Error) return value;
6
+
7
+ // Test if value inherits from Error
8
+ if (value.isPrototypeOf(Error)) return value as Error & typeof value;
9
+
10
+ let stringified = "[Unable to stringify the thrown value]";
11
+ try {
12
+ stringified = JSON.stringify(value);
13
+ } catch {}
14
+
15
+ const error = new Error(
16
+ `This value was thrown as is, not through an Error: ${stringified}`
17
+ );
18
+ return error;
19
+ }
20
+
21
+ export function getBaseUrl() {
22
+ if (typeof window !== "undefined")
23
+ // browser should use relative path
24
+ return "";
25
+
26
+ if (process.env.VERCEL_URL)
27
+ // reference for vercel.com
28
+ return `https://${process.env.VERCEL_URL}`;
29
+
30
+ if (process.env.NEXT_PUBLIC_URL)
31
+ // Manually set deployment URL from env
32
+ return process.env.NEXT_PUBLIC_URL;
33
+
34
+ // assume localhost
35
+ return `http://127.0.0.1:3000`;
36
+ }
package/tsconfig.json CHANGED
@@ -1,11 +1,11 @@
1
- {
2
- "extends": "tsconfig/react-library.json",
3
- "include": [
4
- "."
5
- ],
6
- "exclude": [
7
- "dist",
8
- "build",
9
- "node_modules"
10
- ]
1
+ {
2
+ "extends": "tsconfig/react-library.json",
3
+ "include": [
4
+ "."
5
+ ],
6
+ "exclude": [
7
+ "dist",
8
+ "build",
9
+ "node_modules"
10
+ ]
11
11
  }
package/dist/index.d.mts DELETED
@@ -1,72 +0,0 @@
1
- import { NextRequest } from 'next/server';
2
- import { WebServiceClient } from '@maxmind/geoip2-node';
3
- import z from 'zod';
4
-
5
- declare const eventTypes: readonly ["AppSetup", "ProtocolInstalled", "InterviewStarted", "InterviewCompleted", "DataExported", "Error"];
6
- type EventType = (typeof eventTypes)[number];
7
- type EventTypeWithoutError = Exclude<EventType, "Error">;
8
- declare const EventsSchema: z.ZodObject<{
9
- type: z.ZodEnum<["AppSetup", "ProtocolInstalled", "InterviewStarted", "InterviewCompleted", "DataExported", "Error"]>;
10
- installationId: z.ZodString;
11
- timestamp: z.ZodString;
12
- isocode: z.ZodOptional<z.ZodString>;
13
- error: z.ZodOptional<z.ZodObject<{
14
- message: z.ZodString;
15
- name: z.ZodString;
16
- stack: z.ZodOptional<z.ZodString>;
17
- }, "strip", z.ZodTypeAny, {
18
- message: string;
19
- name: string;
20
- stack?: string | undefined;
21
- }, {
22
- message: string;
23
- name: string;
24
- stack?: string | undefined;
25
- }>>;
26
- metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
27
- }, "strip", z.ZodTypeAny, {
28
- type: "AppSetup" | "ProtocolInstalled" | "InterviewStarted" | "InterviewCompleted" | "DataExported" | "Error";
29
- installationId: string;
30
- timestamp: string;
31
- isocode?: string | undefined;
32
- error?: {
33
- message: string;
34
- name: string;
35
- stack?: string | undefined;
36
- } | undefined;
37
- metadata?: Record<string, unknown> | undefined;
38
- }, {
39
- type: "AppSetup" | "ProtocolInstalled" | "InterviewStarted" | "InterviewCompleted" | "DataExported" | "Error";
40
- installationId: string;
41
- timestamp: string;
42
- isocode?: string | undefined;
43
- error?: {
44
- message: string;
45
- name: string;
46
- stack?: string | undefined;
47
- } | undefined;
48
- metadata?: Record<string, unknown> | undefined;
49
- }>;
50
- type Event = z.infer<typeof EventsSchema>;
51
- type AnalyticsEvent = {
52
- type: EventTypeWithoutError;
53
- metadata?: Record<string, unknown>;
54
- };
55
- type AnalyticsError = {
56
- type: "Error";
57
- error: Error;
58
- metadata?: Record<string, unknown>;
59
- };
60
- type AnalyticsEventOrError = AnalyticsEvent | AnalyticsError;
61
- type AnalyticsEventOrErrorWithTimestamp = AnalyticsEventOrError & {
62
- timestamp: string;
63
- };
64
- type RouteHandlerConfiguration = {
65
- platformUrl?: string;
66
- installationId: string;
67
- maxMindClient: WebServiceClient;
68
- };
69
- declare const createRouteHandler: ({ platformUrl, installationId, maxMindClient, }: RouteHandlerConfiguration) => (request: NextRequest) => Promise<Response>;
70
- declare const makeEventTracker: (endpoint?: string) => (event: AnalyticsEventOrError) => Promise<void>;
71
-
72
- export { AnalyticsError, AnalyticsEvent, AnalyticsEventOrError, AnalyticsEventOrErrorWithTimestamp, Event, EventType, EventsSchema, createRouteHandler, eventTypes, makeEventTracker };