+6
CHANGELOG.md
+6
CHANGELOG.md
+2
-23
README.md
+2
-23
README.md
···
61
61
GET /stats/:appView/events?env=prod
62
62
```
63
63
64
-
## Client Usage
64
+
## Client
65
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
-
```
66
+
See [@tijs/driftline-client](https://jsr.io/@tijs/driftline-client) for the TypeScript client library.
88
67
89
68
## Anonymity
90
69
-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
-
}
+2
-3
deno.json
+2
-3
deno.json
···
1
1
{
2
2
"$schema": "https://raw.githubusercontent.com/denoland/deno/98f62cee78e85bfc47c62ed703777c6bc8794f1c/cli/schemas/config-file.v1.json",
3
3
"name": "driftline-analytics",
4
-
"version": "0.1.0",
5
-
"exports": "./client/analytics-client.ts",
4
+
"version": "0.1.1",
6
5
"lock": false,
7
6
"compilerOptions": {
8
7
"noImplicitAny": false,
···
26
25
"include": ["**/*.ts", "**/*.json"]
27
26
},
28
27
"tasks": {
29
-
"check": "deno check --allow-import backend/index.http.ts client/analytics-client.ts",
28
+
"check": "deno check --allow-import backend/index.http.ts",
30
29
"lint": "deno lint",
31
30
"fmt": "deno fmt",
32
31
"fmt:check": "deno fmt --check",
-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();