@codaco/analytics 8.0.0 → 9.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,240 +1,72 @@
1
- import { type NextRequest, NextResponse } from "next/server";
2
- import z from "zod";
3
- import { ensureError } from "./utils";
4
-
5
- // Todo: it would be great to work out a way to support arbitrary types here.
6
- export const eventTypes = [
7
- "AppSetup",
8
- "ProtocolInstalled",
9
- "InterviewStarted",
10
- "InterviewCompleted",
11
- "DataExported",
12
- ] as const;
13
-
14
- const EventSchema = z.object({
15
- type: z.enum(eventTypes),
16
- });
17
-
18
- const ErrorSchema = z.object({
19
- type: z.literal("Error"),
20
- message: z.string(),
21
- name: z.string(),
22
- stack: z.string().optional(),
23
- cause: z.string().optional(),
24
- });
25
-
26
- const SharedEventAndErrorSchema = z.object({
27
- metadata: z.record(z.string(), z.unknown()).optional(),
28
- });
29
-
30
- /**
31
- * Raw events are the payload that is sent to trackEvent, which can be either
32
- * general events or errors. We discriminate on the `type` property to determine
33
- * which schema to use, and then merge the shared properties.
34
- */
35
- export const RawEventSchema = z.discriminatedUnion("type", [
36
- SharedEventAndErrorSchema.merge(EventSchema),
37
- SharedEventAndErrorSchema.merge(ErrorSchema),
38
- ]);
39
- export type RawEvent = z.infer<typeof RawEventSchema>;
40
-
41
- /**
42
- * Trackable events are the events that are sent to the route handler by
43
- * `trackEvent`. The function adds the timestamp to ensure it is not inaccurate
44
- * due to network latency or processing time.
45
- */
46
- const TrackablePropertiesSchema = z.object({
47
- timestamp: z.string(),
48
- });
49
-
50
- export const TrackableEventSchema = z.intersection(RawEventSchema, TrackablePropertiesSchema);
51
- export type TrackableEvent = z.infer<typeof TrackableEventSchema>;
52
-
53
- /**
54
- * Dispatchable events are the events that are sent to the platform. The route
55
- * handler injects the installationId and countryISOCode properties.
56
- */
57
- const DispatchablePropertiesSchema = z.object({
58
- installationId: z.string(),
59
- countryISOCode: z.string(),
60
- });
61
-
62
1
  /**
63
- * The final schema for an analytics event. This is the schema that is used to
64
- * validate the event before it is inserted into the database. It is the
65
- * intersection of the trackable event and the dispatchable properties.
2
+ * @codaco/analytics
3
+ *
4
+ * PostHog analytics wrapper for Network Canvas applications
5
+ * with installation ID tracking, error reporting, and feature flags.
6
+ *
7
+ * @example Client-side usage (React):
8
+ * ```tsx
9
+ * import { AnalyticsProvider, useAnalytics } from '@codaco/analytics';
10
+ *
11
+ * // In your root layout
12
+ * export default function RootLayout({ children }) {
13
+ * return (
14
+ * <AnalyticsProvider
15
+ * config={{
16
+ * installationId: 'your-unique-installation-id',
17
+ * }}
18
+ * >
19
+ * {children}
20
+ * </AnalyticsProvider>
21
+ * );
22
+ * }
23
+ *
24
+ * // In your components
25
+ * function MyComponent() {
26
+ * const { trackEvent, trackError } = useAnalytics();
27
+ *
28
+ * const handleAction = () => {
29
+ * trackEvent('app_setup', {
30
+ * metadata: { version: '1.0.0' }
31
+ * });
32
+ * };
33
+ *
34
+ * return <button onClick={handleAction}>Setup</button>;
35
+ * }
36
+ * ```
37
+ *
38
+ * @example Server-side usage (API routes, server actions):
39
+ * ```ts
40
+ * import { serverAnalytics } from '@codaco/analytics/server';
41
+ *
42
+ * export async function POST(request: Request) {
43
+ * serverAnalytics.trackEvent('data_exported', {
44
+ * metadata: { format: 'csv' }
45
+ * });
46
+ *
47
+ * // ... rest of your handler
48
+ * }
49
+ * ```
66
50
  */
67
- export const AnalyticsEventSchema = z.intersection(TrackableEventSchema, DispatchablePropertiesSchema);
68
- export type analyticsEvent = z.infer<typeof AnalyticsEventSchema>;
69
-
70
- type GeoData = {
71
- status: "success" | "fail";
72
- countryCode: string;
73
- message: string;
74
- };
75
-
76
- export const createRouteHandler = ({
77
- platformUrl = "https://analytics.networkcanvas.com",
78
- installationId,
79
- disableAnalytics,
80
- }: {
81
- platformUrl?: string;
82
- installationId: string;
83
- disableAnalytics?: boolean;
84
- }) => {
85
- return async (request: NextRequest) => {
86
- try {
87
- const incomingEvent = (await request.json()) as unknown;
88
-
89
- // Check if analytics is disabled
90
- if (disableAnalytics) {
91
- console.info("🛑 Analytics disabled. Payload not sent.");
92
- try {
93
- console.info("Payload:", "\n", JSON.stringify(incomingEvent, null, 2));
94
- } catch (e) {
95
- console.error("Error stringifying payload:", e);
96
- }
97
-
98
- return NextResponse.json({ message: "Analytics disabled" }, { status: 200 });
99
- }
100
-
101
- // Validate the event
102
- const trackableEvent = TrackableEventSchema.safeParse(incomingEvent);
103
-
104
- if (!trackableEvent.success) {
105
- console.error("Invalid event:", trackableEvent.error);
106
- return NextResponse.json({ error: "Invalid event" }, { status: 400 });
107
- }
108
-
109
- // We don't want failures in third party services to prevent us from
110
- // tracking analytics events, so we'll catch any errors and log them
111
- // and continue with an 'Unknown' country code.
112
- let countryISOCode = "Unknown";
113
- try {
114
- const ip = await fetch("https://api64.ipify.org").then((res) => res.text());
115
-
116
- if (!ip) {
117
- throw new Error("Could not fetch IP address");
118
- }
119
-
120
- const geoData = (await fetch(`http://ip-api.com/json/${ip}`).then((res) => res.json())) as GeoData;
121
-
122
- if (geoData.status === "success") {
123
- countryISOCode = geoData.countryCode;
124
- } else {
125
- throw new Error(geoData.message);
126
- }
127
- } catch (e) {
128
- console.error("Geolocation failed:", e);
129
- }
130
-
131
- const analyticsEvent: analyticsEvent = {
132
- ...trackableEvent.data,
133
- installationId,
134
- countryISOCode,
135
- };
136
-
137
- // Forward to backend
138
- const response = await fetch(`${platformUrl}/api/event`, {
139
- keepalive: true,
140
- method: "POST",
141
- headers: {
142
- "Content-Type": "application/json",
143
- },
144
- body: JSON.stringify(analyticsEvent),
145
- });
146
-
147
- if (!response.ok) {
148
- let error = `Analytics platform returned an unexpected error: ${response.statusText}`;
149
-
150
- if (response.status === 400) {
151
- error = "Analytics platform rejected the event as invalid. Please check the event schema";
152
- }
153
-
154
- if (response.status === 404) {
155
- error =
156
- "Analytics platform could not be reached. Please specify a valid platform URL, or check that the platform is online.";
157
- }
158
-
159
- if (response.status === 500) {
160
- error = "Analytics platform returned an internal server error. Please check the platform logs.";
161
- }
162
-
163
- console.info(`⚠️ Analytics platform rejected event: ${error}`);
164
- return Response.json(
165
- {
166
- error,
167
- },
168
- { status: 500 },
169
- );
170
- }
171
- console.info("🚀 Analytics event sent to platform!");
172
- return Response.json({ message: "Event forwarded successfully" });
173
- } catch (e) {
174
- const error = ensureError(e);
175
- console.info("🚫 Internal error with sending analytics event.");
176
-
177
- return Response.json({ error: `Error in analytics route handler: ${error.message}` }, { status: 500 });
178
- }
179
- };
180
- };
181
-
182
- export const makeEventTracker =
183
- (options?: { endpoint?: string }) =>
184
- async (
185
- event: RawEvent,
186
- ): Promise<{
187
- error: string | null;
188
- success: boolean;
189
- }> => {
190
- // We use a relative path by default, which should automatically use the
191
- // same origin as the page that is sending the event.
192
- const endpoint = options?.endpoint ?? "/api/analytics";
193
-
194
- const eventWithTimeStamp: TrackableEvent = {
195
- ...event,
196
- timestamp: new Date().toJSON(),
197
- };
198
-
199
- try {
200
- const response = await fetch(endpoint, {
201
- method: "POST",
202
- keepalive: true,
203
- body: JSON.stringify(eventWithTimeStamp),
204
- headers: {
205
- "Content-Type": "application/json",
206
- },
207
- });
208
-
209
- if (!response.ok) {
210
- if (response.status === 404) {
211
- return {
212
- error: "Analytics endpoint not found, did you forget to add the route?",
213
- success: false,
214
- };
215
- }
216
-
217
- // createRouteHandler will return a 400 if the event failed schema validation.
218
- if (response.status === 400) {
219
- return {
220
- error: `Invalid event sent to analytics endpoint: ${response.statusText}`,
221
- success: false,
222
- };
223
- }
224
-
225
- // createRouteHandler will return a 500 for all error states
226
- return {
227
- error: `Internal server error when sending analytics event: ${response.statusText}. Check the route handler implementation.`,
228
- success: false,
229
- };
230
- }
231
51
 
232
- return { error: null, success: true };
233
- } catch (e) {
234
- const error = ensureError(e);
235
- return {
236
- error: `Internal error when sending analytics event: ${error.message}`,
237
- success: false,
238
- };
239
- }
240
- };
52
+ // Re-export config helpers
53
+ export { defaultConfig, isDisabledByEnv, mergeConfig } from "./config";
54
+ export { useAnalytics, useFeatureFlag, useFeatureFlagValue } from "./hooks";
55
+
56
+ // Re-export provider and hooks for client-side usage
57
+ export { AnalyticsProvider, type AnalyticsProviderProps } from "./provider";
58
+ // Re-export types
59
+ export type {
60
+ Analytics,
61
+ AnalyticsConfig,
62
+ ErrorProperties,
63
+ EventProperties,
64
+ EventType,
65
+ } from "./types";
66
+ export { eventTypes, legacyEventTypeMap } from "./types";
67
+
68
+ // Re-export utilities
69
+ export { ensureError } from "./utils";
70
+
71
+ // Note: Server-side exports are in a separate entry point
72
+ // Import from '@codaco/analytics/server' for server-side usage
@@ -0,0 +1,60 @@
1
+ "use client";
2
+
3
+ import { createContext, type ReactNode, useEffect, useRef } from "react";
4
+ import { createAnalytics } from "./client";
5
+ import { mergeConfig } from "./config";
6
+ import type { Analytics, AnalyticsConfig } from "./types";
7
+
8
+ /**
9
+ * React Context for analytics
10
+ */
11
+ export const AnalyticsContext = createContext<Analytics | null>(null);
12
+
13
+ /**
14
+ * Props for the AnalyticsProvider
15
+ */
16
+ export interface AnalyticsProviderProps {
17
+ children: ReactNode;
18
+ config: AnalyticsConfig;
19
+ }
20
+
21
+ /**
22
+ * Provider component that initializes PostHog and provides analytics context
23
+ *
24
+ * @example
25
+ * ```tsx
26
+ * import { AnalyticsProvider } from '@codaco/analytics';
27
+ *
28
+ * function App({ children }) {
29
+ * return (
30
+ * <AnalyticsProvider
31
+ * config={{
32
+ * installationId: 'your-installation-id',
33
+ * apiKey: 'phc_your_api_key', // optional if set via env
34
+ * apiHost: 'https://ph-relay.networkcanvas.com', // optional
35
+ * }}
36
+ * >
37
+ * {children}
38
+ * </AnalyticsProvider>
39
+ * );
40
+ * }
41
+ * ```
42
+ */
43
+ export function AnalyticsProvider({ children, config }: AnalyticsProviderProps) {
44
+ const analyticsRef = useRef<Analytics | null>(null);
45
+
46
+ // Initialize analytics only once
47
+ useEffect(() => {
48
+ if (!analyticsRef.current) {
49
+ const mergedConfig = mergeConfig(config);
50
+ analyticsRef.current = createAnalytics(mergedConfig);
51
+ }
52
+ }, []); // Empty deps - only initialize once
53
+
54
+ // Don't render children until analytics is initialized
55
+ if (!analyticsRef.current) {
56
+ return null;
57
+ }
58
+
59
+ return <AnalyticsContext.Provider value={analyticsRef.current}>{children}</AnalyticsContext.Provider>;
60
+ }
package/src/server.ts ADDED
@@ -0,0 +1,213 @@
1
+ import { mergeConfig } from "./config";
2
+ import type { Analytics, AnalyticsConfig, ErrorProperties, EventProperties, EventType } from "./types";
3
+ import { ensureError } from "./utils";
4
+
5
+ /**
6
+ * Server-side analytics implementation
7
+ * This uses PostHog's API directly for server-side tracking
8
+ */
9
+ class ServerAnalytics implements Analytics {
10
+ private config: Required<AnalyticsConfig>;
11
+ private disabled: boolean;
12
+
13
+ constructor(config: AnalyticsConfig) {
14
+ this.config = mergeConfig(config);
15
+ this.disabled = this.config.disabled;
16
+ }
17
+
18
+ /**
19
+ * Track an event on the server-side
20
+ */
21
+ trackEvent(eventType: EventType | string, properties?: EventProperties): void {
22
+ if (this.disabled) return;
23
+
24
+ // Send event to PostHog using fetch
25
+ this.sendToPostHog(eventType, {
26
+ ...properties,
27
+ ...(properties?.metadata ?? {}),
28
+ }).catch((_error) => {
29
+ if (this.config.debug) {
30
+ }
31
+ });
32
+ }
33
+
34
+ /**
35
+ * Track an error on the server-side
36
+ */
37
+ trackError(error: Error, additionalProperties?: EventProperties): void {
38
+ if (this.disabled) return;
39
+
40
+ const errorObj = ensureError(error);
41
+ const errorProperties: ErrorProperties = {
42
+ message: errorObj.message,
43
+ name: errorObj.name,
44
+ stack: errorObj.stack,
45
+ cause: errorObj.cause ? String(errorObj.cause) : undefined,
46
+ ...additionalProperties,
47
+ };
48
+
49
+ this.sendToPostHog("error", {
50
+ ...errorProperties,
51
+ ...(additionalProperties?.metadata ?? {}),
52
+ }).catch((_error) => {
53
+ if (this.config.debug) {
54
+ }
55
+ });
56
+ }
57
+
58
+ /**
59
+ * Feature flags are not supported in server-side mode
60
+ * Use client-side hooks or PostHog API directly for feature flags
61
+ */
62
+ isFeatureEnabled(_flagKey: string): boolean {
63
+ return false;
64
+ }
65
+
66
+ /**
67
+ * Feature flags are not supported in server-side mode
68
+ */
69
+ getFeatureFlag(_flagKey: string): string | boolean | undefined {
70
+ return undefined;
71
+ }
72
+
73
+ /**
74
+ * Feature flags are not supported in server-side mode
75
+ */
76
+ reloadFeatureFlags(): void {}
77
+
78
+ /**
79
+ * User identification on the server-side
80
+ */
81
+ identify(distinctId: string, properties?: Record<string, unknown>): void {
82
+ if (this.disabled) return;
83
+
84
+ this.sendToPostHog("$identify", {
85
+ $set: properties ?? {},
86
+ distinct_id: distinctId,
87
+ }).catch((_error) => {
88
+ if (this.config.debug) {
89
+ }
90
+ });
91
+ }
92
+
93
+ /**
94
+ * Reset is not applicable on server-side
95
+ */
96
+ reset(): void {}
97
+
98
+ isEnabled(): boolean {
99
+ return !this.disabled;
100
+ }
101
+
102
+ getInstallationId(): string {
103
+ return this.config.installationId;
104
+ }
105
+
106
+ /**
107
+ * Send event to PostHog using fetch API
108
+ * Note: API key authentication is handled by the Cloudflare Worker proxy,
109
+ * so we don't include it in the payload.
110
+ */
111
+ private async sendToPostHog(event: string, properties: Record<string, unknown>): Promise<void> {
112
+ if (this.disabled) return;
113
+
114
+ const payload = {
115
+ event,
116
+ properties: {
117
+ ...properties,
118
+ installation_id: this.config.installationId,
119
+ },
120
+ timestamp: new Date().toISOString(),
121
+ };
122
+
123
+ try {
124
+ const response = await fetch(`${this.config.apiHost}/capture`, {
125
+ method: "POST",
126
+ headers: {
127
+ "Content-Type": "application/json",
128
+ },
129
+ body: JSON.stringify(payload),
130
+ // Use keepalive for reliability
131
+ keepalive: true,
132
+ });
133
+
134
+ if (!response.ok) {
135
+ throw new Error(`PostHog API returned ${response.status}: ${response.statusText}`);
136
+ }
137
+ } catch (_error) {
138
+ // Silently fail - we don't want analytics errors to break the app
139
+ if (this.config.debug) {
140
+ }
141
+ }
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Global server-side analytics instance
147
+ */
148
+ let serverAnalyticsInstance: ServerAnalytics | null = null;
149
+
150
+ /**
151
+ * Initialize server-side analytics
152
+ * Call this once in your app (e.g., in a layout or middleware)
153
+ *
154
+ * @example
155
+ * ```ts
156
+ * // In your Next.js layout or API route
157
+ * import { initServerAnalytics } from '@codaco/analytics/server';
158
+ *
159
+ * initServerAnalytics({
160
+ * installationId: 'your-unique-installation-id',
161
+ * });
162
+ * ```
163
+ */
164
+ export function initServerAnalytics(config: AnalyticsConfig): void {
165
+ if (!serverAnalyticsInstance) {
166
+ serverAnalyticsInstance = new ServerAnalytics(config);
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Get the server-side analytics instance
172
+ * Use this in server components, API routes, and server actions
173
+ *
174
+ * @example
175
+ * ```ts
176
+ * import { getServerAnalytics } from '@codaco/analytics/server';
177
+ *
178
+ * export async function POST(request: Request) {
179
+ * const analytics = getServerAnalytics();
180
+ * analytics.trackEvent('data_exported', {
181
+ * metadata: { format: 'csv' }
182
+ * });
183
+ *
184
+ * // ... rest of your handler
185
+ * }
186
+ * ```
187
+ */
188
+ export function getServerAnalytics(): Analytics {
189
+ if (!serverAnalyticsInstance) {
190
+ throw new Error(
191
+ "Server analytics not initialized. Call initServerAnalytics() first (e.g., in your root layout or middleware).",
192
+ );
193
+ }
194
+
195
+ return serverAnalyticsInstance;
196
+ }
197
+
198
+ /**
199
+ * Convenience export for direct usage
200
+ * Requires calling initServerAnalytics() first
201
+ */
202
+ export const serverAnalytics = new Proxy({} as Analytics, {
203
+ get(_target, prop) {
204
+ if (!serverAnalyticsInstance) {
205
+ throw new Error(
206
+ "Server analytics not initialized. Call initServerAnalytics({ installationId: '...' }) first " +
207
+ "(e.g., in your root layout or middleware).",
208
+ );
209
+ }
210
+
211
+ return serverAnalyticsInstance[prop as keyof Analytics];
212
+ },
213
+ });