@codaco/analytics 3.1.0 → 4.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/.eslintrc.js +4 -4
- package/.turbo/turbo-build.log +17 -16
- package/.turbo/turbo-test.log +31 -0
- package/README.md +9 -9
- package/dist/index.d.mts +137 -37
- package/dist/index.mjs +99 -67
- package/dist/index.mjs.map +1 -1
- package/jest.config.js +198 -198
- package/package.json +1 -1
- package/src/index.ts +226 -185
- package/src/utils.ts +36 -36
- package/tsconfig.json +10 -10
package/src/index.ts
CHANGED
|
@@ -1,185 +1,226 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import { WebServiceClient } from "@maxmind/geoip2-node";
|
|
3
|
-
import { ensureError, getBaseUrl } from "./utils";
|
|
4
|
-
import z from "zod";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
] as const;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
export type
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
+
// Properties that everything has in common.
|
|
16
|
+
const SharedEventAndErrorSchema = z.object({
|
|
17
|
+
metadata: z.record(z.unknown()).optional(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const EventSchema = z.object({
|
|
21
|
+
type: z.enum(eventTypes),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const ErrorSchema = z.object({
|
|
25
|
+
type: z.literal("Error"),
|
|
26
|
+
error: z
|
|
27
|
+
.object({
|
|
28
|
+
message: z.string(),
|
|
29
|
+
name: z.string(),
|
|
30
|
+
stack: z.string().optional(),
|
|
31
|
+
})
|
|
32
|
+
.strict(),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Raw events are the events that are sent trackEvent.
|
|
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
|
+
// Trackable events are the events that are sent to the route handler.
|
|
43
|
+
const TrackablePropertiesSchema = z.object({
|
|
44
|
+
timestamp: z.string(),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const TrackableEventSchema = z.intersection(
|
|
48
|
+
RawEventSchema,
|
|
49
|
+
TrackablePropertiesSchema
|
|
50
|
+
);
|
|
51
|
+
export type TrackableEvent = z.infer<typeof TrackableEventSchema>;
|
|
52
|
+
|
|
53
|
+
// Dispatchable events are the events that are sent to the platform.
|
|
54
|
+
const DispatchablePropertiesSchema = z.object({
|
|
55
|
+
installationId: z.string(),
|
|
56
|
+
countryISOCode: z.string(),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
export const DispatchableEventSchema = z.intersection(
|
|
60
|
+
TrackableEventSchema,
|
|
61
|
+
DispatchablePropertiesSchema
|
|
62
|
+
);
|
|
63
|
+
export type DispatchableEvent = z.infer<typeof DispatchableEventSchema>;
|
|
64
|
+
|
|
65
|
+
type RouteHandlerConfiguration = {
|
|
66
|
+
platformUrl?: string;
|
|
67
|
+
installationId: string;
|
|
68
|
+
maxMindClient: WebServiceClient;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const createRouteHandler = ({
|
|
72
|
+
platformUrl = "https://analytics.networkcanvas.com",
|
|
73
|
+
installationId,
|
|
74
|
+
maxMindClient,
|
|
75
|
+
}: RouteHandlerConfiguration) => {
|
|
76
|
+
return async (request: NextRequest) => {
|
|
77
|
+
try {
|
|
78
|
+
const incomingEvent = (await request.json()) as unknown;
|
|
79
|
+
|
|
80
|
+
// Validate the event
|
|
81
|
+
const trackableEvent = TrackableEventSchema.safeParse(incomingEvent);
|
|
82
|
+
|
|
83
|
+
if (!trackableEvent.success) {
|
|
84
|
+
console.error("Invalid event:", trackableEvent.error);
|
|
85
|
+
return new Response(JSON.stringify({ error: "Invalid event" }), {
|
|
86
|
+
status: 400,
|
|
87
|
+
headers: {
|
|
88
|
+
"Content-Type": "application/json",
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// We don't want failures in third party services to prevent us from
|
|
94
|
+
// tracking analytics events.
|
|
95
|
+
let countryISOCode = "Unknown";
|
|
96
|
+
try {
|
|
97
|
+
const ip = await fetch("https://api64.ipify.org").then((res) =>
|
|
98
|
+
res.text()
|
|
99
|
+
);
|
|
100
|
+
const { country } = await maxMindClient.country(ip);
|
|
101
|
+
countryISOCode = country?.isoCode ?? "Unknown";
|
|
102
|
+
} catch (e) {
|
|
103
|
+
console.error("Geolocation failed:", e);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const dispatchableEvent: DispatchableEvent = {
|
|
107
|
+
...trackableEvent.data,
|
|
108
|
+
installationId,
|
|
109
|
+
countryISOCode,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// Forward to microservice
|
|
113
|
+
const response = await fetch(`${platformUrl}/api/event`, {
|
|
114
|
+
keepalive: true,
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers: {
|
|
117
|
+
"Content-Type": "application/json",
|
|
118
|
+
},
|
|
119
|
+
body: JSON.stringify(dispatchableEvent),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (!response.ok) {
|
|
123
|
+
let error = `Analytics platform returned an unexpected error: ${response.statusText}`;
|
|
124
|
+
|
|
125
|
+
if (response.status === 400) {
|
|
126
|
+
error = `Analytics platform rejected the event as invalid. Please check the event schema`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (response.status === 404) {
|
|
130
|
+
error = `Analytics platform could not be reached. Please specify a valid platform URL, or check that the platform is online.`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (response.status === 500) {
|
|
134
|
+
error = `Analytics platform returned an internal server error. Please check the platform logs.`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
console.info("⚠️ Analytics platform rejected event.");
|
|
138
|
+
return Response.json(
|
|
139
|
+
{
|
|
140
|
+
error,
|
|
141
|
+
},
|
|
142
|
+
{ status: 500 }
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
console.info("🚀 Analytics event sent to platform!");
|
|
146
|
+
return Response.json({ message: "Event forwarded successfully" });
|
|
147
|
+
} catch (e) {
|
|
148
|
+
const error = ensureError(e);
|
|
149
|
+
console.info("🚫 Internal error with sending analytics event.");
|
|
150
|
+
|
|
151
|
+
return Response.json(
|
|
152
|
+
{ error: `Error in analytics route handler: ${error.message}` },
|
|
153
|
+
{ status: 500 }
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
type ConsumerConfiguration = {
|
|
160
|
+
enabled?: boolean;
|
|
161
|
+
endpoint?: string;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export type EventTrackerReturn = {
|
|
165
|
+
error: string | null;
|
|
166
|
+
success: boolean;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export const makeEventTracker =
|
|
170
|
+
({ enabled = false, endpoint = "/api/analytics" }: ConsumerConfiguration) =>
|
|
171
|
+
async (event: RawEvent): Promise<EventTrackerReturn> => {
|
|
172
|
+
// If analytics is disabled don't send analytics events.
|
|
173
|
+
if (!enabled) {
|
|
174
|
+
console.log("Analytics disabled, not sending event");
|
|
175
|
+
return { error: null, success: true };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const endpointWithHost = getBaseUrl() + endpoint;
|
|
179
|
+
|
|
180
|
+
const eventWithTimeStamp: TrackableEvent = {
|
|
181
|
+
...event,
|
|
182
|
+
timestamp: new Date().toJSON(),
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
const response = await fetch(endpointWithHost, {
|
|
187
|
+
method: "POST",
|
|
188
|
+
keepalive: true,
|
|
189
|
+
body: JSON.stringify(eventWithTimeStamp),
|
|
190
|
+
headers: {
|
|
191
|
+
"Content-Type": "application/json",
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
if (!response.ok) {
|
|
196
|
+
if (response.status === 404) {
|
|
197
|
+
return {
|
|
198
|
+
error: `Analytics endpoint not found, did you forget to add the route?`,
|
|
199
|
+
success: false,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// createRouteHandler will return a 400 if the event failed schema validation.
|
|
204
|
+
if (response.status === 400) {
|
|
205
|
+
return {
|
|
206
|
+
error: `Invalid event sent to analytics endpoint: ${response.statusText}`,
|
|
207
|
+
success: false,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// createRouteHandler will return a 500 for all error states
|
|
212
|
+
return {
|
|
213
|
+
error: `Internal server error when sending analytics event: ${response.statusText}. Check the route handler implementation.`,
|
|
214
|
+
success: false,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return { error: null, success: true };
|
|
219
|
+
} catch (e) {
|
|
220
|
+
const error = ensureError(e);
|
|
221
|
+
return {
|
|
222
|
+
error: `Internal error when sending analytics event: ${error.message}`,
|
|
223
|
+
success: false,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
};
|
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
|
}
|