@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.
@@ -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
+ });
package/src/types.ts ADDED
@@ -0,0 +1,183 @@
1
+ import z from "zod";
2
+
3
+ /**
4
+ * Event types supported by the analytics system.
5
+ * These are converted to snake_case for PostHog.
6
+ */
7
+ export const eventTypes = [
8
+ "app_setup",
9
+ "protocol_installed",
10
+ "interview_started",
11
+ "interview_completed",
12
+ "data_exported",
13
+ "error",
14
+ ] as const;
15
+
16
+ export type EventType = (typeof eventTypes)[number];
17
+
18
+ /**
19
+ * Legacy event type mapping for backward compatibility
20
+ */
21
+ export const legacyEventTypeMap: Record<string, EventType> = {
22
+ AppSetup: "app_setup",
23
+ ProtocolInstalled: "protocol_installed",
24
+ InterviewStarted: "interview_started",
25
+ InterviewCompleted: "interview_completed",
26
+ DataExported: "data_exported",
27
+ Error: "error",
28
+ };
29
+
30
+ /**
31
+ * Standard event properties that can be sent with any event
32
+ */
33
+ export const EventPropertiesSchema = z.object({
34
+ metadata: z.record(z.string(), z.unknown()).optional(),
35
+ });
36
+
37
+ export type EventProperties = z.infer<typeof EventPropertiesSchema>;
38
+
39
+ /**
40
+ * Error-specific properties for error tracking
41
+ */
42
+ export const ErrorPropertiesSchema = EventPropertiesSchema.extend({
43
+ message: z.string(),
44
+ name: z.string(),
45
+ stack: z.string().optional(),
46
+ cause: z.string().optional(),
47
+ });
48
+
49
+ export type ErrorProperties = z.infer<typeof ErrorPropertiesSchema>;
50
+
51
+ /**
52
+ * Analytics configuration options
53
+ *
54
+ * This package is designed to work exclusively with the Cloudflare Worker
55
+ * reverse proxy at ph-relay.networkcanvas.com. All authentication is handled
56
+ * by the worker, so the API key is optional.
57
+ */
58
+ export interface AnalyticsConfig {
59
+ /**
60
+ * PostHog API host - should point to the Cloudflare Worker reverse proxy
61
+ * Defaults to "https://ph-relay.networkcanvas.com"
62
+ */
63
+ apiHost?: string;
64
+
65
+ /**
66
+ * PostHog project API key (optional)
67
+ *
68
+ * When using the reverse proxy (default), authentication is handled by the
69
+ * Cloudflare Worker. A placeholder key will be used for client-side PostHog
70
+ * initialization if not provided.
71
+ *
72
+ * Only set this if you need to override the default behavior.
73
+ */
74
+ apiKey?: string;
75
+
76
+ /**
77
+ * Unique identifier for this installation/deployment
78
+ * This is included with every event as a super property
79
+ */
80
+ installationId: string;
81
+
82
+ /**
83
+ * Disable all analytics tracking
84
+ * Can be set via DISABLE_ANALYTICS or NEXT_PUBLIC_DISABLE_ANALYTICS env var
85
+ */
86
+ disabled?: boolean;
87
+
88
+ /**
89
+ * Enable debug mode for PostHog
90
+ */
91
+ debug?: boolean;
92
+
93
+ /**
94
+ * Additional options to pass to PostHog initialization
95
+ */
96
+ posthogOptions?: {
97
+ /**
98
+ * Disable session recording
99
+ */
100
+ disable_session_recording?: boolean;
101
+
102
+ /**
103
+ * Autocapture settings
104
+ */
105
+ autocapture?: boolean;
106
+
107
+ /**
108
+ * Capture pageviews automatically
109
+ */
110
+ capture_pageview?: boolean;
111
+
112
+ /**
113
+ * Capture pageleave events
114
+ */
115
+ capture_pageleave?: boolean;
116
+
117
+ /**
118
+ * Cross-subdomain cookie
119
+ */
120
+ cross_subdomain_cookie?: boolean;
121
+
122
+ /**
123
+ * Advanced feature flags support
124
+ */
125
+ advanced_disable_feature_flags?: boolean;
126
+
127
+ /**
128
+ * Other PostHog options
129
+ */
130
+ [key: string]: unknown;
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Analytics instance interface
136
+ */
137
+ export interface Analytics {
138
+ /**
139
+ * Track a custom event
140
+ */
141
+ trackEvent: (eventType: EventType | string, properties?: EventProperties) => void;
142
+
143
+ /**
144
+ * Track an error with full stack trace
145
+ */
146
+ trackError: (error: Error, additionalProperties?: EventProperties) => void;
147
+
148
+ /**
149
+ * Check if a feature flag is enabled
150
+ */
151
+ isFeatureEnabled: (flagKey: string) => boolean | undefined;
152
+
153
+ /**
154
+ * Get the value of a feature flag
155
+ */
156
+ getFeatureFlag: (flagKey: string) => string | boolean | undefined;
157
+
158
+ /**
159
+ * Reload feature flags from PostHog
160
+ */
161
+ reloadFeatureFlags: () => void;
162
+
163
+ /**
164
+ * Identify a user (optional - for advanced use cases)
165
+ * Note: By default we only track installations, not users
166
+ */
167
+ identify: (distinctId: string, properties?: Record<string, unknown>) => void;
168
+
169
+ /**
170
+ * Reset the user identity
171
+ */
172
+ reset: () => void;
173
+
174
+ /**
175
+ * Check if analytics is enabled
176
+ */
177
+ isEnabled: () => boolean;
178
+
179
+ /**
180
+ * Get the installation ID
181
+ */
182
+ getInstallationId: () => string;
183
+ }
package/src/utils.ts CHANGED
@@ -1,23 +1,20 @@
1
1
  // Helper function that ensures that a value is an Error
2
2
  export function ensureError(value: unknown): Error {
3
- if (!value) return new Error('No value was thrown');
3
+ if (!value) return new Error("No value was thrown");
4
4
 
5
- if (value instanceof Error) return value;
5
+ if (value instanceof Error) return value;
6
6
 
7
- // Test if value inherits from Error
8
- if (Object.prototype.isPrototypeOf.call(value, Error))
9
- return value as Error & typeof value;
7
+ // Test if value inherits from Error
8
+ if (Object.prototype.isPrototypeOf.call(value, Error)) return value as Error & typeof value;
10
9
 
11
- let stringified = '[Unable to stringify the thrown value]';
12
- try {
13
- stringified = JSON.stringify(value);
14
- } catch (e) {
15
- // eslint-disable-next-line no-console
16
- console.error(e);
17
- }
10
+ let stringified = "[Unable to stringify the thrown value]";
11
+ try {
12
+ stringified = JSON.stringify(value);
13
+ } catch (e) {
14
+ // biome-ignore lint/suspicious/noConsole: logging
15
+ console.error(e);
16
+ }
18
17
 
19
- const error = new Error(
20
- `This value was thrown as is, not through an Error: ${stringified}`,
21
- );
22
- return error;
18
+ const error = new Error(`This value was thrown as is, not through an Error: ${stringified}`);
19
+ return error;
23
20
  }
package/tsconfig.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
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"]
2
+ "extends": "@codaco/tsconfig/web.json",
3
+ "compilerOptions": {
4
+ "baseUrl": ".",
5
+ "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
6
+ },
7
+ "include": ["."],
8
+ "exclude": ["dist", "build", "node_modules"]
9
9
  }
@@ -0,0 +1,18 @@
1
+ /// <reference types="vitest" />
2
+
3
+ import { dirname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { defineConfig } from "vitest/config";
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+
9
+ export default defineConfig({
10
+ resolve: {
11
+ alias: {
12
+ "~": resolve(__dirname, "./src"),
13
+ },
14
+ },
15
+ test: {
16
+ disableConsoleIntercept: true,
17
+ },
18
+ });
@@ -1,16 +0,0 @@
1
-
2
- 
3
- > @codaco/analytics@6.0.0 build /Users/buckhalt/Code/complexdatacollective/network-canvas-monorepo/packages/analytics
4
- > tsup src/index.ts --format esm --dts --clean --sourcemap
5
-
6
- CLI Building entry: src/index.ts
7
- CLI Using tsconfig: tsconfig.json
8
- CLI tsup v8.0.2
9
- CLI Target: es2022
10
- CLI Cleaning output folder
11
- ESM Build start
12
- ESM dist/index.js 6.32 KB
13
- ESM dist/index.js.map 13.22 KB
14
- ESM ⚡️ Build success in 167ms
15
- DTS Build start
16
-  ELIFECYCLE  Command failed.
@@ -1,7 +0,0 @@
1
-
2
- 
3
- > @codaco/analytics@7.0.0 lint /Users/buckhalt/Code/complexdatacollective/network-canvas-monorepo/packages/analytics
4
- > eslint .
5
-
6
- Warning: React version was set to "detect" in eslint-plugin-react settings, but the "react" package is not installed. Assuming latest React version for linting.
7
- Pages directory cannot be found at /Users/buckhalt/Code/complexdatacollective/network-canvas-monorepo/packages/analytics/pages or /Users/buckhalt/Code/complexdatacollective/network-canvas-monorepo/packages/analytics/src/pages. If using a custom path, please configure with the `no-html-link-for-pages` rule in your eslint config file.