@codaco/analytics 8.0.0 → 9.0.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.
@@ -0,0 +1,207 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { defaultConfig, isDisabledByEnv, mergeConfig } from "../config";
3
+ import { ErrorPropertiesSchema, EventPropertiesSchema, type EventType, eventTypes, legacyEventTypeMap } from "../types";
4
+
5
+ describe("Event Types", () => {
6
+ it("should export all event types", () => {
7
+ expect(eventTypes).toEqual([
8
+ "app_setup",
9
+ "protocol_installed",
10
+ "interview_started",
11
+ "interview_completed",
12
+ "data_exported",
13
+ "error",
14
+ ]);
15
+ });
16
+
17
+ it("should map legacy event types to new snake_case types", () => {
18
+ expect(legacyEventTypeMap.AppSetup).toBe("app_setup");
19
+ expect(legacyEventTypeMap.ProtocolInstalled).toBe("protocol_installed");
20
+ expect(legacyEventTypeMap.InterviewStarted).toBe("interview_started");
21
+ expect(legacyEventTypeMap.InterviewCompleted).toBe("interview_completed");
22
+ expect(legacyEventTypeMap.DataExported).toBe("data_exported");
23
+ expect(legacyEventTypeMap.Error).toBe("error");
24
+ });
25
+ });
26
+
27
+ describe("Event Schemas", () => {
28
+ describe("EventPropertiesSchema", () => {
29
+ it("should validate event properties with metadata", () => {
30
+ const properties = {
31
+ metadata: {
32
+ version: "1.0.0",
33
+ userId: "123",
34
+ },
35
+ };
36
+
37
+ const result = EventPropertiesSchema.safeParse(properties);
38
+ expect(result.success).toBe(true);
39
+ });
40
+
41
+ it("should validate event properties without metadata", () => {
42
+ const properties = {};
43
+ const result = EventPropertiesSchema.safeParse(properties);
44
+ expect(result.success).toBe(true);
45
+ });
46
+
47
+ it("should allow arbitrary metadata keys and values", () => {
48
+ const properties = {
49
+ metadata: {
50
+ string: "value",
51
+ number: 42,
52
+ boolean: true,
53
+ nested: { key: "value" },
54
+ array: [1, 2, 3],
55
+ },
56
+ };
57
+
58
+ const result = EventPropertiesSchema.safeParse(properties);
59
+ expect(result.success).toBe(true);
60
+ });
61
+ });
62
+
63
+ describe("ErrorPropertiesSchema", () => {
64
+ it("should validate error properties with all fields", () => {
65
+ const errorProps = {
66
+ message: "Something went wrong",
67
+ name: "TestError",
68
+ stack: "Error: Something went wrong\n at test.ts:10",
69
+ cause: "Root cause",
70
+ metadata: {
71
+ context: "test",
72
+ },
73
+ };
74
+
75
+ const result = ErrorPropertiesSchema.safeParse(errorProps);
76
+ expect(result.success).toBe(true);
77
+ });
78
+
79
+ it("should validate error properties with required fields only", () => {
80
+ const errorProps = {
81
+ message: "Error message",
82
+ name: "Error",
83
+ };
84
+
85
+ const result = ErrorPropertiesSchema.safeParse(errorProps);
86
+ expect(result.success).toBe(true);
87
+ });
88
+
89
+ it("should reject error properties missing message", () => {
90
+ const errorProps = {
91
+ name: "Error",
92
+ };
93
+
94
+ const result = ErrorPropertiesSchema.safeParse(errorProps);
95
+ expect(result.success).toBe(false);
96
+ });
97
+
98
+ it("should reject error properties missing name", () => {
99
+ const errorProps = {
100
+ message: "Error message",
101
+ };
102
+
103
+ const result = ErrorPropertiesSchema.safeParse(errorProps);
104
+ expect(result.success).toBe(false);
105
+ });
106
+ });
107
+ });
108
+
109
+ describe("Configuration", () => {
110
+ describe("defaultConfig", () => {
111
+ it("should have correct default values", () => {
112
+ expect(defaultConfig.apiHost).toBe("https://ph-relay.networkcanvas.com");
113
+ expect(defaultConfig.posthogOptions?.disable_session_recording).toBe(true);
114
+ expect(defaultConfig.posthogOptions?.autocapture).toBe(false);
115
+ expect(defaultConfig.posthogOptions?.capture_pageview).toBe(false);
116
+ expect(defaultConfig.posthogOptions?.advanced_disable_feature_flags).toBe(false);
117
+ });
118
+ });
119
+
120
+ describe("mergeConfig", () => {
121
+ it("should merge user config with defaults", () => {
122
+ const userConfig = {
123
+ installationId: "test-123",
124
+ apiKey: "phc_test",
125
+ };
126
+
127
+ const merged = mergeConfig(userConfig);
128
+
129
+ expect(merged.installationId).toBe("test-123");
130
+ expect(merged.apiKey).toBe("phc_test");
131
+ expect(merged.apiHost).toBe("https://ph-relay.networkcanvas.com");
132
+ expect(merged.disabled).toBe(false);
133
+ });
134
+
135
+ it("should allow overriding default values", () => {
136
+ const userConfig = {
137
+ installationId: "test-123",
138
+ apiKey: "phc_test",
139
+ apiHost: "https://custom.posthog.com",
140
+ disabled: true,
141
+ debug: true,
142
+ };
143
+
144
+ const merged = mergeConfig(userConfig);
145
+
146
+ expect(merged.apiHost).toBe("https://custom.posthog.com");
147
+ expect(merged.disabled).toBe(true);
148
+ expect(merged.debug).toBe(true);
149
+ });
150
+
151
+ it("should use placeholder API key when none provided (proxy mode)", () => {
152
+ const userConfig = {
153
+ installationId: "test-123",
154
+ };
155
+
156
+ const merged = mergeConfig(userConfig);
157
+
158
+ // When using proxy mode, a placeholder key is automatically provided
159
+ expect(merged.apiKey).toBe("phc_proxy_mode_placeholder");
160
+ expect(merged.installationId).toBe("test-123");
161
+ });
162
+
163
+ it("should merge PostHog options", () => {
164
+ const userConfig = {
165
+ installationId: "test-123",
166
+ apiKey: "phc_test",
167
+ posthogOptions: {
168
+ autocapture: true,
169
+ capture_pageview: true,
170
+ },
171
+ };
172
+
173
+ const merged = mergeConfig(userConfig);
174
+
175
+ expect(merged.posthogOptions.autocapture).toBe(true);
176
+ expect(merged.posthogOptions.capture_pageview).toBe(true);
177
+ // Should still have default values for other options
178
+ expect(merged.posthogOptions.disable_session_recording).toBe(true);
179
+ });
180
+ });
181
+ });
182
+
183
+ describe("Environment Variables", () => {
184
+ describe("isDisabledByEnv", () => {
185
+ it("should return a boolean value", () => {
186
+ // This test depends on the actual env vars
187
+ // In a real test, you would mock process.env
188
+ const result = isDisabledByEnv();
189
+ expect(typeof result).toBe("boolean");
190
+ });
191
+ });
192
+ });
193
+
194
+ describe("Type Exports", () => {
195
+ it("should export EventType correctly", () => {
196
+ const validTypes: EventType[] = [
197
+ "app_setup",
198
+ "protocol_installed",
199
+ "interview_started",
200
+ "interview_completed",
201
+ "data_exported",
202
+ "error",
203
+ ];
204
+
205
+ expect(validTypes).toEqual(eventTypes);
206
+ });
207
+ });
@@ -0,0 +1,105 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { ensureError } from "../utils";
3
+
4
+ describe("ensureError", () => {
5
+ it("should return the error if value is already an Error instance", () => {
6
+ const error = new Error("Test error");
7
+ const result = ensureError(error);
8
+ expect(result).toBe(error);
9
+ expect(result.message).toBe("Test error");
10
+ });
11
+
12
+ it("should return a default error when no value is thrown", () => {
13
+ const result = ensureError(null);
14
+ expect(result).toBeInstanceOf(Error);
15
+ expect(result.message).toBe("No value was thrown");
16
+ });
17
+
18
+ it("should return a default error when undefined is thrown", () => {
19
+ const result = ensureError(undefined);
20
+ expect(result).toBeInstanceOf(Error);
21
+ expect(result.message).toBe("No value was thrown");
22
+ });
23
+
24
+ it("should wrap a string in an Error", () => {
25
+ const result = ensureError("Something went wrong");
26
+ expect(result).toBeInstanceOf(Error);
27
+ expect(result.message).toContain("This value was thrown as is, not through an Error");
28
+ expect(result.message).toContain('"Something went wrong"');
29
+ });
30
+
31
+ it("should wrap a number in an Error", () => {
32
+ const result = ensureError(42);
33
+ expect(result).toBeInstanceOf(Error);
34
+ expect(result.message).toContain("This value was thrown as is, not through an Error");
35
+ expect(result.message).toContain("42");
36
+ });
37
+
38
+ it("should wrap an object in an Error with stringified value", () => {
39
+ const obj = { code: "ERR_001", details: "Something failed" };
40
+ const result = ensureError(obj);
41
+ expect(result).toBeInstanceOf(Error);
42
+ expect(result.message).toContain("This value was thrown as is, not through an Error");
43
+ expect(result.message).toContain(JSON.stringify(obj));
44
+ });
45
+
46
+ it("should wrap an array in an Error with stringified value", () => {
47
+ const arr = ["error1", "error2"];
48
+ const result = ensureError(arr);
49
+ expect(result).toBeInstanceOf(Error);
50
+ expect(result.message).toContain("This value was thrown as is, not through an Error");
51
+ expect(result.message).toContain(JSON.stringify(arr));
52
+ });
53
+
54
+ it("should handle circular references gracefully", () => {
55
+ const circular: { prop?: unknown } = {};
56
+ circular.prop = circular;
57
+
58
+ // Mock console.error to avoid test output pollution
59
+ const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
60
+
61
+ const result = ensureError(circular);
62
+ expect(result).toBeInstanceOf(Error);
63
+ expect(result.message).toBe(
64
+ "This value was thrown as is, not through an Error: [Unable to stringify the thrown value]",
65
+ );
66
+ expect(consoleErrorSpy).toHaveBeenCalled();
67
+
68
+ consoleErrorSpy.mockRestore();
69
+ });
70
+
71
+ it("should handle custom error classes that inherit from Error", () => {
72
+ class CustomError extends Error {
73
+ code: string;
74
+ constructor(message: string, code: string) {
75
+ super(message);
76
+ this.code = code;
77
+ this.name = "CustomError";
78
+ }
79
+ }
80
+
81
+ const customError = new CustomError("Custom error message", "CUSTOM_001");
82
+ const result = ensureError(customError);
83
+ expect(result).toBe(customError);
84
+ expect(result.message).toBe("Custom error message");
85
+ });
86
+
87
+ it("should handle boolean values", () => {
88
+ const result = ensureError(true);
89
+ expect(result).toBeInstanceOf(Error);
90
+ expect(result.message).toContain("This value was thrown as is, not through an Error");
91
+ expect(result.message).toContain("true");
92
+ });
93
+
94
+ it("should handle empty string", () => {
95
+ const result = ensureError("");
96
+ expect(result).toBeInstanceOf(Error);
97
+ expect(result.message).toBe("No value was thrown");
98
+ });
99
+
100
+ it("should handle zero as a value", () => {
101
+ const result = ensureError(0);
102
+ expect(result).toBeInstanceOf(Error);
103
+ expect(result.message).toBe("No value was thrown");
104
+ });
105
+ });
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
+ }