just the currents, never the identities

Initial release v0.1.0

Anonymous analytics service for ATProto app views.

- Event collection API
- Stats API endpoints
- Per-app-view API key authentication
- TypeScript client library
- Admin endpoint for API key management

tijs.org 18e42e68

+2
.gitignore
··· 1 + .DS_Store 2 + .vt/
+5
.vscode/extensions.json
··· 1 + { 2 + "recommendations": [ 3 + "denoland.vscode-deno" 4 + ] 5 + }
+6
.vtignore
··· 1 + .git 2 + .vscode 3 + .cursorrules 4 + .DS_Store 5 + node_modules 6 + vendor
+290
AGENTS.md
··· 1 + You are an advanced assistant specialized in generating Val Town code. 2 + 3 + ## Core Guidelines 4 + 5 + - Ask clarifying questions when requirements are ambiguous 6 + - Provide complete, functional solutions rather than skeleton implementations 7 + - Test your logic against edge cases before presenting the final solution 8 + - Ensure all code follows Val Town's specific platform requirements 9 + - If a section of code that you're working on is getting too complex, consider refactoring it into subcomponents 10 + 11 + ## Code Standards 12 + 13 + - Generate code in TypeScript or TSX 14 + - Add appropriate TypeScript types and interfaces for all data structures 15 + - Prefer official SDKs or libraries than writing API calls directly 16 + - Ask the user to supply API or library documentation if you are at all unsure about it 17 + - **Never bake in secrets into the code** - always use environment variables 18 + - Include comments explaining complex logic (avoid commenting obvious operations) 19 + - Follow modern ES6+ conventions and functional programming practices if possible 20 + 21 + ## Types of triggers 22 + 23 + ### 1. HTTP Trigger 24 + 25 + - Create web APIs and endpoints 26 + - Handle HTTP requests and responses 27 + - Example structure: 28 + 29 + ```ts 30 + export default async function (req: Request) { 31 + return new Response("Hello World"); 32 + } 33 + ``` 34 + 35 + Files that are HTTP triggers have http in their name like `foobar.http.tsx` 36 + 37 + ### 2. Cron Triggers 38 + 39 + - Run on a schedule 40 + - Use cron expressions for timing 41 + - Example structure: 42 + 43 + ```ts 44 + export default async function () { 45 + // Scheduled task code 46 + } 47 + ``` 48 + 49 + Files that are Cron triggers have cron in their name like `foobar.cron.tsx` 50 + 51 + ### 3. Email Triggers 52 + 53 + - Process incoming emails 54 + - Handle email-based workflows 55 + - Example structure: 56 + 57 + ```ts 58 + export default async function (email: Email) { 59 + // Process email 60 + } 61 + ``` 62 + 63 + Files that are Email triggers have email in their name like `foobar.email.tsx` 64 + 65 + 66 + ## Val Town Standard Libraries 67 + 68 + Val Town provides several hosted services and utility functions. 69 + 70 + ### Blob Storage 71 + 72 + ```ts 73 + import { blob } from "https://esm.town/v/std/blob"; 74 + await blob.setJSON("myKey", { hello: "world" }); 75 + let blobDemo = await blob.getJSON("myKey"); 76 + let appKeys = await blob.list("app_"); 77 + await blob.delete("myKey"); 78 + ``` 79 + 80 + ### SQLite 81 + 82 + ```ts 83 + import { sqlite } from "https://esm.town/v/stevekrouse/sqlite"; 84 + const TABLE_NAME = 'todo_app_users_2'; 85 + // Create table - do this before usage and change table name when modifying schema 86 + await sqlite.execute(`CREATE TABLE IF NOT EXISTS ${TABLE_NAME} ( 87 + id INTEGER PRIMARY KEY AUTOINCREMENT, 88 + name TEXT NOT NULL 89 + )`); 90 + // Query data 91 + const result = await sqlite.execute(`SELECT * FROM ${TABLE_NAME} WHERE id = ?`, [1]); 92 + ``` 93 + 94 + Note: When changing a SQLite table's schema, change the table's name (e.g., add _2 or _3) to create a fresh table. 95 + 96 + ### OpenAI 97 + 98 + ```ts 99 + import { OpenAI } from "https://esm.town/v/std/openai"; 100 + const openai = new OpenAI(); 101 + const completion = await openai.chat.completions.create({ 102 + messages: [ 103 + { role: "user", content: "Say hello in a creative way" }, 104 + ], 105 + model: "gpt-4o-mini", 106 + max_tokens: 30, 107 + }); 108 + ``` 109 + 110 + ### Email 111 + 112 + ```ts 113 + import { email } from "https://esm.town/v/std/email"; 114 + // By default emails the owner of the val 115 + await email({ 116 + subject: "Hi", 117 + text: "Hi", 118 + html: "<h1>Hi</h1>" 119 + }); 120 + ``` 121 + 122 + ## Val Town Utility Functions 123 + 124 + Val Town provides several utility functions to help with common project tasks. 125 + 126 + ### Importing Utilities 127 + 128 + Always import utilities with version pins to avoid breaking changes: 129 + 130 + ```ts 131 + import { parseProject, readFile, serveFile } from "https://esm.town/v/std/utils@85-main/index.ts"; 132 + ``` 133 + 134 + ### Available Utilities 135 + 136 + 137 + #### **serveFile** - Serve project files with proper content types 138 + 139 + For example, in Hono: 140 + 141 + ```ts 142 + // serve all files in frontend/ and shared/ 143 + app.get("/frontend/*", c => serveFile(c.req.path, import.meta.url)); 144 + app.get("/shared/*", c => serveFile(c.req.path, import.meta.url)); 145 + ``` 146 + 147 + #### **readFile** - Read files from within the project: 148 + 149 + ```ts 150 + // Read a file from the project 151 + const fileContent = await readFile("/frontend/index.html", import.meta.url); 152 + ``` 153 + 154 + #### **listFiles** - List all files in the project 155 + 156 + ```ts 157 + const files = await listFiles(import.meta.url); 158 + ``` 159 + 160 + #### **parseProject** - Extract information about the current project from import.meta.url 161 + 162 + This is useful for including info for linking back to a val, ie in "view source" urls: 163 + 164 + ```ts 165 + const projectVal = parseProject(import.meta.url); 166 + console.log(projectVal.username); // Owner of the project 167 + console.log(projectVal.name); // Project name 168 + console.log(projectVal.version); // Version number 169 + console.log(projectVal.branch); // Branch name 170 + console.log(projectVal.links.self.project); // URL to the project page 171 + ``` 172 + 173 + However, it's *extremely importing* to note that `parseProject` and other Standard Library utilities ONLY RUN ON THE SERVER. 174 + If you need access to this data on the client, run it in the server and pass it to the client by splicing it into the HTML page 175 + or by making an API request for it. 176 + 177 + ## Val Town Platform Specifics 178 + 179 + - **Redirects:** Use `return new Response(null, { status: 302, headers: { Location: "/place/to/redirect" }})` instead of `Response.redirect` which is broken 180 + - **Images:** Avoid external images or base64 images. Use emojis, unicode symbols, or icon fonts/libraries instead 181 + - **AI Image:** To inline generate an AI image use: `<img src="https://maxm-imggenurl.web.val.run/the-description-of-your-image" />` 182 + - **Storage:** DO NOT use the Deno KV module for storage 183 + - **Browser APIs:** DO NOT use the `alert()`, `prompt()`, or `confirm()` methods 184 + - **Weather Data:** Use open-meteo for weather data (doesn't require API keys) unless otherwise specified 185 + - **View Source:** Add a view source link by importing & using `import.meta.url.replace("ems.sh", "val.town)"` (or passing this data to the client) and include `target="_top"` attribute 186 + - **Error Debugging:** Add `<script src="https://esm.town/v/std/catch"></script>` to HTML to capture client-side errors 187 + - **Error Handling:** Only use try...catch when there's a clear local resolution; Avoid catches that merely log or return 500s. Let errors bubble up with full context 188 + - **Environment Variables:** Use `Deno.env.get('keyname')` when you need to, but generally prefer APIs that don't require keys 189 + - **Imports:** Use `https://esm.sh` for npm and Deno dependencies to ensure compatibility on server and browser 190 + - **Storage Strategy:** Only use backend storage if explicitly required; prefer simple static client-side sites 191 + - **React Configuration:** When using React libraries, pin versions with `?deps=react@18.2.0,react-dom@18.2.0` and start the file with `/** @jsxImportSource https://esm.sh/react@18.2.0 */` 192 + - Ensure all React dependencies and sub-dependencies are pinned to the same version 193 + - **Styling:** Default to using TailwindCSS via `<script src="https://cdn.twind.style" crossorigin></script>` unless otherwise specified 194 + 195 + ## Project Structure and Design Patterns 196 + 197 + ### Recommended Directory Structure 198 + ``` 199 + ├── backend/ 200 + │ ├── database/ 201 + │ │ ├── migrations.ts # Schema definitions 202 + │ │ ├── queries.ts # DB query functions 203 + │ │ └── README.md 204 + │ └── routes/ # Route modules 205 + │ ├── [route].ts 206 + │ └── static.ts # Static file serving 207 + │ ├── index.ts # Main entry point 208 + │ └── README.md 209 + ├── frontend/ 210 + │ ├── components/ 211 + │ │ ├── App.tsx 212 + │ │ └── [Component].tsx 213 + │ ├── favicon.svg 214 + │ ├── index.html # Main HTML template 215 + │ ├── index.tsx # Frontend JS entry point 216 + │ ├── README.md 217 + │ └── style.css 218 + ├── README.md 219 + └── shared/ 220 + ├── README.md 221 + └── utils.ts # Shared types and functions 222 + ``` 223 + 224 + ### Backend (Hono) Best Practices 225 + 226 + - Hono is the recommended API framework 227 + - Main entry point should be `backend/index.ts` 228 + - **Static asset serving:** Use the utility functions to read and serve project files: 229 + ```ts 230 + import { readFile, serveFile } from "https://esm.town/v/std/utils@85-main/index.ts"; 231 + 232 + // serve all files in frontend/ and shared/ 233 + app.get("/frontend/*", c => serveFile(c.req.path, import.meta.url)); 234 + app.get("/shared/*", c => serveFile(c.req.path, import.meta.url)); 235 + 236 + // For index.html, often you'll want to bootstrap with initial data 237 + app.get("/", async c => { 238 + let html = await readFile("/frontend/index.html", import.meta.url); 239 + 240 + // Inject data to avoid extra round-trips 241 + const initialData = await fetchInitialData(); 242 + const dataScript = `<script> 243 + window.__INITIAL_DATA__ = ${JSON.stringify(initialData)}; 244 + </script>`; 245 + 246 + html = html.replace("</head>", `${dataScript}</head>`); 247 + return c.html(html); 248 + }); 249 + ``` 250 + - Create RESTful API routes for CRUD operations 251 + - Always include this snippet at the top-level Hono app to re-throwing errors to see full stack traces: 252 + ```ts 253 + // Unwrap Hono errors to see original error details 254 + app.onError((err, c) => { 255 + throw err; 256 + }); 257 + ``` 258 + 259 + ### Database Patterns 260 + - Run migrations on startup or comment out for performance 261 + - Change table names when modifying schemas rather than altering 262 + - Export clear query functions with proper TypeScript typing 263 + 264 + ## Common Gotchas and Solutions 265 + 266 + 1. **Environment Limitations:** 267 + - Val Town runs on Deno in a serverless context, not Node.js 268 + - Code in `shared/` must work in both frontend and backend environments 269 + - Cannot use `Deno` keyword in shared code 270 + - Use `https://esm.sh` for imports that work in both environments 271 + 272 + 2. **SQLite Peculiarities:** 273 + - Limited support for ALTER TABLE operations 274 + - Create new tables with updated schemas and copy data when needed 275 + - Always run table creation before querying 276 + 277 + 3. **React Configuration:** 278 + - All React dependencies must be pinned to 18.2.0 279 + - Always include `@jsxImportSource https://esm.sh/react@18.2.0` at the top of React files 280 + - Rendering issues often come from mismatched React versions 281 + 282 + 4. **File Handling:** 283 + - Val Town only supports text files, not binary 284 + - Use the provided utilities to read files across branches and forks 285 + - For files in the project, use `readFile` helpers 286 + 287 + 5. **API Design:** 288 + - `fetch` handler is the entry point for HTTP vals 289 + - Run the Hono app with `export default app.fetch // This is the entry point for HTTP vals` 290 +
+15
CHANGELOG.md
··· 1 + # Changelog 2 + 3 + ## [0.1.0] - 2025-11-28 4 + 5 + Initial release of Driftline Analytics. 6 + 7 + ### Added 8 + 9 + - Event collection API (`POST /collect`) 10 + - Stats API endpoints (`GET /stats/:appView`) 11 + - Per-app-view API key authentication 12 + - TypeScript client library with `AnalyticsClient` class 13 + - `deriveUidFromDid` helper for anonymous user ID generation 14 + - Admin endpoint for API key management 15 + - SQLite storage with indexed queries
+118
README.md
··· 1 + # Driftline Analytics 2 + 3 + Anonymous analytics service for ATProto app views, hosted on Valtown. 4 + 5 + ## Features 6 + 7 + - Anonymous by design: users are identified by pseudonymous IDs derived from DIDs 8 + - Per-app-view isolation: same user gets different IDs across different app views 9 + - Simple event model: accounts, views, and actions 10 + - JSON stats API 11 + 12 + ## API Endpoints 13 + 14 + Base URL: `https://driftline.val.run` 15 + 16 + ### Health Check 17 + 18 + ``` 19 + GET / 20 + ``` 21 + 22 + ### Collect Events 23 + 24 + ``` 25 + POST /collect 26 + Headers: X-API-Key: <your-api-key> 27 + Content-Type: application/json 28 + 29 + Body (single event): 30 + { 31 + "v": 1, 32 + "appView": "kipclip.com", 33 + "env": "prod", 34 + "ts": "2025-01-15T10:30:00.000Z", 35 + "uid": "a1b2c3d4e5f6", 36 + "type": "action", 37 + "name": "checkin_created", 38 + "screen": "CheckinScreen", 39 + "props": { "placeType": "cafe" } 40 + } 41 + 42 + Body (batch): 43 + { 44 + "events": [...] 45 + } 46 + ``` 47 + 48 + Event types: 49 + - `account` - Track account creation (once per user) 50 + - `view` - Track screen impressions 51 + - `action` - Track user actions 52 + 53 + ### Get Stats 54 + 55 + All stats endpoints require the `X-API-Key` header. 56 + 57 + ``` 58 + GET /stats/:appView?env=prod 59 + GET /stats/:appView/accounts?env=prod 60 + GET /stats/:appView/users?env=prod 61 + GET /stats/:appView/events?env=prod 62 + ``` 63 + 64 + ## Client Usage 65 + 66 + ```typescript 67 + import { 68 + AnalyticsClient, 69 + deriveUidFromDid 70 + } from "https://esm.town/v/tijs/driftline-analytics/client/analytics-client.ts"; 71 + 72 + // Derive anonymous user ID from DID (use your app-specific salt) 73 + const uid = await deriveUidFromDid(user.did, KIPCLIP_SALT); 74 + 75 + const analytics = new AnalyticsClient({ 76 + appView: "kipclip.com", 77 + env: "prod", 78 + collectorUrl: "https://driftline.val.run", 79 + apiKey: KIPCLIP_API_KEY, 80 + uid, 81 + }); 82 + 83 + // Track events 84 + await analytics.trackAccountCreated(); 85 + await analytics.trackView("HomeScreen"); 86 + await analytics.trackAction("checkin_created", "CheckinScreen", { placeType: "cafe" }); 87 + ``` 88 + 89 + ## Anonymity 90 + 91 + User IDs are derived using SHA-256: 92 + 93 + ```typescript 94 + uid = sha256(salt + did).slice(0, 12) 95 + ``` 96 + 97 + - Each app view uses its own salt 98 + - Same DID produces different UIDs across app views 99 + - Server never sees the original DID 100 + 101 + ## Admin 102 + 103 + Create API keys (requires `ADMIN_SECRET` env var): 104 + 105 + ``` 106 + POST /admin/api-keys 107 + Headers: X-Admin-Secret: <admin-secret> 108 + Body: { "appView": "your-app.com" } 109 + ``` 110 + 111 + ## Development 112 + 113 + ```bash 114 + deno task fmt # Format code 115 + deno task lint # Lint code 116 + deno task check # Type check 117 + deno task deploy # Format, lint, check, and push to Valtown 118 + ```
+58
backend/database/migrations.ts
··· 1 + /** 2 + * Database migrations for Driftline Analytics 3 + */ 4 + 5 + import { sqlite } from "https://esm.town/v/stevekrouse/sqlite?v=13"; 6 + 7 + const EVENTS_TABLE = "driftline_events"; 8 + const API_KEYS_TABLE = "driftline_api_keys"; 9 + 10 + export async function runMigrations(): Promise<void> { 11 + // Events table 12 + await sqlite.execute(` 13 + CREATE TABLE IF NOT EXISTS ${EVENTS_TABLE} ( 14 + id INTEGER PRIMARY KEY AUTOINCREMENT, 15 + ts TEXT NOT NULL, 16 + app_view TEXT NOT NULL, 17 + env TEXT NOT NULL, 18 + type TEXT NOT NULL, 19 + name TEXT NOT NULL, 20 + uid TEXT NOT NULL, 21 + screen TEXT, 22 + props TEXT 23 + ) 24 + `); 25 + 26 + // Indexes for events 27 + await sqlite.execute(` 28 + CREATE INDEX IF NOT EXISTS ${EVENTS_TABLE}_app_view_idx 29 + ON ${EVENTS_TABLE} (app_view, env, ts) 30 + `); 31 + 32 + await sqlite.execute(` 33 + CREATE INDEX IF NOT EXISTS ${EVENTS_TABLE}_type_idx 34 + ON ${EVENTS_TABLE} (app_view, type, name) 35 + `); 36 + 37 + await sqlite.execute(` 38 + CREATE INDEX IF NOT EXISTS ${EVENTS_TABLE}_uid_idx 39 + ON ${EVENTS_TABLE} (app_view, uid) 40 + `); 41 + 42 + // API keys table 43 + await sqlite.execute(` 44 + CREATE TABLE IF NOT EXISTS ${API_KEYS_TABLE} ( 45 + id INTEGER PRIMARY KEY AUTOINCREMENT, 46 + app_view TEXT NOT NULL UNIQUE, 47 + api_key TEXT NOT NULL, 48 + created TEXT NOT NULL 49 + ) 50 + `); 51 + 52 + await sqlite.execute(` 53 + CREATE INDEX IF NOT EXISTS ${API_KEYS_TABLE}_key_idx 54 + ON ${API_KEYS_TABLE} (api_key) 55 + `); 56 + } 57 + 58 + export { API_KEYS_TABLE, EVENTS_TABLE };
+240
backend/database/queries.ts
··· 1 + /** 2 + * Database query functions for Driftline Analytics 3 + */ 4 + 5 + import { sqlite } from "https://esm.town/v/stevekrouse/sqlite?v=13"; 6 + import { API_KEYS_TABLE, EVENTS_TABLE } from "./migrations.ts"; 7 + import type { 8 + AnalyticsEvent, 9 + Environment, 10 + EventType, 11 + StatsResponse, 12 + } from "../../shared/types.ts"; 13 + 14 + // API Key functions 15 + 16 + export async function validateApiKey(apiKey: string): Promise<string | null> { 17 + const result = await sqlite.execute({ 18 + sql: `SELECT app_view FROM ${API_KEYS_TABLE} WHERE api_key = ?`, 19 + args: [apiKey], 20 + }); 21 + 22 + if (result.rows.length === 0) { 23 + return null; 24 + } 25 + 26 + return result.rows[0].app_view as string; 27 + } 28 + 29 + export async function createApiKey( 30 + appView: string, 31 + apiKey: string, 32 + ): Promise<void> { 33 + await sqlite.execute({ 34 + sql: 35 + `INSERT INTO ${API_KEYS_TABLE} (app_view, api_key, created) VALUES (?, ?, ?)`, 36 + args: [appView, apiKey, new Date().toISOString()], 37 + }); 38 + } 39 + 40 + // Event storage 41 + 42 + export async function storeEvents(events: AnalyticsEvent[]): Promise<void> { 43 + for (const event of events) { 44 + await sqlite.execute({ 45 + sql: ` 46 + INSERT INTO ${EVENTS_TABLE} (ts, app_view, env, type, name, uid, screen, props) 47 + VALUES (?, ?, ?, ?, ?, ?, ?, ?) 48 + `, 49 + args: [ 50 + event.ts, 51 + event.appView, 52 + event.env, 53 + event.type, 54 + event.name, 55 + event.uid, 56 + event.screen ?? null, 57 + event.props ? JSON.stringify(event.props) : null, 58 + ], 59 + }); 60 + } 61 + } 62 + 63 + // Stats queries 64 + 65 + export async function getAccountCount( 66 + appView: string, 67 + env: Environment, 68 + ): Promise<number> { 69 + const result = await sqlite.execute({ 70 + sql: ` 71 + SELECT COUNT(DISTINCT uid) as count 72 + FROM ${EVENTS_TABLE} 73 + WHERE app_view = ? AND env = ? AND type = 'account' AND name = 'account_created' 74 + `, 75 + args: [appView, env], 76 + }); 77 + 78 + return (result.rows[0]?.count as number) ?? 0; 79 + } 80 + 81 + export async function getUniqueUsers( 82 + appView: string, 83 + env: Environment, 84 + ): Promise<number> { 85 + const result = await sqlite.execute({ 86 + sql: ` 87 + SELECT COUNT(DISTINCT uid) as count 88 + FROM ${EVENTS_TABLE} 89 + WHERE app_view = ? AND env = ? 90 + `, 91 + args: [appView, env], 92 + }); 93 + 94 + return (result.rows[0]?.count as number) ?? 0; 95 + } 96 + 97 + export async function getTotalEvents( 98 + appView: string, 99 + env: Environment, 100 + ): Promise<number> { 101 + const result = await sqlite.execute({ 102 + sql: ` 103 + SELECT COUNT(*) as count 104 + FROM ${EVENTS_TABLE} 105 + WHERE app_view = ? AND env = ? 106 + `, 107 + args: [appView, env], 108 + }); 109 + 110 + return (result.rows[0]?.count as number) ?? 0; 111 + } 112 + 113 + export async function getEventsByType( 114 + appView: string, 115 + env: Environment, 116 + ): Promise<Record<EventType, number>> { 117 + const result = await sqlite.execute({ 118 + sql: ` 119 + SELECT type, COUNT(*) as count 120 + FROM ${EVENTS_TABLE} 121 + WHERE app_view = ? AND env = ? 122 + GROUP BY type 123 + `, 124 + args: [appView, env], 125 + }); 126 + 127 + const counts: Record<EventType, number> = { 128 + account: 0, 129 + view: 0, 130 + action: 0, 131 + }; 132 + 133 + for (const row of result.rows) { 134 + const type = row.type as EventType; 135 + counts[type] = row.count as number; 136 + } 137 + 138 + return counts; 139 + } 140 + 141 + export async function getEventsByName( 142 + appView: string, 143 + env: Environment, 144 + ): Promise<Record<string, number>> { 145 + const result = await sqlite.execute({ 146 + sql: ` 147 + SELECT name, COUNT(*) as count 148 + FROM ${EVENTS_TABLE} 149 + WHERE app_view = ? AND env = ? 150 + GROUP BY name 151 + ORDER BY count DESC 152 + `, 153 + args: [appView, env], 154 + }); 155 + 156 + const counts: Record<string, number> = {}; 157 + for (const row of result.rows) { 158 + counts[row.name as string] = row.count as number; 159 + } 160 + 161 + return counts; 162 + } 163 + 164 + export async function getTopScreens( 165 + appView: string, 166 + env: Environment, 167 + limit: number = 10, 168 + ): Promise<Array<{ screen: string; count: number }>> { 169 + const result = await sqlite.execute({ 170 + sql: ` 171 + SELECT screen, COUNT(*) as count 172 + FROM ${EVENTS_TABLE} 173 + WHERE app_view = ? AND env = ? AND screen IS NOT NULL 174 + GROUP BY screen 175 + ORDER BY count DESC 176 + LIMIT ? 177 + `, 178 + args: [appView, env, limit], 179 + }); 180 + 181 + return result.rows.map((row) => ({ 182 + screen: row.screen as string, 183 + count: row.count as number, 184 + })); 185 + } 186 + 187 + export async function getTopActions( 188 + appView: string, 189 + env: Environment, 190 + limit: number = 10, 191 + ): Promise<Array<{ name: string; count: number }>> { 192 + const result = await sqlite.execute({ 193 + sql: ` 194 + SELECT name, COUNT(*) as count 195 + FROM ${EVENTS_TABLE} 196 + WHERE app_view = ? AND env = ? AND type = 'action' 197 + GROUP BY name 198 + ORDER BY count DESC 199 + LIMIT ? 200 + `, 201 + args: [appView, env, limit], 202 + }); 203 + 204 + return result.rows.map((row) => ({ 205 + name: row.name as string, 206 + count: row.count as number, 207 + })); 208 + } 209 + 210 + export async function getStats( 211 + appView: string, 212 + env: Environment, 213 + ): Promise<StatsResponse> { 214 + const [ 215 + totalAccounts, 216 + totalUsers, 217 + totalEvents, 218 + eventsByType, 219 + topScreens, 220 + topActions, 221 + ] = await Promise.all([ 222 + getAccountCount(appView, env), 223 + getUniqueUsers(appView, env), 224 + getTotalEvents(appView, env), 225 + getEventsByType(appView, env), 226 + getTopScreens(appView, env), 227 + getTopActions(appView, env), 228 + ]); 229 + 230 + return { 231 + appView, 232 + env, 233 + totalAccounts, 234 + totalUsers, 235 + totalEvents, 236 + eventsByType, 237 + topScreens, 238 + topActions, 239 + }; 240 + }
+56
backend/index.http.ts
··· 1 + /** 2 + * Driftline Analytics - Main HTTP entry point 3 + * 4 + * Anonymous analytics service for ATProto app views. 5 + */ 6 + 7 + import { Hono } from "https://esm.sh/hono@4.4.0"; 8 + import { cors } from "https://esm.sh/hono@4.4.0/cors"; 9 + import { runMigrations } from "./database/migrations.ts"; 10 + import { admin } from "./routes/admin.ts"; 11 + import { collector } from "./routes/collector.ts"; 12 + import { stats } from "./routes/stats.ts"; 13 + 14 + const app = new Hono(); 15 + 16 + // Re-throw errors to see full stack traces 17 + app.onError((err, _c) => { 18 + throw err; 19 + }); 20 + 21 + // Enable CORS for client-side tracking 22 + app.use( 23 + "*", 24 + cors({ 25 + origin: "*", 26 + allowMethods: ["GET", "POST", "OPTIONS"], 27 + allowHeaders: ["Content-Type", "X-API-Key"], 28 + }), 29 + ); 30 + 31 + // Run migrations on startup 32 + let migrationsRan = false; 33 + app.use("*", async (_c, next) => { 34 + if (!migrationsRan) { 35 + await runMigrations(); 36 + migrationsRan = true; 37 + } 38 + await next(); 39 + }); 40 + 41 + // Health check 42 + app.get("/", (c) => { 43 + return c.json({ 44 + service: "driftline-analytics", 45 + status: "ok", 46 + version: 1, 47 + }); 48 + }); 49 + 50 + // Mount routes 51 + app.route("/admin", admin); 52 + app.route("/collect", collector); 53 + app.route("/stats", stats); 54 + 55 + // Export for Valtown HTTP trigger 56 + export default app.fetch;
+51
backend/routes/admin.ts
··· 1 + /** 2 + * Admin routes for Driftline Analytics 3 + * Protected by ADMIN_SECRET environment variable 4 + */ 5 + 6 + import { Hono } from "https://esm.sh/hono@4.4.0"; 7 + import { createApiKey } from "../database/queries.ts"; 8 + 9 + const admin = new Hono(); 10 + 11 + admin.post("/api-keys", async (c) => { 12 + const adminSecret = Deno.env.get("ADMIN_SECRET"); 13 + if (!adminSecret) { 14 + return c.json({ error: "Admin not configured" }, 500); 15 + } 16 + 17 + const providedSecret = c.req.header("X-Admin-Secret"); 18 + if (providedSecret !== adminSecret) { 19 + return c.json({ error: "Unauthorized" }, 401); 20 + } 21 + 22 + let body: { appView?: string }; 23 + try { 24 + body = await c.req.json(); 25 + } catch { 26 + return c.json({ error: "Invalid JSON body" }, 400); 27 + } 28 + 29 + if (!body.appView || typeof body.appView !== "string") { 30 + return c.json({ error: "appView is required" }, 400); 31 + } 32 + 33 + const apiKey = crypto.randomUUID(); 34 + 35 + try { 36 + await createApiKey(body.appView, apiKey); 37 + } catch (err) { 38 + if (err instanceof Error && err.message.includes("UNIQUE constraint")) { 39 + return c.json({ error: "API key already exists for this app view" }, 409); 40 + } 41 + throw err; 42 + } 43 + 44 + return c.json({ 45 + success: true, 46 + appView: body.appView, 47 + apiKey, 48 + }); 49 + }); 50 + 51 + export { admin };
+57
backend/routes/collector.ts
··· 1 + /** 2 + * Event collector routes for Driftline Analytics 3 + */ 4 + 5 + import { Hono } from "https://esm.sh/hono@4.4.0"; 6 + import { storeEvents, validateApiKey } from "../database/queries.ts"; 7 + import { 8 + validateAppViewMatch, 9 + validateCollectRequest, 10 + } from "../../shared/validation.ts"; 11 + import type { CollectResponse } from "../../shared/types.ts"; 12 + 13 + const collector = new Hono(); 14 + 15 + collector.post("/", async (c) => { 16 + // Validate API key 17 + const apiKey = c.req.header("X-API-Key"); 18 + if (!apiKey) { 19 + return c.json({ error: "Missing X-API-Key header" }, 401); 20 + } 21 + 22 + const appViewForKey = await validateApiKey(apiKey); 23 + if (!appViewForKey) { 24 + return c.json({ error: "Invalid API key" }, 401); 25 + } 26 + 27 + // Parse and validate request body 28 + let body: unknown; 29 + try { 30 + body = await c.req.json(); 31 + } catch { 32 + return c.json({ error: "Invalid JSON body" }, 400); 33 + } 34 + 35 + const validation = validateCollectRequest(body); 36 + if (!validation.valid) { 37 + return c.json({ error: validation.error }, 400); 38 + } 39 + 40 + // Ensure all events match the API key's app_view 41 + const mismatch = validateAppViewMatch(validation.events, appViewForKey); 42 + if (mismatch) { 43 + return c.json({ error: mismatch }, 403); 44 + } 45 + 46 + // Store events 47 + await storeEvents(validation.events); 48 + 49 + const response: CollectResponse = { 50 + success: true, 51 + count: validation.events.length, 52 + }; 53 + 54 + return c.json(response); 55 + }); 56 + 57 + export { collector };
+129
backend/routes/stats.ts
··· 1 + /** 2 + * Stats API routes for Driftline Analytics 3 + */ 4 + 5 + import { Hono } from "https://esm.sh/hono@4.4.0"; 6 + import { 7 + getAccountCount, 8 + getEventsByName, 9 + getEventsByType, 10 + getStats, 11 + getUniqueUsers, 12 + validateApiKey, 13 + } from "../database/queries.ts"; 14 + import type { 15 + AccountsResponse, 16 + Environment, 17 + EventsResponse, 18 + UsersResponse, 19 + } from "../../shared/types.ts"; 20 + 21 + const stats = new Hono(); 22 + 23 + // Middleware to validate API key and app_view match 24 + async function validateAccess( 25 + c: { 26 + req: { 27 + header: (name: string) => string | undefined; 28 + param: (name: string) => string; 29 + }; 30 + }, 31 + appView: string, 32 + ): Promise<{ error: string; status: number } | null> { 33 + const apiKey = c.req.header("X-API-Key"); 34 + if (!apiKey) { 35 + return { error: "Missing X-API-Key header", status: 401 }; 36 + } 37 + 38 + const appViewForKey = await validateApiKey(apiKey); 39 + if (!appViewForKey) { 40 + return { error: "Invalid API key", status: 401 }; 41 + } 42 + 43 + if (appViewForKey !== appView) { 44 + return { error: "API key does not match requested app_view", status: 403 }; 45 + } 46 + 47 + return null; 48 + } 49 + 50 + function getEnv( 51 + c: { req: { query: (name: string) => string | undefined } }, 52 + ): Environment { 53 + const env = c.req.query("env"); 54 + if (env === "dev") return "dev"; 55 + return "prod"; 56 + } 57 + 58 + // Full stats 59 + stats.get("/:appView", async (c) => { 60 + const appView = c.req.param("appView"); 61 + const accessError = await validateAccess(c, appView); 62 + if (accessError) { 63 + return c.json( 64 + { error: accessError.error }, 65 + accessError.status as 401 | 403, 66 + ); 67 + } 68 + 69 + const env = getEnv(c); 70 + const result = await getStats(appView, env); 71 + return c.json(result); 72 + }); 73 + 74 + // Account count only 75 + stats.get("/:appView/accounts", async (c) => { 76 + const appView = c.req.param("appView"); 77 + const accessError = await validateAccess(c, appView); 78 + if (accessError) { 79 + return c.json( 80 + { error: accessError.error }, 81 + accessError.status as 401 | 403, 82 + ); 83 + } 84 + 85 + const env = getEnv(c); 86 + const count = await getAccountCount(appView, env); 87 + const response: AccountsResponse = { appView, env, count }; 88 + return c.json(response); 89 + }); 90 + 91 + // Unique users only 92 + stats.get("/:appView/users", async (c) => { 93 + const appView = c.req.param("appView"); 94 + const accessError = await validateAccess(c, appView); 95 + if (accessError) { 96 + return c.json( 97 + { error: accessError.error }, 98 + accessError.status as 401 | 403, 99 + ); 100 + } 101 + 102 + const env = getEnv(c); 103 + const count = await getUniqueUsers(appView, env); 104 + const response: UsersResponse = { appView, env, count }; 105 + return c.json(response); 106 + }); 107 + 108 + // Event breakdown 109 + stats.get("/:appView/events", async (c) => { 110 + const appView = c.req.param("appView"); 111 + const accessError = await validateAccess(c, appView); 112 + if (accessError) { 113 + return c.json( 114 + { error: accessError.error }, 115 + accessError.status as 401 | 403, 116 + ); 117 + } 118 + 119 + const env = getEnv(c); 120 + const [byType, byName] = await Promise.all([ 121 + getEventsByType(appView, env), 122 + getEventsByName(appView, env), 123 + ]); 124 + 125 + const response: EventsResponse = { appView, env, byType, byName }; 126 + return c.json(response); 127 + }); 128 + 129 + export { stats };
+155
client/analytics-client.ts
··· 1 + /** 2 + * Driftline Analytics Client 3 + * 4 + * TypeScript client for tracking analytics events from ATProto app views. 5 + * 6 + * Usage: 7 + * const uid = await deriveUidFromDid(user.did, YOUR_APP_SALT); 8 + * const analytics = new AnalyticsClient({ 9 + * appView: "xyz.kipclip.feed", 10 + * env: "prod", 11 + * collectorUrl: "https://your-analytics.val.run", 12 + * apiKey: YOUR_API_KEY, 13 + * uid, 14 + * }); 15 + * 16 + * await analytics.trackAccountCreated(); 17 + * await analytics.trackView("HomeScreen"); 18 + * await analytics.trackAction("checkin_created", "CheckinScreen", { placeType: "cafe" }); 19 + */ 20 + 21 + export type Environment = "dev" | "prod"; 22 + export type EventType = "account" | "view" | "action"; 23 + 24 + export type AnalyticsEvent = { 25 + v: 1; 26 + appView: string; 27 + env: Environment; 28 + ts: string; 29 + uid: string; 30 + type: EventType; 31 + name: string; 32 + screen?: string; 33 + props?: Record<string, unknown>; 34 + }; 35 + 36 + export type AnalyticsClientConfig = { 37 + appView: string; 38 + env: Environment; 39 + collectorUrl: string; 40 + apiKey: string; 41 + uid: string; 42 + }; 43 + 44 + export class AnalyticsClient { 45 + constructor(private cfg: AnalyticsClientConfig) {} 46 + 47 + private createEvent( 48 + type: EventType, 49 + name: string, 50 + screen?: string, 51 + props?: Record<string, unknown>, 52 + ): AnalyticsEvent { 53 + const event: AnalyticsEvent = { 54 + v: 1, 55 + appView: this.cfg.appView, 56 + env: this.cfg.env, 57 + ts: new Date().toISOString(), 58 + uid: this.cfg.uid, 59 + type, 60 + name, 61 + }; 62 + 63 + if (screen) { 64 + event.screen = screen; 65 + } 66 + 67 + if (props && Object.keys(props).length > 0) { 68 + event.props = props; 69 + } 70 + 71 + return event; 72 + } 73 + 74 + private async send(event: AnalyticsEvent): Promise<void> { 75 + const url = this.cfg.collectorUrl.replace(/\/$/, "") + "/collect"; 76 + 77 + try { 78 + const response = await fetch(url, { 79 + method: "POST", 80 + headers: { 81 + "Content-Type": "application/json", 82 + "X-API-Key": this.cfg.apiKey, 83 + }, 84 + body: JSON.stringify(event), 85 + }); 86 + 87 + if (!response.ok) { 88 + const error = await response.json().catch(() => ({ 89 + error: "Unknown error", 90 + })); 91 + console.error("[analytics] Failed to send event:", error); 92 + } 93 + } catch (err) { 94 + console.error("[analytics] Network error:", err); 95 + } 96 + } 97 + 98 + /** 99 + * Track when an account is first created/registered for this app view. 100 + * Should only be called once per user. 101 + */ 102 + async trackAccountCreated(props?: Record<string, unknown>): Promise<void> { 103 + const event = this.createEvent( 104 + "account", 105 + "account_created", 106 + undefined, 107 + props, 108 + ); 109 + await this.send(event); 110 + } 111 + 112 + /** 113 + * Track a screen/view impression. 114 + */ 115 + async trackView( 116 + screen: string, 117 + props?: Record<string, unknown>, 118 + ): Promise<void> { 119 + const event = this.createEvent("view", "screen_impression", screen, props); 120 + await this.send(event); 121 + } 122 + 123 + /** 124 + * Track a user action. 125 + */ 126 + async trackAction( 127 + name: string, 128 + screen?: string, 129 + props?: Record<string, unknown>, 130 + ): Promise<void> { 131 + const event = this.createEvent("action", name, screen, props); 132 + await this.send(event); 133 + } 134 + } 135 + 136 + /** 137 + * Derive a pseudonymous user ID from a DID. 138 + * The same DID + salt will always produce the same uid. 139 + * Different salts (per app view) produce different uids for the same DID. 140 + * 141 + * @param did - The user's DID (e.g., "did:plc:...") 142 + * @param salt - App-specific salt (keep secret, store in env vars) 143 + * @returns 12-character hex string 144 + */ 145 + export async function deriveUidFromDid( 146 + did: string, 147 + salt: string, 148 + ): Promise<string> { 149 + const data = new TextEncoder().encode(salt + did); 150 + const hash = await crypto.subtle.digest("SHA-256", data); 151 + const hex = Array.from(new Uint8Array(hash)) 152 + .map((b) => b.toString(16).padStart(2, "0")) 153 + .join(""); 154 + return hex.slice(0, 12); 155 + }
+43
deno.json
··· 1 + { 2 + "$schema": "https://raw.githubusercontent.com/denoland/deno/98f62cee78e85bfc47c62ed703777c6bc8794f1c/cli/schemas/config-file.v1.json", 3 + "name": "driftline-analytics", 4 + "version": "0.1.0", 5 + "exports": "./client/analytics-client.ts", 6 + "lock": false, 7 + "compilerOptions": { 8 + "noImplicitAny": false, 9 + "strict": true, 10 + "types": ["https://www.val.town/types/valtown.d.ts"], 11 + "lib": [ 12 + "dom", 13 + "dom.iterable", 14 + "dom.asynciterable", 15 + "deno.ns", 16 + "deno.unstable" 17 + ] 18 + }, 19 + "lint": { 20 + "include": ["**/*.ts"], 21 + "rules": { 22 + "exclude": ["no-explicit-any", "no-import-prefix"] 23 + } 24 + }, 25 + "fmt": { 26 + "include": ["**/*.ts", "**/*.json"] 27 + }, 28 + "tasks": { 29 + "check": "deno check --allow-import backend/index.http.ts client/analytics-client.ts", 30 + "lint": "deno lint", 31 + "fmt": "deno fmt", 32 + "fmt:check": "deno fmt --check", 33 + "test": "deno test --allow-import", 34 + "deploy": "deno task fmt && deno task lint && deno task check && vt push" 35 + }, 36 + "node_modules_dir": false, 37 + "experimental": { 38 + "unstable-node-globals": true, 39 + "unstable-temporal": true, 40 + "unstable-worker-options": true, 41 + "unstable-sloppy-imports": true 42 + } 43 + }
+40
scripts/create-api-key.ts
··· 1 + /** 2 + * Script to create an API key for an app view. 3 + * Run via: deno run --allow-import scripts/create-api-key.ts <app_view> 4 + * 5 + * This script must be run in the Valtown environment (or use their API). 6 + * For local testing, copy this logic into a val and run it there. 7 + */ 8 + 9 + import { sqlite } from "https://esm.town/v/stevekrouse/sqlite?v=13"; 10 + 11 + const API_KEYS_TABLE = "driftline_api_keys"; 12 + 13 + async function createApiKey(appView: string): Promise<string> { 14 + const apiKey = crypto.randomUUID(); 15 + 16 + await sqlite.execute({ 17 + sql: 18 + `INSERT INTO ${API_KEYS_TABLE} (app_view, api_key, created) VALUES (?, ?, ?)`, 19 + args: [appView, apiKey, new Date().toISOString()], 20 + }); 21 + 22 + return apiKey; 23 + } 24 + 25 + async function main() { 26 + const appView = Deno.args[0]; 27 + 28 + if (!appView) { 29 + console.error("Usage: deno run scripts/create-api-key.ts <app_view>"); 30 + console.error("Example: deno run scripts/create-api-key.ts kipclip.com"); 31 + Deno.exit(1); 32 + } 33 + 34 + console.log(`Creating API key for app view: ${appView}`); 35 + const apiKey = await createApiKey(appView); 36 + console.log(`API key created: ${apiKey}`); 37 + console.log(`\nStore this in your Valtown secrets!`); 38 + } 39 + 40 + main();
+74
shared/types.ts
··· 1 + /** 2 + * Analytics event types and shared interfaces for Driftline Analytics 3 + */ 4 + 5 + export type EventType = "account" | "view" | "action"; 6 + export type Environment = "dev" | "prod"; 7 + 8 + export type AnalyticsEvent = { 9 + v: 1; 10 + appView: string; 11 + env: Environment; 12 + ts: string; 13 + uid: string; 14 + type: EventType; 15 + name: string; 16 + screen?: string; 17 + props?: Record<string, unknown>; 18 + }; 19 + 20 + export type CollectRequest = AnalyticsEvent | { events: AnalyticsEvent[] }; 21 + 22 + export type CollectResponse = { 23 + success: boolean; 24 + count: number; 25 + }; 26 + 27 + export type StatsResponse = { 28 + appView: string; 29 + env: Environment; 30 + totalAccounts: number; 31 + totalUsers: number; 32 + totalEvents: number; 33 + eventsByType: Record<EventType, number>; 34 + topScreens: Array<{ screen: string; count: number }>; 35 + topActions: Array<{ name: string; count: number }>; 36 + }; 37 + 38 + export type AccountsResponse = { 39 + appView: string; 40 + env: Environment; 41 + count: number; 42 + }; 43 + 44 + export type UsersResponse = { 45 + appView: string; 46 + env: Environment; 47 + count: number; 48 + }; 49 + 50 + export type EventsResponse = { 51 + appView: string; 52 + env: Environment; 53 + byType: Record<EventType, number>; 54 + byName: Record<string, number>; 55 + }; 56 + 57 + export type ApiKeyRecord = { 58 + id: number; 59 + app_view: string; 60 + api_key: string; 61 + created: string; 62 + }; 63 + 64 + export type EventRecord = { 65 + id: number; 66 + ts: string; 67 + app_view: string; 68 + env: string; 69 + type: string; 70 + name: string; 71 + uid: string; 72 + screen: string | null; 73 + props: string | null; 74 + };
+120
shared/validation.ts
··· 1 + /** 2 + * Event validation for Driftline Analytics 3 + */ 4 + 5 + import type { AnalyticsEvent, Environment, EventType } from "./types.ts"; 6 + 7 + const VALID_ENVS: Environment[] = ["dev", "prod"]; 8 + const VALID_TYPES: EventType[] = ["account", "view", "action"]; 9 + const UID_PATTERN = /^[a-f0-9]{12}$/; 10 + 11 + export type ValidationResult = 12 + | { valid: true; events: AnalyticsEvent[] } 13 + | { valid: false; error: string }; 14 + 15 + function isValidIsoDate(str: string): boolean { 16 + const date = new Date(str); 17 + return !isNaN(date.getTime()) && date.toISOString() === str; 18 + } 19 + 20 + function validateSingleEvent(event: unknown, index?: number): string | null { 21 + const prefix = index !== undefined ? `Event[${index}]: ` : ""; 22 + 23 + if (!event || typeof event !== "object") { 24 + return `${prefix}Event must be an object`; 25 + } 26 + 27 + const e = event as Record<string, unknown>; 28 + 29 + if (e.v !== 1) { 30 + return `${prefix}Version (v) must be 1`; 31 + } 32 + 33 + if (typeof e.appView !== "string" || e.appView.length === 0) { 34 + return `${prefix}appView must be a non-empty string`; 35 + } 36 + 37 + if (!VALID_ENVS.includes(e.env as Environment)) { 38 + return `${prefix}env must be 'dev' or 'prod'`; 39 + } 40 + 41 + if (typeof e.ts !== "string" || !isValidIsoDate(e.ts)) { 42 + return `${prefix}ts must be a valid ISO timestamp`; 43 + } 44 + 45 + if (typeof e.uid !== "string" || !UID_PATTERN.test(e.uid)) { 46 + return `${prefix}uid must be a 12-character hex string`; 47 + } 48 + 49 + if (!VALID_TYPES.includes(e.type as EventType)) { 50 + return `${prefix}type must be 'account', 'view', or 'action'`; 51 + } 52 + 53 + if (typeof e.name !== "string" || e.name.length === 0) { 54 + return `${prefix}name must be a non-empty string`; 55 + } 56 + 57 + if (e.screen !== undefined && typeof e.screen !== "string") { 58 + return `${prefix}screen must be a string if provided`; 59 + } 60 + 61 + if ( 62 + e.props !== undefined && (typeof e.props !== "object" || e.props === null) 63 + ) { 64 + return `${prefix}props must be an object if provided`; 65 + } 66 + 67 + return null; 68 + } 69 + 70 + export function validateCollectRequest(body: unknown): ValidationResult { 71 + if (!body || typeof body !== "object") { 72 + return { valid: false, error: "Request body must be an object" }; 73 + } 74 + 75 + const b = body as Record<string, unknown>; 76 + 77 + // Check if it's a batch request 78 + if ("events" in b && Array.isArray(b.events)) { 79 + if (b.events.length === 0) { 80 + return { valid: false, error: "Events array cannot be empty" }; 81 + } 82 + 83 + if (b.events.length > 100) { 84 + return { valid: false, error: "Maximum 100 events per request" }; 85 + } 86 + 87 + const events: AnalyticsEvent[] = []; 88 + for (let i = 0; i < b.events.length; i++) { 89 + const error = validateSingleEvent(b.events[i], i); 90 + if (error) { 91 + return { valid: false, error }; 92 + } 93 + events.push(b.events[i] as AnalyticsEvent); 94 + } 95 + 96 + return { valid: true, events }; 97 + } 98 + 99 + // Single event 100 + const error = validateSingleEvent(body); 101 + if (error) { 102 + return { valid: false, error }; 103 + } 104 + 105 + return { valid: true, events: [body as AnalyticsEvent] }; 106 + } 107 + 108 + export function validateAppViewMatch( 109 + events: AnalyticsEvent[], 110 + expectedAppView: string, 111 + ): string | null { 112 + for (let i = 0; i < events.length; i++) { 113 + if (events[i].appView !== expectedAppView) { 114 + return `Event[${i}]: appView '${ 115 + events[i].appView 116 + }' does not match API key's app_view '${expectedAppView}'`; 117 + } 118 + } 119 + return null; 120 + }