Typescript client for Driftline analytics
1/**
2 * @module
3 *
4 * Driftline Analytics Client - anonymous analytics for ATProto app views.
5 *
6 * @example Basic usage
7 * ```ts
8 * import { AnalyticsClient, deriveUidFromDid } from "@tijs/driftline-client";
9 *
10 * // Derive anonymous user ID from DID
11 * const uid = await deriveUidFromDid(user.did, "your-app-secret-salt");
12 *
13 * const analytics = new AnalyticsClient({
14 * appView: "kipclip.com",
15 * env: "prod",
16 * collectorUrl: "https://driftline.val.run",
17 * apiKey: "your-api-key",
18 * uid,
19 * });
20 *
21 * // Track events (fire-and-forget, never blocks)
22 * analytics.trackAccountCreated();
23 * analytics.trackView("HomeScreen");
24 * analytics.trackAction("checkin_created", "CheckinScreen", { placeType: "cafe" });
25 * ```
26 */
27
28/**
29 * Environment type for analytics events.
30 * Use "dev" for development/testing, "prod" for production.
31 */
32export type Environment = "dev" | "prod";
33
34/**
35 * Type of analytics event.
36 * - `account` - Track account creation (once per user)
37 * - `view` - Track screen/page impressions
38 * - `action` - Track user actions (clicks, submissions, etc.)
39 */
40export type EventType = "account" | "view" | "action";
41
42/**
43 * Analytics event payload sent to the collector.
44 */
45export type AnalyticsEvent = {
46 /** Event schema version, always 1 */
47 v: 1;
48 /** App view identifier (e.g., "kipclip.com") */
49 appView: string;
50 /** Environment: "dev" or "prod" */
51 env: Environment;
52 /** ISO 8601 timestamp */
53 ts: string;
54 /** Pseudonymous user ID (12-char hex) */
55 uid: string;
56 /** Event type */
57 type: EventType;
58 /** Event name (e.g., "account_created", "screen_impression", "checkin_created") */
59 name: string;
60 /** Screen/page name (optional) */
61 screen?: string;
62 /** Additional properties (optional) */
63 props?: Record<string, unknown>;
64};
65
66/**
67 * Configuration for the AnalyticsClient.
68 *
69 * @example
70 * ```ts
71 * const config: AnalyticsClientConfig = {
72 * appView: "kipclip.com",
73 * env: "prod",
74 * collectorUrl: "https://driftline.val.run",
75 * apiKey: "your-api-key",
76 * uid: "a1b2c3d4e5f6",
77 * };
78 * ```
79 */
80export type AnalyticsClientConfig = {
81 /** App view identifier (e.g., "kipclip.com") */
82 appView: string;
83 /** Environment: "dev" or "prod" */
84 env: Environment;
85 /** Driftline collector URL */
86 collectorUrl: string;
87 /** API key for authentication */
88 apiKey: string;
89 /** Pseudonymous user ID from {@link deriveUidFromDid} */
90 uid: string;
91};
92
93/**
94 * Client for sending analytics events to Driftline.
95 *
96 * @example
97 * ```ts
98 * import { AnalyticsClient, deriveUidFromDid } from "@tijs/driftline-client";
99 *
100 * const uid = await deriveUidFromDid(user.did, "your-salt");
101 *
102 * const analytics = new AnalyticsClient({
103 * appView: "kipclip.com",
104 * env: "prod",
105 * collectorUrl: "https://driftline.val.run",
106 * apiKey: "your-api-key",
107 * uid,
108 * });
109 *
110 * analytics.trackView("HomeScreen");
111 * analytics.trackAction("button_clicked", "HomeScreen", { buttonId: "submit" });
112 * ```
113 */
114export class AnalyticsClient {
115 /**
116 * Create a new AnalyticsClient instance.
117 *
118 * @param cfg - Client configuration
119 */
120 constructor(private cfg: AnalyticsClientConfig) {}
121
122 private createEvent(
123 type: EventType,
124 name: string,
125 screen?: string,
126 props?: Record<string, unknown>,
127 ): AnalyticsEvent {
128 const event: AnalyticsEvent = {
129 v: 1,
130 appView: this.cfg.appView,
131 env: this.cfg.env,
132 ts: new Date().toISOString(),
133 uid: this.cfg.uid,
134 type,
135 name,
136 };
137
138 if (screen) {
139 event.screen = screen;
140 }
141
142 if (props && Object.keys(props).length > 0) {
143 event.props = props;
144 }
145
146 return event;
147 }
148
149 private async send(event: AnalyticsEvent): Promise<void> {
150 const url = this.cfg.collectorUrl.replace(/\/$/, "") + "/collect";
151
152 try {
153 const response = await fetch(url, {
154 method: "POST",
155 headers: {
156 "Content-Type": "application/json",
157 "X-API-Key": this.cfg.apiKey,
158 },
159 body: JSON.stringify(event),
160 });
161
162 if (!response.ok) {
163 const error = await response.json().catch(() => ({
164 error: "Unknown error",
165 }));
166 console.error("[driftline] Failed to send event:", error);
167 }
168 } catch (err) {
169 console.error("[driftline] Network error:", err);
170 }
171 }
172
173 /**
174 * Track when an account is first created/registered for this app view.
175 * Should only be called once per user.
176 *
177 * This method is fire-and-forget and returns immediately without blocking.
178 *
179 * @param props - Optional additional properties
180 *
181 * @example
182 * ```ts
183 * analytics.trackAccountCreated();
184 * analytics.trackAccountCreated({ referrer: "twitter" });
185 * ```
186 */
187 trackAccountCreated(props?: Record<string, unknown>): void {
188 const event = this.createEvent(
189 "account",
190 "account_created",
191 undefined,
192 props,
193 );
194 this.send(event);
195 }
196
197 /**
198 * Track a screen/view impression.
199 *
200 * This method is fire-and-forget and returns immediately without blocking.
201 *
202 * @param screen - Screen or page name
203 * @param props - Optional additional properties
204 *
205 * @example
206 * ```ts
207 * analytics.trackView("HomeScreen");
208 * analytics.trackView("ProfileScreen", { userId: "123" });
209 * ```
210 */
211 trackView(screen: string, props?: Record<string, unknown>): void {
212 const event = this.createEvent("view", "screen_impression", screen, props);
213 this.send(event);
214 }
215
216 /**
217 * Track a user action.
218 *
219 * This method is fire-and-forget and returns immediately without blocking.
220 *
221 * @param name - Action name (e.g., "checkin_created", "button_clicked")
222 * @param screen - Optional screen where the action occurred
223 * @param props - Optional additional properties
224 *
225 * @example
226 * ```ts
227 * analytics.trackAction("checkin_created");
228 * analytics.trackAction("checkin_created", "CheckinScreen");
229 * analytics.trackAction("checkin_created", "CheckinScreen", { placeType: "cafe" });
230 * ```
231 */
232 trackAction(
233 name: string,
234 screen?: string,
235 props?: Record<string, unknown>,
236 ): void {
237 const event = this.createEvent("action", name, screen, props);
238 this.send(event);
239 }
240}
241
242/**
243 * Derive a pseudonymous user ID from a DID using SHA-256.
244 *
245 * The same DID + salt will always produce the same uid.
246 * Different salts (per app view) produce different uids for the same DID,
247 * preventing cross-app-view tracking.
248 *
249 * @param did - The user's DID (e.g., "did:plc:...")
250 * @param salt - App-specific salt (keep secret, store in env vars)
251 * @returns 12-character hex string
252 *
253 * @example
254 * ```ts
255 * const uid = await deriveUidFromDid("did:plc:abc123", "my-secret-salt");
256 * // Returns something like "a1b2c3d4e5f6"
257 * ```
258 */
259export async function deriveUidFromDid(
260 did: string,
261 salt: string,
262): Promise<string> {
263 const data = new TextEncoder().encode(salt + did);
264 const hash = await crypto.subtle.digest("SHA-256", data);
265 const hex = Array.from(new Uint8Array(hash))
266 .map((b) => b.toString(16).padStart(2, "0"))
267 .join("");
268 return hex.slice(0, 12);
269}