Typescript client for Driftline analytics
at main 7.1 kB view raw
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}