@codaco/analytics 5.1.0 → 6.0.0-alpha.1

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.
@@ -1,17 +1,12 @@
1
1
 
2
2
  
3
- > @codaco/analytics@5.0.0 build /Users/jmh629/Projects/error-analytics-microservice/packages/analytics
3
+ > @codaco/analytics@5.1.0 build /Users/jmh629/Projects/network-canvas/packages/analytics
4
4
  > tsup src/index.ts --format esm --dts --clean --sourcemap
5
5
 
6
6
  CLI Building entry: src/index.ts
7
7
  CLI Using tsconfig: tsconfig.json
8
- CLI tsup v7.3.0
8
+ CLI tsup v8.0.2
9
9
  CLI Target: es2022
10
10
  CLI Cleaning output folder
11
11
  ESM Build start
12
- ESM dist/index.js 5.77 KB
13
- ESM dist/index.js.map 11.87 KB
14
- ESM ⚡️ Build success in 18ms
15
12
  DTS Build start
16
- DTS ⚡️ Build success in 907ms
17
- DTS dist/index.d.ts 5.64 KB
package/dist/index.d.ts CHANGED
@@ -7,68 +7,72 @@ declare const eventTypes: readonly ["AppSetup", "ProtocolInstalled", "InterviewS
7
7
  * events or errors. We discriminate on the `type` property to determine which
8
8
  * schema to use, and then merge the shared properties.
9
9
  */
10
- declare const RawEventSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
10
+ declare const RawEventSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<z.objectUtil.extendShape<{
11
11
  metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
12
+ }, {
12
13
  type: z.ZodEnum<["AppSetup", "ProtocolInstalled", "InterviewStarted", "InterviewCompleted", "DataExported"]>;
13
- }, "strip", z.ZodTypeAny, {
14
+ }>, "strip", z.ZodTypeAny, {
14
15
  type: "AppSetup" | "ProtocolInstalled" | "InterviewStarted" | "InterviewCompleted" | "DataExported";
15
16
  metadata?: Record<string, unknown> | undefined;
16
17
  }, {
17
18
  type: "AppSetup" | "ProtocolInstalled" | "InterviewStarted" | "InterviewCompleted" | "DataExported";
18
19
  metadata?: Record<string, unknown> | undefined;
19
- }>, z.ZodObject<{
20
+ }>, z.ZodObject<z.objectUtil.extendShape<{
20
21
  metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
22
+ }, {
21
23
  type: z.ZodLiteral<"Error">;
22
24
  message: z.ZodString;
23
25
  name: z.ZodString;
24
26
  stack: z.ZodOptional<z.ZodString>;
25
27
  cause: z.ZodOptional<z.ZodString>;
26
- }, "strip", z.ZodTypeAny, {
28
+ }>, "strip", z.ZodTypeAny, {
27
29
  type: "Error";
28
30
  message: string;
29
31
  name: string;
30
- metadata?: Record<string, unknown> | undefined;
31
32
  stack?: string | undefined;
32
33
  cause?: string | undefined;
34
+ metadata?: Record<string, unknown> | undefined;
33
35
  }, {
34
36
  type: "Error";
35
37
  message: string;
36
38
  name: string;
37
- metadata?: Record<string, unknown> | undefined;
38
39
  stack?: string | undefined;
39
40
  cause?: string | undefined;
41
+ metadata?: Record<string, unknown> | undefined;
40
42
  }>]>;
41
43
  type RawEvent = z.infer<typeof RawEventSchema>;
42
- declare const TrackableEventSchema: z.ZodIntersection<z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
44
+ declare const TrackableEventSchema: z.ZodIntersection<z.ZodDiscriminatedUnion<"type", [z.ZodObject<z.objectUtil.extendShape<{
43
45
  metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
46
+ }, {
44
47
  type: z.ZodEnum<["AppSetup", "ProtocolInstalled", "InterviewStarted", "InterviewCompleted", "DataExported"]>;
45
- }, "strip", z.ZodTypeAny, {
48
+ }>, "strip", z.ZodTypeAny, {
46
49
  type: "AppSetup" | "ProtocolInstalled" | "InterviewStarted" | "InterviewCompleted" | "DataExported";
47
50
  metadata?: Record<string, unknown> | undefined;
48
51
  }, {
49
52
  type: "AppSetup" | "ProtocolInstalled" | "InterviewStarted" | "InterviewCompleted" | "DataExported";
50
53
  metadata?: Record<string, unknown> | undefined;
51
- }>, z.ZodObject<{
54
+ }>, z.ZodObject<z.objectUtil.extendShape<{
52
55
  metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
56
+ }, {
53
57
  type: z.ZodLiteral<"Error">;
54
58
  message: z.ZodString;
55
59
  name: z.ZodString;
56
60
  stack: z.ZodOptional<z.ZodString>;
57
61
  cause: z.ZodOptional<z.ZodString>;
58
- }, "strip", z.ZodTypeAny, {
62
+ }>, "strip", z.ZodTypeAny, {
59
63
  type: "Error";
60
64
  message: string;
61
65
  name: string;
62
- metadata?: Record<string, unknown> | undefined;
63
66
  stack?: string | undefined;
64
67
  cause?: string | undefined;
68
+ metadata?: Record<string, unknown> | undefined;
65
69
  }, {
66
70
  type: "Error";
67
71
  message: string;
68
72
  name: string;
69
- metadata?: Record<string, unknown> | undefined;
70
73
  stack?: string | undefined;
71
74
  cause?: string | undefined;
75
+ metadata?: Record<string, unknown> | undefined;
72
76
  }>]>, z.ZodObject<{
73
77
  timestamp: z.ZodString;
74
78
  }, "strip", z.ZodTypeAny, {
@@ -82,36 +86,38 @@ type TrackableEvent = z.infer<typeof TrackableEventSchema>;
82
86
  * validate the event before it is inserted into the database. It is the
83
87
  * intersection of the trackable event and the dispatchable properties.
84
88
  */
85
- declare const AnalyticsEventSchema: z.ZodIntersection<z.ZodIntersection<z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
89
+ declare const AnalyticsEventSchema: z.ZodIntersection<z.ZodIntersection<z.ZodDiscriminatedUnion<"type", [z.ZodObject<z.objectUtil.extendShape<{
86
90
  metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
91
+ }, {
87
92
  type: z.ZodEnum<["AppSetup", "ProtocolInstalled", "InterviewStarted", "InterviewCompleted", "DataExported"]>;
88
- }, "strip", z.ZodTypeAny, {
93
+ }>, "strip", z.ZodTypeAny, {
89
94
  type: "AppSetup" | "ProtocolInstalled" | "InterviewStarted" | "InterviewCompleted" | "DataExported";
90
95
  metadata?: Record<string, unknown> | undefined;
91
96
  }, {
92
97
  type: "AppSetup" | "ProtocolInstalled" | "InterviewStarted" | "InterviewCompleted" | "DataExported";
93
98
  metadata?: Record<string, unknown> | undefined;
94
- }>, z.ZodObject<{
99
+ }>, z.ZodObject<z.objectUtil.extendShape<{
95
100
  metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
101
+ }, {
96
102
  type: z.ZodLiteral<"Error">;
97
103
  message: z.ZodString;
98
104
  name: z.ZodString;
99
105
  stack: z.ZodOptional<z.ZodString>;
100
106
  cause: z.ZodOptional<z.ZodString>;
101
- }, "strip", z.ZodTypeAny, {
107
+ }>, "strip", z.ZodTypeAny, {
102
108
  type: "Error";
103
109
  message: string;
104
110
  name: string;
105
- metadata?: Record<string, unknown> | undefined;
106
111
  stack?: string | undefined;
107
112
  cause?: string | undefined;
113
+ metadata?: Record<string, unknown> | undefined;
108
114
  }, {
109
115
  type: "Error";
110
116
  message: string;
111
117
  name: string;
112
- metadata?: Record<string, unknown> | undefined;
113
118
  stack?: string | undefined;
114
119
  cause?: string | undefined;
120
+ metadata?: Record<string, unknown> | undefined;
115
121
  }>]>, z.ZodObject<{
116
122
  timestamp: z.ZodString;
117
123
  }, "strip", z.ZodTypeAny, {
@@ -130,12 +136,11 @@ declare const AnalyticsEventSchema: z.ZodIntersection<z.ZodIntersection<z.ZodDis
130
136
  }>>;
131
137
  type analyticsEvent = z.infer<typeof AnalyticsEventSchema>;
132
138
  declare const createRouteHandler: ({ platformUrl, installationId, }: {
133
- platformUrl?: string | undefined;
139
+ platformUrl?: string;
134
140
  installationId: string;
135
141
  }) => (request: NextRequest) => Promise<Response>;
136
- declare const makeEventTracker: ({ enabled, endpoint, }: {
137
- enabled?: boolean | undefined;
138
- endpoint?: string | undefined;
142
+ declare const makeEventTracker: ({ endpoint }: {
143
+ endpoint?: string;
139
144
  }) => (event: RawEvent) => Promise<{
140
145
  error: string | null;
141
146
  success: boolean;
package/dist/index.js CHANGED
@@ -1,15 +1,19 @@
1
+ // src/index.ts
2
+ import { NextResponse } from "next/server";
3
+
1
4
  // src/utils.ts
2
5
  function ensureError(value) {
3
6
  if (!value)
4
7
  return new Error("No value was thrown");
5
8
  if (value instanceof Error)
6
9
  return value;
7
- if (value.isPrototypeOf(Error))
10
+ if (Object.prototype.isPrototypeOf.call(value, Error))
8
11
  return value;
9
12
  let stringified = "[Unable to stringify the thrown value]";
10
13
  try {
11
14
  stringified = JSON.stringify(value);
12
- } catch {
15
+ } catch (e) {
16
+ console.error(e);
13
17
  }
14
18
  const error = new Error(
15
19
  `This value was thrown as is, not through an Error: ${stringified}`
@@ -74,15 +78,26 @@ var createRouteHandler = ({
74
78
  return async (request) => {
75
79
  try {
76
80
  const incomingEvent = await request.json();
81
+ if (process.env.DISABLE_ANALYTICS) {
82
+ console.info("\u{1F6D1} Analytics disabled. Payload not sent.");
83
+ try {
84
+ console.info(
85
+ "Payload:",
86
+ "\n",
87
+ JSON.stringify(incomingEvent, null, 2)
88
+ );
89
+ } catch (e) {
90
+ console.error("Error stringifying payload:", e);
91
+ }
92
+ return NextResponse.json(
93
+ { message: "Analytics disabled" },
94
+ { status: 200 }
95
+ );
96
+ }
77
97
  const trackableEvent = TrackableEventSchema.safeParse(incomingEvent);
78
98
  if (!trackableEvent.success) {
79
99
  console.error("Invalid event:", trackableEvent.error);
80
- return new Response(JSON.stringify({ error: "Invalid event" }), {
81
- status: 400,
82
- headers: {
83
- "Content-Type": "application/json"
84
- }
85
- });
100
+ return NextResponse.json({ error: "Invalid event" }, { status: 400 });
86
101
  }
87
102
  let countryISOCode = "Unknown";
88
103
  try {
@@ -92,7 +107,9 @@ var createRouteHandler = ({
92
107
  if (!ip) {
93
108
  throw new Error("Could not fetch IP address");
94
109
  }
95
- const geoData = await fetch(`http://ip-api.com/json/${ip}`).then((res) => res.json());
110
+ const geoData = await fetch(`http://ip-api.com/json/${ip}`).then(
111
+ (res) => res.json()
112
+ );
96
113
  if (geoData.status === "success") {
97
114
  countryISOCode = geoData.countryCode;
98
115
  } else {
@@ -145,14 +162,7 @@ var createRouteHandler = ({
145
162
  }
146
163
  };
147
164
  };
148
- var makeEventTracker = ({
149
- enabled = false,
150
- endpoint = "/api/analytics"
151
- }) => async (event) => {
152
- if (!enabled) {
153
- console.log("Analytics disabled - event not sent.");
154
- return { error: null, success: true };
155
- }
165
+ var makeEventTracker = ({ endpoint = "/api/analytics" }) => async (event) => {
156
166
  const endpointWithHost = getBaseUrl() + endpoint;
157
167
  const eventWithTimeStamp = {
158
168
  ...event,
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/utils.ts","../src/index.ts"],"sourcesContent":["// Helper function that ensures that a value is an Error\nexport function ensureError(value: unknown): Error {\n if (!value) return new Error(\"No value was thrown\");\n\n if (value instanceof Error) return value;\n\n // Test if value inherits from Error\n if (value.isPrototypeOf(Error)) return value as Error & typeof value;\n\n let stringified = \"[Unable to stringify the thrown value]\";\n try {\n stringified = JSON.stringify(value);\n } catch {}\n\n const error = new Error(\n `This value was thrown as is, not through an Error: ${stringified}`\n );\n return error;\n}\n\nexport function getBaseUrl() {\n if (typeof window !== \"undefined\")\n // browser should use relative path\n return \"\";\n\n if (process.env.VERCEL_URL)\n // reference for vercel.com\n return `https://${process.env.VERCEL_URL}`;\n\n if (process.env.NEXT_PUBLIC_URL)\n // Manually set deployment URL from env\n return process.env.NEXT_PUBLIC_URL;\n\n // assume localhost\n return `http://127.0.0.1:3000`;\n}\n","import { type NextRequest } from \"next/server\";\nimport { ensureError, getBaseUrl } from \"./utils\";\nimport z from \"zod\";\n\n// Todo: it would be great to work out a way to support arbitrary types here.\nexport const eventTypes = [\n \"AppSetup\",\n \"ProtocolInstalled\",\n \"InterviewStarted\",\n \"InterviewCompleted\",\n \"DataExported\",\n] as const;\n\nconst EventSchema = z.object({\n type: z.enum(eventTypes),\n});\n\nconst ErrorSchema = z.object({\n type: z.literal(\"Error\"),\n message: z.string(),\n name: z.string(),\n stack: z.string().optional(),\n cause: z.string().optional(),\n});\n\nconst SharedEventAndErrorSchema = z.object({\n metadata: z.record(z.unknown()).optional(),\n});\n\n/**\n * Raw events are the events that are sent trackEvent. They are either general\n * events or errors. We discriminate on the `type` property to determine which\n * schema to use, and then merge the shared properties.\n */\nexport const RawEventSchema = z.discriminatedUnion(\"type\", [\n SharedEventAndErrorSchema.merge(EventSchema),\n SharedEventAndErrorSchema.merge(ErrorSchema),\n]);\nexport type RawEvent = z.infer<typeof RawEventSchema>;\n\n/**\n * Trackable events are the events that are sent to the route handler. The\n * `trackEvent` function adds the timestamp to ensure it is not inaccurate\n * due to network latency or processing time.\n */\nconst TrackablePropertiesSchema = z.object({\n timestamp: z.string(),\n});\n\nexport const TrackableEventSchema = z.intersection(\n RawEventSchema,\n TrackablePropertiesSchema\n);\nexport type TrackableEvent = z.infer<typeof TrackableEventSchema>;\n\n/**\n * Dispatchable events are the events that are sent to the platform. The route\n * handler injects the installationId and countryISOCode properties.\n */\nconst DispatchablePropertiesSchema = z.object({\n installationId: z.string(),\n countryISOCode: z.string(),\n});\n\n/**\n * The final schema for an analytics event. This is the schema that is used to\n * validate the event before it is inserted into the database. It is the\n * intersection of the trackable event and the dispatchable properties.\n */\nexport const AnalyticsEventSchema = z.intersection(\n TrackableEventSchema,\n DispatchablePropertiesSchema\n);\nexport type analyticsEvent = z.infer<typeof AnalyticsEventSchema>;\n\nexport const createRouteHandler = ({\n platformUrl = \"https://analytics.networkcanvas.com\",\n installationId,\n}: {\n platformUrl?: string;\n installationId: string;\n}) => {\n return async (request: NextRequest) => {\n try {\n const incomingEvent = (await request.json()) as unknown;\n\n // Validate the event\n const trackableEvent = TrackableEventSchema.safeParse(incomingEvent);\n\n if (!trackableEvent.success) {\n console.error(\"Invalid event:\", trackableEvent.error);\n return new Response(JSON.stringify({ error: \"Invalid event\" }), {\n status: 400,\n headers: {\n \"Content-Type\": \"application/json\",\n },\n });\n }\n\n // We don't want failures in third party services to prevent us from\n // tracking analytics events, so we'll catch any errors and log them\n // and continue with an 'Unknown' country code.\n let countryISOCode = \"Unknown\";\n try {\n const ip = await fetch(\"https://api64.ipify.org\").then((res) =>\n res.text()\n );\n\n if (!ip) {\n throw new Error(\"Could not fetch IP address\");\n }\n\n const geoData = await fetch(`http://ip-api.com/json/${ip}`).then((res) => res.json());\n\n if(geoData.status === \"success\") {\n countryISOCode = geoData.countryCode;\n } else {\n throw new Error(geoData.message);\n }\n } catch (e) {\n console.error(\"Geolocation failed:\", e);\n }\n\n const analyticsEvent: analyticsEvent = {\n ...trackableEvent.data,\n installationId,\n countryISOCode,\n };\n\n // Forward to backend\n const response = await fetch(`${platformUrl}/api/event`, {\n keepalive: true,\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(analyticsEvent),\n });\n\n if (!response.ok) {\n let error = `Analytics platform returned an unexpected error: ${response.statusText}`;\n\n if (response.status === 400) {\n error = `Analytics platform rejected the event as invalid. Please check the event schema`;\n }\n\n if (response.status === 404) {\n error = `Analytics platform could not be reached. Please specify a valid platform URL, or check that the platform is online.`;\n }\n\n if (response.status === 500) {\n error = `Analytics platform returned an internal server error. Please check the platform logs.`;\n }\n\n console.info(`⚠️ Analytics platform rejected event: ${error}`);\n return Response.json(\n {\n error,\n },\n { status: 500 }\n );\n }\n console.info(\"🚀 Analytics event sent to platform!\");\n return Response.json({ message: \"Event forwarded successfully\" });\n } catch (e) {\n const error = ensureError(e);\n console.info(\"🚫 Internal error with sending analytics event.\");\n\n return Response.json(\n { error: `Error in analytics route handler: ${error.message}` },\n { status: 500 }\n );\n }\n };\n};\n\nexport const makeEventTracker =\n ({\n enabled = false,\n endpoint = \"/api/analytics\",\n }: {\n enabled?: boolean;\n endpoint?: string;\n }) =>\n async (\n event: RawEvent\n ): Promise<{\n error: string | null;\n success: boolean;\n }> => {\n if (!enabled) {\n console.log(\"Analytics disabled - event not sent.\");\n return { error: null, success: true };\n }\n\n const endpointWithHost = getBaseUrl() + endpoint;\n\n const eventWithTimeStamp: TrackableEvent = {\n ...event,\n timestamp: new Date().toJSON(),\n };\n\n try {\n const response = await fetch(endpointWithHost, {\n method: \"POST\",\n keepalive: true,\n body: JSON.stringify(eventWithTimeStamp),\n headers: {\n \"Content-Type\": \"application/json\",\n },\n });\n\n if (!response.ok) {\n if (response.status === 404) {\n return {\n error: `Analytics endpoint not found, did you forget to add the route?`,\n success: false,\n };\n }\n\n // createRouteHandler will return a 400 if the event failed schema validation.\n if (response.status === 400) {\n return {\n error: `Invalid event sent to analytics endpoint: ${response.statusText}`,\n success: false,\n };\n }\n\n // createRouteHandler will return a 500 for all error states\n return {\n error: `Internal server error when sending analytics event: ${response.statusText}. Check the route handler implementation.`,\n success: false,\n };\n }\n\n return { error: null, success: true };\n } catch (e) {\n const error = ensureError(e);\n return {\n error: `Internal error when sending analytics event: ${error.message}`,\n success: false,\n };\n }\n };\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/index.ts","../src/utils.ts"],"sourcesContent":["import { NextResponse, type NextRequest } from 'next/server';\nimport { ensureError, getBaseUrl } from './utils';\nimport z from 'zod';\n\n// Todo: it would be great to work out a way to support arbitrary types here.\nexport const eventTypes = [\n 'AppSetup',\n 'ProtocolInstalled',\n 'InterviewStarted',\n 'InterviewCompleted',\n 'DataExported',\n] as const;\n\nconst EventSchema = z.object({\n type: z.enum(eventTypes),\n});\n\nconst ErrorSchema = z.object({\n type: z.literal('Error'),\n message: z.string(),\n name: z.string(),\n stack: z.string().optional(),\n cause: z.string().optional(),\n});\n\nconst SharedEventAndErrorSchema = z.object({\n metadata: z.record(z.unknown()).optional(),\n});\n\n/**\n * Raw events are the events that are sent trackEvent. They are either general\n * events or errors. We discriminate on the `type` property to determine which\n * schema to use, and then merge the shared properties.\n */\nexport const RawEventSchema = z.discriminatedUnion('type', [\n SharedEventAndErrorSchema.merge(EventSchema),\n SharedEventAndErrorSchema.merge(ErrorSchema),\n]);\nexport type RawEvent = z.infer<typeof RawEventSchema>;\n\n/**\n * Trackable events are the events that are sent to the route handler. The\n * `trackEvent` function adds the timestamp to ensure it is not inaccurate\n * due to network latency or processing time.\n */\nconst TrackablePropertiesSchema = z.object({\n timestamp: z.string(),\n});\n\nexport const TrackableEventSchema = z.intersection(\n RawEventSchema,\n TrackablePropertiesSchema,\n);\nexport type TrackableEvent = z.infer<typeof TrackableEventSchema>;\n\n/**\n * Dispatchable events are the events that are sent to the platform. The route\n * handler injects the installationId and countryISOCode properties.\n */\nconst DispatchablePropertiesSchema = z.object({\n installationId: z.string(),\n countryISOCode: z.string(),\n});\n\n/**\n * The final schema for an analytics event. This is the schema that is used to\n * validate the event before it is inserted into the database. It is the\n * intersection of the trackable event and the dispatchable properties.\n */\nexport const AnalyticsEventSchema = z.intersection(\n TrackableEventSchema,\n DispatchablePropertiesSchema,\n);\nexport type analyticsEvent = z.infer<typeof AnalyticsEventSchema>;\n\ntype GeoData = {\n status: 'success' | 'fail';\n countryCode: string;\n message: string;\n};\n\nexport const createRouteHandler = ({\n platformUrl = 'https://analytics.networkcanvas.com',\n installationId,\n}: {\n platformUrl?: string;\n installationId: string;\n}) => {\n return async (request: NextRequest) => {\n try {\n const incomingEvent = (await request.json()) as unknown;\n\n // Check if analytics is disabled\n // eslint-disable-next-line no-process-env, turbo/no-undeclared-env-vars\n if (process.env.DISABLE_ANALYTICS) {\n // eslint-disable-next-line no-console\n console.info('🛑 Analytics disabled. Payload not sent.');\n try {\n // eslint-disable-next-line no-console\n console.info(\n 'Payload:',\n '\\n',\n JSON.stringify(incomingEvent, null, 2),\n );\n } catch (e) {\n // eslint-disable-next-line no-console\n console.error('Error stringifying payload:', e);\n }\n\n return NextResponse.json(\n { message: 'Analytics disabled' },\n { status: 200 },\n );\n }\n\n // Validate the event\n const trackableEvent = TrackableEventSchema.safeParse(incomingEvent);\n\n if (!trackableEvent.success) {\n // eslint-disable-next-line no-console\n console.error('Invalid event:', trackableEvent.error);\n return NextResponse.json({ error: 'Invalid event' }, { status: 400 });\n }\n\n // We don't want failures in third party services to prevent us from\n // tracking analytics events, so we'll catch any errors and log them\n // and continue with an 'Unknown' country code.\n let countryISOCode = 'Unknown';\n try {\n const ip = await fetch('https://api64.ipify.org').then((res) =>\n res.text(),\n );\n\n if (!ip) {\n throw new Error('Could not fetch IP address');\n }\n\n const geoData = (await fetch(`http://ip-api.com/json/${ip}`).then(\n (res) => res.json(),\n )) as GeoData;\n\n if (geoData.status === 'success') {\n countryISOCode = geoData.countryCode;\n } else {\n throw new Error(geoData.message);\n }\n } catch (e) {\n // eslint-disable-next-line no-console\n console.error('Geolocation failed:', e);\n }\n\n const analyticsEvent: analyticsEvent = {\n ...trackableEvent.data,\n installationId,\n countryISOCode,\n };\n\n // Forward to backend\n const response = await fetch(`${platformUrl}/api/event`, {\n keepalive: true,\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(analyticsEvent),\n });\n\n if (!response.ok) {\n let error = `Analytics platform returned an unexpected error: ${response.statusText}`;\n\n if (response.status === 400) {\n error = `Analytics platform rejected the event as invalid. Please check the event schema`;\n }\n\n if (response.status === 404) {\n error = `Analytics platform could not be reached. Please specify a valid platform URL, or check that the platform is online.`;\n }\n\n if (response.status === 500) {\n error = `Analytics platform returned an internal server error. Please check the platform logs.`;\n }\n\n // eslint-disable-next-line no-console\n console.info(`⚠️ Analytics platform rejected event: ${error}`);\n return Response.json(\n {\n error,\n },\n { status: 500 },\n );\n }\n // eslint-disable-next-line no-console\n console.info('🚀 Analytics event sent to platform!');\n return Response.json({ message: 'Event forwarded successfully' });\n } catch (e) {\n const error = ensureError(e);\n // eslint-disable-next-line no-console\n console.info('🚫 Internal error with sending analytics event.');\n\n return Response.json(\n { error: `Error in analytics route handler: ${error.message}` },\n { status: 500 },\n );\n }\n };\n};\n\nexport const makeEventTracker =\n ({ endpoint = '/api/analytics' }: { endpoint?: string }) =>\n async (\n event: RawEvent,\n ): Promise<{\n error: string | null;\n success: boolean;\n }> => {\n const endpointWithHost = getBaseUrl() + endpoint;\n\n const eventWithTimeStamp: TrackableEvent = {\n ...event,\n timestamp: new Date().toJSON(),\n };\n\n try {\n const response = await fetch(endpointWithHost, {\n method: 'POST',\n keepalive: true,\n body: JSON.stringify(eventWithTimeStamp),\n headers: {\n 'Content-Type': 'application/json',\n },\n });\n\n if (!response.ok) {\n if (response.status === 404) {\n return {\n error: `Analytics endpoint not found, did you forget to add the route?`,\n success: false,\n };\n }\n\n // createRouteHandler will return a 400 if the event failed schema validation.\n if (response.status === 400) {\n return {\n error: `Invalid event sent to analytics endpoint: ${response.statusText}`,\n success: false,\n };\n }\n\n // createRouteHandler will return a 500 for all error states\n return {\n error: `Internal server error when sending analytics event: ${response.statusText}. Check the route handler implementation.`,\n success: false,\n };\n }\n\n return { error: null, success: true };\n } catch (e) {\n const error = ensureError(e);\n return {\n error: `Internal error when sending analytics event: ${error.message}`,\n success: false,\n };\n }\n };\n","/* eslint-disable no-process-env */\n// Helper function that ensures that a value is an Error\nexport function ensureError(value: unknown): Error {\n if (!value) return new Error('No value was thrown');\n\n if (value instanceof Error) return value;\n\n // Test if value inherits from Error\n if (Object.prototype.isPrototypeOf.call(value, Error))\n return value as Error & typeof value;\n\n let stringified = '[Unable to stringify the thrown value]';\n try {\n stringified = JSON.stringify(value);\n } catch (e) {\n // eslint-disable-next-line no-console\n console.error(e);\n }\n\n const error = new Error(\n `This value was thrown as is, not through an Error: ${stringified}`,\n );\n return error;\n}\n\nexport function getBaseUrl() {\n if (typeof window !== 'undefined')\n // browser should use relative path\n return '';\n\n if (process.env.VERCEL_URL)\n // reference for vercel.com\n return `https://${process.env.VERCEL_URL}`;\n\n if (process.env.NEXT_PUBLIC_URL)\n // Manually set deployment URL from env\n return process.env.NEXT_PUBLIC_URL;\n\n // assume localhost\n return `http://127.0.0.1:3000`;\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@codaco/analytics",
3
- "version": "5.1.0",
3
+ "version": "6.0.0-alpha.1",
4
+ "packageManager": "pnpm@8.15.5+sha256.4b4efa12490e5055d59b9b9fc9438b7d581a6b7af3b5675eb5c5f447cee1a589",
4
5
  "type": "module",
5
6
  "main": "./dist/index.js",
6
7
  "types": "./dist/index.d.ts",
7
- "author": "Complex Data Collective <developers@coda.co>",
8
+ "author": "Complex Data Collective <hello@complexdatacollective.org>",
8
9
  "description": "Utilities for tracking analytics and error reporting in Fresco",
9
10
  "scripts": {
10
11
  "build": "tsup src/index.ts --format esm --dts --clean --sourcemap",
@@ -12,15 +13,25 @@
12
13
  "dev": "npm run build -- --watch"
13
14
  },
14
15
  "peerDependencies": {
15
- "next": "13 || 14"
16
+ "next": "13 || 14 || 15"
16
17
  },
17
18
  "devDependencies": {
18
- "eslint-config-custom": "workspace:*",
19
- "tsconfig": "workspace:*",
20
- "tsup": "^7.2.0",
21
- "typescript": "^5.3.2"
19
+ "@codaco/eslint-config": "workspace:*",
20
+ "@codaco/tsconfig": "workspace:*",
21
+ "eslint": "^8.57.0",
22
+ "prettier": "^3.2.5",
23
+ "tsup": "^8.0.2",
24
+ "typescript": "^5.4.5"
22
25
  },
23
26
  "dependencies": {
24
- "zod": "^3.22.4"
27
+ "zod": "^3.23.8"
28
+ },
29
+ "eslintConfig": {
30
+ "root": true,
31
+ "extends": [
32
+ "@codaco/eslint-config/base",
33
+ "@codaco/eslint-config/nextjs",
34
+ "@codaco/eslint-config/react"
35
+ ]
25
36
  }
26
37
  }
package/src/index.ts CHANGED
@@ -1,14 +1,14 @@
1
- import { type NextRequest } from "next/server";
2
- import { ensureError, getBaseUrl } from "./utils";
3
- import z from "zod";
1
+ import { NextResponse, type NextRequest } from 'next/server';
2
+ import { ensureError, getBaseUrl } from './utils';
3
+ import z from 'zod';
4
4
 
5
5
  // Todo: it would be great to work out a way to support arbitrary types here.
6
6
  export const eventTypes = [
7
- "AppSetup",
8
- "ProtocolInstalled",
9
- "InterviewStarted",
10
- "InterviewCompleted",
11
- "DataExported",
7
+ 'AppSetup',
8
+ 'ProtocolInstalled',
9
+ 'InterviewStarted',
10
+ 'InterviewCompleted',
11
+ 'DataExported',
12
12
  ] as const;
13
13
 
14
14
  const EventSchema = z.object({
@@ -16,7 +16,7 @@ const EventSchema = z.object({
16
16
  });
17
17
 
18
18
  const ErrorSchema = z.object({
19
- type: z.literal("Error"),
19
+ type: z.literal('Error'),
20
20
  message: z.string(),
21
21
  name: z.string(),
22
22
  stack: z.string().optional(),
@@ -32,7 +32,7 @@ const SharedEventAndErrorSchema = z.object({
32
32
  * events or errors. We discriminate on the `type` property to determine which
33
33
  * schema to use, and then merge the shared properties.
34
34
  */
35
- export const RawEventSchema = z.discriminatedUnion("type", [
35
+ export const RawEventSchema = z.discriminatedUnion('type', [
36
36
  SharedEventAndErrorSchema.merge(EventSchema),
37
37
  SharedEventAndErrorSchema.merge(ErrorSchema),
38
38
  ]);
@@ -49,7 +49,7 @@ const TrackablePropertiesSchema = z.object({
49
49
 
50
50
  export const TrackableEventSchema = z.intersection(
51
51
  RawEventSchema,
52
- TrackablePropertiesSchema
52
+ TrackablePropertiesSchema,
53
53
  );
54
54
  export type TrackableEvent = z.infer<typeof TrackableEventSchema>;
55
55
 
@@ -69,12 +69,18 @@ const DispatchablePropertiesSchema = z.object({
69
69
  */
70
70
  export const AnalyticsEventSchema = z.intersection(
71
71
  TrackableEventSchema,
72
- DispatchablePropertiesSchema
72
+ DispatchablePropertiesSchema,
73
73
  );
74
74
  export type analyticsEvent = z.infer<typeof AnalyticsEventSchema>;
75
75
 
76
+ type GeoData = {
77
+ status: 'success' | 'fail';
78
+ countryCode: string;
79
+ message: string;
80
+ };
81
+
76
82
  export const createRouteHandler = ({
77
- platformUrl = "https://analytics.networkcanvas.com",
83
+ platformUrl = 'https://analytics.networkcanvas.com',
78
84
  installationId,
79
85
  }: {
80
86
  platformUrl?: string;
@@ -84,41 +90,63 @@ export const createRouteHandler = ({
84
90
  try {
85
91
  const incomingEvent = (await request.json()) as unknown;
86
92
 
93
+ // Check if analytics is disabled
94
+ // eslint-disable-next-line no-process-env, turbo/no-undeclared-env-vars
95
+ if (process.env.DISABLE_ANALYTICS) {
96
+ // eslint-disable-next-line no-console
97
+ console.info('🛑 Analytics disabled. Payload not sent.');
98
+ try {
99
+ // eslint-disable-next-line no-console
100
+ console.info(
101
+ 'Payload:',
102
+ '\n',
103
+ JSON.stringify(incomingEvent, null, 2),
104
+ );
105
+ } catch (e) {
106
+ // eslint-disable-next-line no-console
107
+ console.error('Error stringifying payload:', e);
108
+ }
109
+
110
+ return NextResponse.json(
111
+ { message: 'Analytics disabled' },
112
+ { status: 200 },
113
+ );
114
+ }
115
+
87
116
  // Validate the event
88
117
  const trackableEvent = TrackableEventSchema.safeParse(incomingEvent);
89
118
 
90
119
  if (!trackableEvent.success) {
91
- console.error("Invalid event:", trackableEvent.error);
92
- return new Response(JSON.stringify({ error: "Invalid event" }), {
93
- status: 400,
94
- headers: {
95
- "Content-Type": "application/json",
96
- },
97
- });
120
+ // eslint-disable-next-line no-console
121
+ console.error('Invalid event:', trackableEvent.error);
122
+ return NextResponse.json({ error: 'Invalid event' }, { status: 400 });
98
123
  }
99
124
 
100
125
  // We don't want failures in third party services to prevent us from
101
126
  // tracking analytics events, so we'll catch any errors and log them
102
127
  // and continue with an 'Unknown' country code.
103
- let countryISOCode = "Unknown";
128
+ let countryISOCode = 'Unknown';
104
129
  try {
105
- const ip = await fetch("https://api64.ipify.org").then((res) =>
106
- res.text()
130
+ const ip = await fetch('https://api64.ipify.org').then((res) =>
131
+ res.text(),
107
132
  );
108
133
 
109
134
  if (!ip) {
110
- throw new Error("Could not fetch IP address");
135
+ throw new Error('Could not fetch IP address');
111
136
  }
112
137
 
113
- const geoData = await fetch(`http://ip-api.com/json/${ip}`).then((res) => res.json());
138
+ const geoData = (await fetch(`http://ip-api.com/json/${ip}`).then(
139
+ (res) => res.json(),
140
+ )) as GeoData;
114
141
 
115
- if(geoData.status === "success") {
142
+ if (geoData.status === 'success') {
116
143
  countryISOCode = geoData.countryCode;
117
144
  } else {
118
145
  throw new Error(geoData.message);
119
146
  }
120
147
  } catch (e) {
121
- console.error("Geolocation failed:", e);
148
+ // eslint-disable-next-line no-console
149
+ console.error('Geolocation failed:', e);
122
150
  }
123
151
 
124
152
  const analyticsEvent: analyticsEvent = {
@@ -130,9 +158,9 @@ export const createRouteHandler = ({
130
158
  // Forward to backend
131
159
  const response = await fetch(`${platformUrl}/api/event`, {
132
160
  keepalive: true,
133
- method: "POST",
161
+ method: 'POST',
134
162
  headers: {
135
- "Content-Type": "application/json",
163
+ 'Content-Type': 'application/json',
136
164
  },
137
165
  body: JSON.stringify(analyticsEvent),
138
166
  });
@@ -152,47 +180,39 @@ export const createRouteHandler = ({
152
180
  error = `Analytics platform returned an internal server error. Please check the platform logs.`;
153
181
  }
154
182
 
183
+ // eslint-disable-next-line no-console
155
184
  console.info(`⚠️ Analytics platform rejected event: ${error}`);
156
185
  return Response.json(
157
186
  {
158
187
  error,
159
188
  },
160
- { status: 500 }
189
+ { status: 500 },
161
190
  );
162
191
  }
163
- console.info("🚀 Analytics event sent to platform!");
164
- return Response.json({ message: "Event forwarded successfully" });
192
+ // eslint-disable-next-line no-console
193
+ console.info('🚀 Analytics event sent to platform!');
194
+ return Response.json({ message: 'Event forwarded successfully' });
165
195
  } catch (e) {
166
196
  const error = ensureError(e);
167
- console.info("🚫 Internal error with sending analytics event.");
197
+ // eslint-disable-next-line no-console
198
+ console.info('🚫 Internal error with sending analytics event.');
168
199
 
169
200
  return Response.json(
170
201
  { error: `Error in analytics route handler: ${error.message}` },
171
- { status: 500 }
202
+ { status: 500 },
172
203
  );
173
204
  }
174
205
  };
175
206
  };
176
207
 
177
208
  export const makeEventTracker =
178
- ({
179
- enabled = false,
180
- endpoint = "/api/analytics",
181
- }: {
182
- enabled?: boolean;
183
- endpoint?: string;
184
- }) =>
209
+ ({ endpoint = '/api/analytics' }: { endpoint?: string }) =>
185
210
  async (
186
- event: RawEvent
211
+ event: RawEvent,
187
212
  ): Promise<{
188
213
  error: string | null;
189
214
  success: boolean;
190
215
  }> => {
191
- if (!enabled) {
192
- console.log("Analytics disabled - event not sent.");
193
- return { error: null, success: true };
194
- }
195
-
196
216
  const endpointWithHost = getBaseUrl() + endpoint;
197
217
 
198
218
  const eventWithTimeStamp: TrackableEvent = {
@@ -202,11 +222,11 @@ export const makeEventTracker =
202
222
 
203
223
  try {
204
224
  const response = await fetch(endpointWithHost, {
205
- method: "POST",
225
+ method: 'POST',
206
226
  keepalive: true,
207
227
  body: JSON.stringify(eventWithTimeStamp),
208
228
  headers: {
209
- "Content-Type": "application/json",
229
+ 'Content-Type': 'application/json',
210
230
  },
211
231
  });
212
232
 
package/src/utils.ts CHANGED
@@ -1,27 +1,32 @@
1
+ /* eslint-disable no-process-env */
1
2
  // Helper function that ensures that a value is an Error
2
3
  export function ensureError(value: unknown): Error {
3
- if (!value) return new Error("No value was thrown");
4
+ if (!value) return new Error('No value was thrown');
4
5
 
5
6
  if (value instanceof Error) return value;
6
7
 
7
8
  // Test if value inherits from Error
8
- if (value.isPrototypeOf(Error)) return value as Error & typeof value;
9
+ if (Object.prototype.isPrototypeOf.call(value, Error))
10
+ return value as Error & typeof value;
9
11
 
10
- let stringified = "[Unable to stringify the thrown value]";
12
+ let stringified = '[Unable to stringify the thrown value]';
11
13
  try {
12
14
  stringified = JSON.stringify(value);
13
- } catch {}
15
+ } catch (e) {
16
+ // eslint-disable-next-line no-console
17
+ console.error(e);
18
+ }
14
19
 
15
20
  const error = new Error(
16
- `This value was thrown as is, not through an Error: ${stringified}`
21
+ `This value was thrown as is, not through an Error: ${stringified}`,
17
22
  );
18
23
  return error;
19
24
  }
20
25
 
21
26
  export function getBaseUrl() {
22
- if (typeof window !== "undefined")
27
+ if (typeof window !== 'undefined')
23
28
  // browser should use relative path
24
- return "";
29
+ return '';
25
30
 
26
31
  if (process.env.VERCEL_URL)
27
32
  // reference for vercel.com
package/tsconfig.json CHANGED
@@ -1,11 +1,9 @@
1
1
  {
2
- "extends": "tsconfig/react-library.json",
3
- "include": [
4
- "."
5
- ],
6
- "exclude": [
7
- "dist",
8
- "build",
9
- "node_modules"
10
- ]
11
- }
2
+ "extends": "@codaco/tsconfig/base.json",
3
+ "compilerOptions": {
4
+ "baseUrl": ".",
5
+ "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
6
+ },
7
+ "include": ["."],
8
+ "exclude": ["dist", "build", "node_modules"]
9
+ }
package/.eslintrc.js DELETED
@@ -1,4 +0,0 @@
1
- module.exports = {
2
- root: true,
3
- extends: ["custom/next"],
4
- };