@codaco/analytics 7.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/client.ts ADDED
@@ -0,0 +1,151 @@
1
+ import posthog from "posthog-js";
2
+ import type { Analytics, AnalyticsConfig, ErrorProperties, EventProperties, EventType } from "./types";
3
+ import { ensureError } from "./utils";
4
+
5
+ /**
6
+ * Create a client-side analytics instance
7
+ * This wraps PostHog with Network Canvas-specific functionality
8
+ */
9
+ export function createAnalytics(config: Required<AnalyticsConfig>): Analytics {
10
+ const { apiHost, apiKey, installationId, disabled, debug, posthogOptions } = config;
11
+
12
+ // If analytics is disabled, return a no-op implementation
13
+ if (disabled) {
14
+ return createNoOpAnalytics(installationId);
15
+ }
16
+
17
+ // Initialize PostHog
18
+ posthog.init(apiKey, {
19
+ api_host: apiHost,
20
+ loaded: (posthogInstance) => {
21
+ // Set installation ID as a super property (included with every event)
22
+ posthogInstance.register({
23
+ installation_id: installationId,
24
+ });
25
+
26
+ if (debug) {
27
+ posthogInstance.debug();
28
+ }
29
+ },
30
+ ...posthogOptions,
31
+ });
32
+
33
+ return {
34
+ trackEvent: (eventType: EventType | string, properties?: EventProperties) => {
35
+ if (disabled) return;
36
+
37
+ try {
38
+ posthog.capture(eventType, {
39
+ ...properties,
40
+ // Flatten metadata into properties for better PostHog integration
41
+ ...(properties?.metadata ?? {}),
42
+ });
43
+ } catch (_e) {
44
+ if (debug) {
45
+ }
46
+ }
47
+ },
48
+
49
+ trackError: (error: Error, additionalProperties?: EventProperties) => {
50
+ if (disabled) return;
51
+
52
+ try {
53
+ const errorObj = ensureError(error);
54
+ const errorProperties: ErrorProperties = {
55
+ message: errorObj.message,
56
+ name: errorObj.name,
57
+ stack: errorObj.stack,
58
+ cause: errorObj.cause ? String(errorObj.cause) : undefined,
59
+ ...additionalProperties,
60
+ };
61
+
62
+ posthog.capture("error", {
63
+ ...errorProperties,
64
+ // Flatten metadata
65
+ ...(additionalProperties?.metadata ?? {}),
66
+ });
67
+ } catch (_e) {
68
+ if (debug) {
69
+ }
70
+ }
71
+ },
72
+
73
+ isFeatureEnabled: (flagKey: string) => {
74
+ if (disabled) return false;
75
+
76
+ try {
77
+ return posthog.isFeatureEnabled(flagKey);
78
+ } catch (_e) {
79
+ if (debug) {
80
+ }
81
+ return undefined;
82
+ }
83
+ },
84
+
85
+ getFeatureFlag: (flagKey: string) => {
86
+ if (disabled) return undefined;
87
+
88
+ try {
89
+ return posthog.getFeatureFlag(flagKey);
90
+ } catch (_e) {
91
+ if (debug) {
92
+ }
93
+ return undefined;
94
+ }
95
+ },
96
+
97
+ reloadFeatureFlags: () => {
98
+ if (disabled) return;
99
+
100
+ try {
101
+ posthog.reloadFeatureFlags();
102
+ } catch (_e) {
103
+ if (debug) {
104
+ }
105
+ }
106
+ },
107
+
108
+ identify: (distinctId: string, properties?: Record<string, unknown>) => {
109
+ if (disabled) return;
110
+
111
+ try {
112
+ posthog.identify(distinctId, properties);
113
+ } catch (_e) {
114
+ if (debug) {
115
+ }
116
+ }
117
+ },
118
+
119
+ reset: () => {
120
+ if (disabled) return;
121
+
122
+ try {
123
+ posthog.reset();
124
+ } catch (_e) {
125
+ if (debug) {
126
+ }
127
+ }
128
+ },
129
+
130
+ isEnabled: () => !disabled,
131
+
132
+ getInstallationId: () => installationId,
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Create a no-op analytics instance when analytics is disabled
138
+ */
139
+ function createNoOpAnalytics(installationId: string): Analytics {
140
+ return {
141
+ trackEvent: () => {},
142
+ trackError: () => {},
143
+ isFeatureEnabled: () => false,
144
+ getFeatureFlag: () => undefined,
145
+ reloadFeatureFlags: () => {},
146
+ identify: () => {},
147
+ reset: () => {},
148
+ isEnabled: () => false,
149
+ getInstallationId: () => installationId,
150
+ };
151
+ }
package/src/config.ts ADDED
@@ -0,0 +1,92 @@
1
+ import type { AnalyticsConfig } from "./types";
2
+
3
+ /**
4
+ * Hardcoded PostHog API host - always uses the Cloudflare Worker reverse proxy
5
+ * Authentication is handled by the worker at this endpoint
6
+ */
7
+ const POSTHOG_PROXY_HOST = "https://ph-relay.networkcanvas.com";
8
+
9
+ /**
10
+ * Dummy API key used for proxy mode
11
+ * PostHog JS library requires an API key for initialization, but when using
12
+ * the reverse proxy, authentication is handled by the Cloudflare Worker.
13
+ * This placeholder key is used for client-side initialization only.
14
+ */
15
+ const PROXY_MODE_DUMMY_KEY = "phc_proxy_mode_placeholder";
16
+
17
+ /**
18
+ * Check if analytics is disabled via environment variables
19
+ */
20
+ export function isDisabledByEnv(): boolean {
21
+ if (typeof process === "undefined") {
22
+ return false;
23
+ }
24
+
25
+ return process.env.DISABLE_ANALYTICS === "true" || process.env.NEXT_PUBLIC_DISABLE_ANALYTICS === "true";
26
+ }
27
+
28
+ /**
29
+ * Default configuration for analytics
30
+ * API host and key are hardcoded, but disabled flag can be set via environment variables
31
+ */
32
+ export const defaultConfig: Partial<AnalyticsConfig> = {
33
+ // Always use the Cloudflare Worker reverse proxy
34
+ apiHost: POSTHOG_PROXY_HOST,
35
+
36
+ // Analytics enabled by default (can be disabled via env var or config option)
37
+ disabled: false,
38
+
39
+ // Debug mode disabled by default
40
+ debug: false,
41
+
42
+ // Default PostHog options
43
+ posthogOptions: {
44
+ // Disable session recording by default (can be enabled per-app)
45
+ disable_session_recording: true,
46
+
47
+ // Disable autocapture to keep events clean and intentional
48
+ autocapture: false,
49
+
50
+ // Disable automatic pageview capture (apps can enable if needed)
51
+ capture_pageview: false,
52
+
53
+ // Disable pageleave events
54
+ capture_pageleave: false,
55
+
56
+ // Don't use cross-subdomain cookies
57
+ cross_subdomain_cookie: false,
58
+
59
+ // Enable feature flags by default
60
+ advanced_disable_feature_flags: false,
61
+
62
+ // Send feature flag events
63
+ advanced_disable_feature_flags_on_first_load: false,
64
+
65
+ // Enable persistence for feature flags
66
+ persistence: "localStorage+cookie",
67
+ },
68
+ };
69
+
70
+ /**
71
+ * Merge user config with defaults
72
+ *
73
+ * Note: This package is designed to work exclusively with the Cloudflare Worker
74
+ * reverse proxy (ph-relay.networkcanvas.com). Authentication is handled by the
75
+ * worker, so the API key is optional and defaults to a placeholder value.
76
+ *
77
+ * The only environment variable checked is DISABLE_ANALYTICS / NEXT_PUBLIC_DISABLE_ANALYTICS
78
+ * for disabling tracking. All other configuration is hardcoded or passed explicitly.
79
+ */
80
+ export function mergeConfig(userConfig: AnalyticsConfig): Required<AnalyticsConfig> {
81
+ return {
82
+ apiHost: userConfig.apiHost ?? defaultConfig.apiHost ?? POSTHOG_PROXY_HOST,
83
+ apiKey: userConfig.apiKey ?? PROXY_MODE_DUMMY_KEY,
84
+ installationId: userConfig.installationId,
85
+ disabled: userConfig.disabled ?? isDisabledByEnv() ?? defaultConfig.disabled ?? false,
86
+ debug: userConfig.debug ?? defaultConfig.debug ?? false,
87
+ posthogOptions: {
88
+ ...defaultConfig.posthogOptions,
89
+ ...userConfig.posthogOptions,
90
+ },
91
+ };
92
+ }
package/src/hooks.ts ADDED
@@ -0,0 +1,79 @@
1
+ "use client";
2
+
3
+ import { useContext } from "react";
4
+ import { AnalyticsContext } from "./provider";
5
+ import type { Analytics } from "./types";
6
+
7
+ /**
8
+ * Hook to access analytics functionality in React components
9
+ *
10
+ * @example
11
+ * ```tsx
12
+ * import { useAnalytics } from '@codaco/analytics';
13
+ *
14
+ * function MyComponent() {
15
+ * const { trackEvent, trackError } = useAnalytics();
16
+ *
17
+ * const handleAction = () => {
18
+ * trackEvent('protocol_installed', {
19
+ * metadata: { protocolName: 'My Protocol' }
20
+ * });
21
+ * };
22
+ *
23
+ * return <button onClick={handleAction}>Install Protocol</button>;
24
+ * }
25
+ * ```
26
+ */
27
+ export function useAnalytics(): Analytics {
28
+ const analytics = useContext(AnalyticsContext);
29
+
30
+ if (!analytics) {
31
+ throw new Error("useAnalytics must be used within an AnalyticsProvider");
32
+ }
33
+
34
+ return analytics;
35
+ }
36
+
37
+ /**
38
+ * Hook to access feature flags
39
+ *
40
+ * @example
41
+ * ```tsx
42
+ * import { useFeatureFlag } from '@codaco/analytics';
43
+ *
44
+ * function MyComponent() {
45
+ * const isNewFeatureEnabled = useFeatureFlag('new-feature');
46
+ *
47
+ * if (isNewFeatureEnabled) {
48
+ * return <NewFeature />;
49
+ * }
50
+ *
51
+ * return <OldFeature />;
52
+ * }
53
+ * ```
54
+ */
55
+ export function useFeatureFlag(flagKey: string): boolean {
56
+ const analytics = useAnalytics();
57
+ return analytics.isFeatureEnabled(flagKey) ?? false;
58
+ }
59
+
60
+ /**
61
+ * Hook to access feature flag values (for multivariate flags)
62
+ *
63
+ * @example
64
+ * ```tsx
65
+ * import { useFeatureFlagValue } from '@codaco/analytics';
66
+ *
67
+ * function MyComponent() {
68
+ * const theme = useFeatureFlagValue('theme-variant');
69
+ *
70
+ * return <div className={theme === 'dark' ? 'dark-theme' : 'light-theme'}>
71
+ * Content
72
+ * </div>;
73
+ * }
74
+ * ```
75
+ */
76
+ export function useFeatureFlagValue(flagKey: string): string | boolean | undefined {
77
+ const analytics = useAnalytics();
78
+ return analytics.getFeatureFlag(flagKey);
79
+ }
package/src/index.ts CHANGED
@@ -1,267 +1,72 @@
1
- import { NextResponse, type NextRequest } from 'next/server';
2
- import { ensureError } from './utils';
3
- import z from 'zod';
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.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(
51
- RawEventSchema,
52
- TrackablePropertiesSchema,
53
- );
54
- export type TrackableEvent = z.infer<typeof TrackableEventSchema>;
55
-
56
- /**
57
- * Dispatchable events are the events that are sent to the platform. The route
58
- * handler injects the installationId and countryISOCode properties.
59
- */
60
- const DispatchablePropertiesSchema = z.object({
61
- installationId: z.string(),
62
- countryISOCode: z.string(),
63
- });
64
-
65
1
  /**
66
- * The final schema for an analytics event. This is the schema that is used to
67
- * validate the event before it is inserted into the database. It is the
68
- * 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
+ * ```
69
50
  */
70
- export const AnalyticsEventSchema = z.intersection(
71
- TrackableEventSchema,
72
- DispatchablePropertiesSchema,
73
- );
74
- export type analyticsEvent = z.infer<typeof AnalyticsEventSchema>;
75
-
76
- type GeoData = {
77
- status: 'success' | 'fail';
78
- countryCode: string;
79
- message: string;
80
- };
81
-
82
- export const createRouteHandler = ({
83
- platformUrl = 'https://analytics.networkcanvas.com',
84
- installationId,
85
- disableAnalytics,
86
- }: {
87
- platformUrl?: string;
88
- installationId: string;
89
- disableAnalytics?: boolean;
90
- }) => {
91
- return async (request: NextRequest) => {
92
- try {
93
- const incomingEvent = (await request.json()) as unknown;
94
-
95
- // Check if analytics is disabled
96
- if (disableAnalytics) {
97
- // eslint-disable-next-line no-console
98
- console.info('🛑 Analytics disabled. Payload not sent.');
99
- try {
100
- // eslint-disable-next-line no-console
101
- console.info(
102
- 'Payload:',
103
- '\n',
104
- JSON.stringify(incomingEvent, null, 2),
105
- );
106
- } catch (e) {
107
- // eslint-disable-next-line no-console
108
- console.error('Error stringifying payload:', e);
109
- }
110
-
111
- return NextResponse.json(
112
- { message: 'Analytics disabled' },
113
- { status: 200 },
114
- );
115
- }
116
-
117
- // Validate the event
118
- const trackableEvent = TrackableEventSchema.safeParse(incomingEvent);
119
-
120
- if (!trackableEvent.success) {
121
- // eslint-disable-next-line no-console
122
- console.error('Invalid event:', trackableEvent.error);
123
- return NextResponse.json({ error: 'Invalid event' }, { status: 400 });
124
- }
125
-
126
- // We don't want failures in third party services to prevent us from
127
- // tracking analytics events, so we'll catch any errors and log them
128
- // and continue with an 'Unknown' country code.
129
- let countryISOCode = 'Unknown';
130
- try {
131
- const ip = await fetch('https://api64.ipify.org').then((res) =>
132
- res.text(),
133
- );
134
-
135
- if (!ip) {
136
- throw new Error('Could not fetch IP address');
137
- }
138
-
139
- const geoData = (await fetch(`http://ip-api.com/json/${ip}`).then(
140
- (res) => res.json(),
141
- )) as GeoData;
142
-
143
- if (geoData.status === 'success') {
144
- countryISOCode = geoData.countryCode;
145
- } else {
146
- throw new Error(geoData.message);
147
- }
148
- } catch (e) {
149
- // eslint-disable-next-line no-console
150
- console.error('Geolocation failed:', e);
151
- }
152
-
153
- const analyticsEvent: analyticsEvent = {
154
- ...trackableEvent.data,
155
- installationId,
156
- countryISOCode,
157
- };
158
-
159
- // Forward to backend
160
- const response = await fetch(`${platformUrl}/api/event`, {
161
- keepalive: true,
162
- method: 'POST',
163
- headers: {
164
- 'Content-Type': 'application/json',
165
- },
166
- body: JSON.stringify(analyticsEvent),
167
- });
168
-
169
- if (!response.ok) {
170
- let error = `Analytics platform returned an unexpected error: ${response.statusText}`;
171
-
172
- if (response.status === 400) {
173
- error = `Analytics platform rejected the event as invalid. Please check the event schema`;
174
- }
175
-
176
- if (response.status === 404) {
177
- error = `Analytics platform could not be reached. Please specify a valid platform URL, or check that the platform is online.`;
178
- }
179
-
180
- if (response.status === 500) {
181
- error = `Analytics platform returned an internal server error. Please check the platform logs.`;
182
- }
183
-
184
- // eslint-disable-next-line no-console
185
- console.info(`⚠️ Analytics platform rejected event: ${error}`);
186
- return Response.json(
187
- {
188
- error,
189
- },
190
- { status: 500 },
191
- );
192
- }
193
- // eslint-disable-next-line no-console
194
- console.info('🚀 Analytics event sent to platform!');
195
- return Response.json({ message: 'Event forwarded successfully' });
196
- } catch (e) {
197
- const error = ensureError(e);
198
- // eslint-disable-next-line no-console
199
- console.info('🚫 Internal error with sending analytics event.');
200
-
201
- return Response.json(
202
- { error: `Error in analytics route handler: ${error.message}` },
203
- { status: 500 },
204
- );
205
- }
206
- };
207
- };
208
-
209
- export const makeEventTracker =
210
- (options?: { endpoint?: string }) =>
211
- async (
212
- event: RawEvent,
213
- ): Promise<{
214
- error: string | null;
215
- success: boolean;
216
- }> => {
217
- // We use a relative path by default, which should automatically use the
218
- // same origin as the page that is sending the event.
219
- const endpoint = options?.endpoint ?? '/api/analytics';
220
-
221
- const eventWithTimeStamp: TrackableEvent = {
222
- ...event,
223
- timestamp: new Date().toJSON(),
224
- };
225
-
226
- try {
227
- const response = await fetch(endpoint, {
228
- method: 'POST',
229
- keepalive: true,
230
- body: JSON.stringify(eventWithTimeStamp),
231
- headers: {
232
- 'Content-Type': 'application/json',
233
- },
234
- });
235
-
236
- if (!response.ok) {
237
- if (response.status === 404) {
238
- return {
239
- error: `Analytics endpoint not found, did you forget to add the route?`,
240
- success: false,
241
- };
242
- }
243
-
244
- // createRouteHandler will return a 400 if the event failed schema validation.
245
- if (response.status === 400) {
246
- return {
247
- error: `Invalid event sent to analytics endpoint: ${response.statusText}`,
248
- success: false,
249
- };
250
- }
251
-
252
- // createRouteHandler will return a 500 for all error states
253
- return {
254
- error: `Internal server error when sending analytics event: ${response.statusText}. Check the route handler implementation.`,
255
- success: false,
256
- };
257
- }
258
51
 
259
- return { error: null, success: true };
260
- } catch (e) {
261
- const error = ensureError(e);
262
- return {
263
- error: `Internal error when sending analytics event: ${error.message}`,
264
- success: false,
265
- };
266
- }
267
- };
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