+5
.vscode/extensions.json
+5
.vscode/extensions.json
+290
AGENTS.md
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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();