@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/CHANGELOG.md +12 -0
- package/MIGRATION.md +404 -0
- package/README.md +486 -4
- package/dist/chunk-3NEQVIC4.js +72 -0
- package/dist/chunk-3NEQVIC4.js.map +1 -0
- package/dist/index.d.ts +113 -82
- package/dist/index.js +188 -160
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +44 -0
- package/dist/server.js +153 -0
- package/dist/server.js.map +1 -0
- package/dist/types-Ymgjicqi.d.ts +145 -0
- package/package.json +27 -7
- package/src/__tests__/client.test.ts +276 -0
- package/src/__tests__/index.test.ts +207 -0
- package/src/__tests__/utils.test.ts +105 -0
- package/src/client.ts +151 -0
- package/src/config.ts +92 -0
- package/src/hooks.ts +79 -0
- package/src/index.ts +69 -237
- package/src/provider.tsx +60 -0
- package/src/server.ts +213 -0
- package/src/types.ts +183 -0
- package/src/utils.ts +1 -0
- package/tsconfig.json +1 -1
- package/vitest.config.ts +18 -0
|
@@ -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
|
+
}
|