@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.
- package/CHANGELOG.md +36 -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-DK5BiTnW.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 +2 -2
- package/vitest.config.ts +18 -0
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
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
package/src/provider.tsx
ADDED
|
@@ -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 type 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
|
+
});
|