Skip to content

Commit 78de573

Browse files
committed
Website: add Umami analytics
- Tracking script in `Layout.astro`, gated on `PUBLIC_UMAMI_WEBSITE_ID` - Download click events with `version` and `arch` properties - Arch-detection script updates `data-umami-event-arch` on link swap - Env vars in `.env.example`, docs in `infrastructure.md`
1 parent ff6a8aa commit 78de573

6 files changed

Lines changed: 306 additions & 2 deletions

File tree

apps/website/.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,13 @@ PUBLIC_PADDLE_ENVIRONMENT=sandbox
1515
# Newsletter (Listmonk)
1616
# Get the list UUID from the Listmonk admin UI: Lists > your list > Settings
1717
PUBLIC_LISTMONK_LIST_UUID=
18+
19+
# Umami analytics (self-hosted, cookieless)
20+
# Leave PUBLIC_UMAMI_WEBSITE_ID empty to disable tracking (for example, in dev env)
21+
PUBLIC_UMAMI_HOST=https://your-umami-instance.example.com
22+
PUBLIC_UMAMI_WEBSITE_ID=
23+
24+
# Umami API credentials to be used in the dev env for scripts (not used by the website in prod!)
25+
UMAMI_API_URL=https://your-umami-instance.example.com
26+
UMAMI_USERNAME=admin
27+
UMAMI_PASSWORD='your-password-here'

apps/website/src/components/Download.astro

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ import NewsletterInlineWrapper from './NewsletterInlineWrapper.astro'
4646
data-dmg-intel={dmgUrls.x86_64}
4747
data-size-arm={dmgSizes?.aarch64}
4848
data-size-intel={dmgSizes?.x86_64}
49+
data-umami-event="download"
50+
data-umami-event-version={version}
51+
data-umami-event-arch="auto"
4952
class="glow mb-4 inline-flex w-full flex-col items-center justify-center gap-1 rounded-xl bg-[var(--color-accent)] px-8 py-4 text-[var(--color-accent-contrast)] transition-all duration-300 hover:scale-[1.02] hover:bg-[var(--color-accent-hover)] motion-reduce:transition-none motion-reduce:hover:scale-100"
5053
>
5154
<span class="flex items-center gap-2 font-semibold">
@@ -68,19 +71,28 @@ import NewsletterInlineWrapper from './NewsletterInlineWrapper.astro'
6871
<a
6972
href={dmgUrls.aarch64}
7073
data-arch-link="aarch64"
74+
data-umami-event="download"
75+
data-umami-event-version={version}
76+
data-umami-event-arch="aarch64"
7177
class="underline underline-offset-2 hover:text-[var(--color-text-secondary)]"
7278
>Apple Silicon{dmgSizes ? ` (${dmgSizes.aarch64})` : ''}</a
7379
>
7480
<span>·</span>
7581
<a
7682
href={dmgUrls.x86_64}
7783
data-arch-link="x86_64"
84+
data-umami-event="download"
85+
data-umami-event-version={version}
86+
data-umami-event-arch="x86_64"
7887
class="underline underline-offset-2 hover:text-[var(--color-text-secondary)]"
7988
>Intel{dmgSizes ? ` (${dmgSizes.x86_64})` : ''}</a
8089
>
8190
<span>·</span>
8291
<a
8392
href={dmgUrls.universal}
93+
data-umami-event="download"
94+
data-umami-event-version={version}
95+
data-umami-event-arch="universal"
8496
class="underline underline-offset-2 hover:text-[var(--color-text-secondary)]"
8597
>Universal{dmgSizes ? ` (${dmgSizes.universal})` : ''}</a
8698
>

apps/website/src/layouts/Layout.astro

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,17 @@ const {
6464
<link rel="preload" as="font" type="font/woff2" href="/fonts/inter-latin-variable.woff2" crossorigin />
6565

6666
<title>{title}</title>
67+
68+
{/* Umami analytics — self-hosted, cookieless */}
69+
{
70+
import.meta.env.PUBLIC_UMAMI_WEBSITE_ID && (
71+
<script
72+
defer
73+
src={`${import.meta.env.PUBLIC_UMAMI_HOST}/script.js`}
74+
data-website-id={import.meta.env.PUBLIC_UMAMI_WEBSITE_ID}
75+
/>
76+
)
77+
}
6778
</head>
6879
<body class="min-h-screen">
6980
<slot />
@@ -78,6 +89,7 @@ const {
7889
document.querySelectorAll('a[data-download-link]').forEach(function (el) {
7990
var url = arch === 'aarch64' ? el.dataset.dmgArm : el.dataset.dmgIntel
8091
if (url) el.href = url
92+
el.dataset.umamiEventArch = arch
8193
var size = arch === 'aarch64' ? el.dataset.sizeArm : el.dataset.sizeIntel
8294
if (size) {
8395
el.querySelectorAll('[data-download-size]').forEach(function (s) {

docs/guides/deploy-website.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,6 @@ Add this secret:
137137
| ------------------------ | -------------------------- |
138138
| `DEPLOY_WEBHOOK_SECRET` | The secret from step 4 |
139139

140-
### 8. Set up Docker network and do initial deploy
141140
### 8. Configure website environment variables
142141

143142
Astro bakes `PUBLIC_*` env vars into the static build, so they must be present in `.env` before building.
@@ -157,6 +156,8 @@ Set the following (get values from the relevant dashboards):
157156
| `PUBLIC_PADDLE_PRICE_ID_*` | Paddle > Catalog > Prices |
158157
| `PUBLIC_PADDLE_ENVIRONMENT` | `sandbox` or `live` |
159158
| `PUBLIC_LISTMONK_LIST_UUID` | Listmonk admin > Lists > your list > Settings |
159+
| `PUBLIC_UMAMI_HOST` | Your Umami instance URL (for example, `https://analytics.example.com`) |
160+
| `PUBLIC_UMAMI_WEBSITE_ID` | Umami > Settings > Websites > getcmdr.com > ID |
160161

161162
### 9. Set up Docker network and do initial deploy
162163

docs/specs/analytics-plan.md

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
# Analytics plan
2+
3+
## Intention
4+
5+
Cmdr is approaching its first public release. We need to understand user behavior so we can make good product decisions,
6+
and track downloads so we know adoption momentum. We want to do this while keeping our privacy-friendly brand intact.
7+
8+
Currently, the privacy policy mentions PostHog and in-app analytics, but **neither is actually implemented**. Downloads
9+
go directly to GitHub Releases with no tracking. This plan closes that gap.
10+
11+
## Guiding principles
12+
13+
- **Privacy first**: No PII, no individual tracking, no cookies where avoidable.
14+
- **Self-hosted where practical**: Umami on our VM, PostHog only where self-hosting is impractical (in-app product
15+
analytics with funnels/retention).
16+
- **Minimal footprint**: No heavy SDKs. HTTP POST calls where possible.
17+
- **Honest privacy policy**: Only claim what we actually do. Update it to match reality.
18+
19+
## Architecture overview
20+
21+
```
22+
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────┐
23+
│ getcmdr.com │ │ Cmdr desktop app │ │ Download redirect │
24+
│ (Astro) │ │ (Tauri) │ │ (CF Worker) │
25+
│ │ │ │ │ │
26+
│ Umami script ──┼──► │ PostHog HTTP ────┼──► │ Logs version + │
27+
│ (cookieless) │ │ capture API │ │ arch + country │
28+
│ │ │ (anonymous) │ │ then 302 → GitHub │
29+
└────────┬────────┘ └────────┬──────────┘ └──────────┬──────────┘
30+
│ │ │
31+
▼ ▼ ▼
32+
Umami server PostHog cloud Cloudflare KV or
33+
(self-hosted VM) (free tier) Analytics Engine
34+
```
35+
36+
## Milestone 1: Website analytics with Umami
37+
38+
### Why Umami instead of PostHog for the website
39+
40+
- Already self-hosted on the VM for other projects — zero additional cost.
41+
- Cookieless by default — no cookie banner needed, simpler privacy policy.
42+
- Lightweight script (~2 KB) vs PostHog's SDK — fits the static Astro site better.
43+
- Good enough for website analytics (page views, referrers, geo, UTM params).
44+
- Full REST API: websites can be created, events sent, and stats queried programmatically via
45+
`POST /api/websites`, `POST /api/send`, `GET /api/websites/:id/stats`, etc. No GUI interaction required for setup.
46+
47+
### Steps
48+
49+
1. **Register getcmdr.com in Umami** via `POST /api/websites` (or via the GUI, either works). Note the website ID.
50+
2. **Add the Umami tracking script** to the website's `<head>` in `apps/website/src/layouts/Layout.astro`:
51+
```html
52+
<script defer src="https://{umami-host}/script.js" data-website-id="{website-id}"></script>
53+
```
54+
Use an env var for the Umami host and website ID so it's not hardcoded.
55+
3. **Track download clicks** as custom events on the download button in `apps/website/src/components/Download.astro`.
56+
Umami supports `data-umami-event` attributes:
57+
```html
58+
<a href="..." data-umami-event="download" data-umami-event-version="0.5.0" data-umami-event-arch="aarch64">
59+
```
60+
This gives us download intent by version and architecture, with geo from Umami automatically.
61+
4. **Disable in dev mode**: Only include the script when `import.meta.env.PROD` is true, or use a feature flag env
62+
var. Remember: `withGlobalTauri: true` in dev mode is a security risk for external scripts.
63+
5. **Verify**: Check the Umami dashboard (or query `GET /api/websites/:id/stats`) to confirm data flows.
64+
65+
## Milestone 2: Download tracking with geo via Cloudflare Worker
66+
67+
### Why a separate endpoint
68+
69+
- Umami's download button events tell us **intent** (clicked download). A redirect endpoint tells us **actual
70+
downloads** (the browser fetched the .dmg).
71+
- Cloudflare Workers have access to `request.cf.country`, `request.cf.city`, `request.cf.continent` for free — no
72+
external geo IP service needed.
73+
- We already run a CF Worker for the license server. This can live alongside it or as a separate Worker.
74+
75+
### Design
76+
77+
`GET https://dl.getcmdr.com/download/:version/:arch` (or a path under the license server domain)
78+
79+
1. Extract `version`, `arch` from the path.
80+
2. Read `request.cf.country` and `request.cf.continent`.
81+
3. Log the event to **Cloudflare Analytics Engine** (free, built into Workers) or increment a counter in **KV**.
82+
4. Respond with `302 → https://github.com/vdavid/cmdr/releases/download/v{version}/Cmdr_{version}_{arch}.dmg`.
83+
84+
#### Decision: Analytics Engine vs KV
85+
86+
- **Analytics Engine** (recommended): purpose-built for this. Write events with dimensions (version, arch, country),
87+
query with SQL via the dashboard or API. No aggregation logic needed. Free tier: 100K events/day.
88+
- **KV**: simpler but requires manual aggregation (composite keys like `downloads:0.5.0:aarch64:SE`). Gets messy for
89+
querying. Better for simple counters only.
90+
91+
Analytics Engine is the better fit unless the free tier becomes a concern (unlikely at launch scale).
92+
93+
### Steps
94+
95+
1. **Decide**: separate Worker (`dl.getcmdr.com`) or new route in the license server. I'd lean toward a new route
96+
in the license server (`/download/:version/:arch`) to avoid another deployment target. But a separate subdomain
97+
is cleaner for CDN caching and separation of concerns.
98+
2. **Implement the redirect endpoint** in Hono:
99+
```ts
100+
app.get('/download/:version/:arch', async (c) => {
101+
const { version, arch } = c.req.param()
102+
const country = c.req.raw.cf?.country ?? 'unknown'
103+
// Log to Analytics Engine or KV
104+
const dmgName = `Cmdr_${version}_${arch}.dmg`
105+
return c.redirect(`https://github.com/vdavid/cmdr/releases/download/v${version}/${dmgName}`, 302)
106+
})
107+
```
108+
3. **Enable Analytics Engine** in `wrangler.toml` (if using it):
109+
```toml
110+
[[analytics_engine_datasets]]
111+
binding = "DOWNLOADS"
112+
```
113+
4. **Update the website download links** in `Download.astro` to point to the redirect endpoint instead of directly
114+
to GitHub.
115+
5. **Update `latest.json`** generation in the release workflow if it contains direct GitHub URLs.
116+
6. **Test**: download via the new URL, verify the redirect works and the event is logged.
117+
118+
## Milestone 3: In-app product analytics with PostHog
119+
120+
### Why PostHog for in-app (not Umami)
121+
122+
- PostHog has funnels, retention, cohorts, and feature flags — Umami doesn't.
123+
- PostHog's free tier (1M events/month) is more than enough for a desktop app's launch phase.
124+
- We only need PostHog's HTTP capture API (`POST https://us.i.posthog.com/capture`), no SDK.
125+
126+
### Design principles for in-app telemetry
127+
128+
- **Anonymous by default**: Use a random `distinct_id` generated on first launch, stored locally. Not tied to email,
129+
license key, or any PII. Rotate it if the user resets the app. This keeps the "no individual tracking" promise.
130+
- **Opt-out available**: Add a toggle in Settings. Respect it immediately.
131+
- **Events, not sessions**: We're a desktop app, not a web app. Track discrete actions, not page views.
132+
- **No content**: Never include file names, paths, AI prompts, or user content in events.
133+
134+
### What to track (starter set)
135+
136+
| Event | Properties | Why |
137+
|---|---|---|
138+
| `app_launched` | `version`, `os_version`, `arch` | Understand active user base |
139+
| `feature_used` | `feature` (for example, `copy`, `network_browse`, `search`, `command_palette`) | Know which features matter |
140+
| `settings_changed` | `setting_key` (not the value) | Understand what people customize |
141+
| `update_installed` | `from_version`, `to_version` | Track update adoption |
142+
| `license_activated` | `license_type` (`personal`/`commercial`) | Understand conversion |
143+
144+
Keep the event list small. Each event should answer a specific product question.
145+
146+
### Steps
147+
148+
1. **Create a PostHog project** (cloud, free tier). Get the project API key.
149+
2. **Implement a telemetry module** in the Svelte frontend (`$lib/telemetry/`):
150+
- `telemetry.svelte.ts`: reactive state for opt-in/opt-out, `distinct_id` generation and persistence.
151+
- `capture.ts`: thin wrapper around `fetch('https://us.i.posthog.com/capture', ...)`. No SDK dependency.
152+
- Respect the opt-out setting. If opted out, `capture()` is a no-op.
153+
3. **Add the opt-out toggle** in Settings, under a "Privacy" or "Telemetry" section.
154+
4. **Instrument the starter events** listed above.
155+
5. **Disable in dev mode**: Don't send events when running `pnpm dev`. Use an env var or check `import.meta.env.DEV`.
156+
6. **Verify**: Check PostHog dashboard for incoming events.
157+
158+
### Open question: Rust-side events?
159+
160+
Some events (like file operation counts) might be easier to capture from Rust. Options:
161+
- Emit them as Tauri events, let the frontend capture and forward to PostHog.
162+
- Or call PostHog's HTTP API directly from Rust (adds a dependency: `reqwest` or similar).
163+
164+
Recommendation: start with frontend-only. Add Rust-side capture later if needed. Simpler to maintain one telemetry
165+
path.
166+
167+
## Milestone 4: Update the privacy policy
168+
169+
The privacy policy currently mentions PostHog for the website and in-app analytics, but neither exists. After
170+
implementing the above:
171+
172+
### Changes needed
173+
174+
1. **Website analytics section**: Replace PostHog with Umami. Mention it's self-hosted and cookieless.
175+
2. **Remove or shrink the cookies section**: Umami doesn't set cookies. Only Paddle might (for checkout).
176+
3. **In-app analytics section**: Keep the current language about aggregate stats. Add that PostHog is the provider.
177+
Emphasize the anonymous `distinct_id` and opt-out toggle.
178+
4. **Download tracking**: Add a brief mention that we track download counts by version and country. No PII involved.
179+
5. **Data processors list**: Remove PostHog from website, add it under in-app. Add a note that website analytics
180+
are self-hosted (no third-party processor).
181+
6. **Update `lastUpdated` date**.
182+
183+
### Net effect on privacy posture
184+
185+
- **Better**: Self-hosted, cookieless website analytics. Fewer third-party processors for the website.
186+
- **Honest**: In-app analytics now actually exist and match what the policy says.
187+
- **Transparent**: Opt-out toggle gives users control.
188+
189+
## Task list
190+
191+
### Milestone 1: Website analytics (Umami)
192+
- [x] Register getcmdr.com in Umami (API or GUI)
193+
- [x] Add Umami script to `Layout.astro` with env var config, disabled in dev
194+
- [x] Add download event tracking to `Download.astro`
195+
- [x] Add env vars to `.env.example` and deployment config
196+
- [x] Set `PUBLIC_UMAMI_HOST` and `PUBLIC_UMAMI_WEBSITE_ID` in production deployment env
197+
- [ ] Verify data flows in Umami dashboard
198+
199+
### Milestone 2: Download tracking (CF Worker)
200+
- [ ] Decide: new route in license server vs separate Worker
201+
- [ ] Enable Analytics Engine in `wrangler.toml`
202+
- [ ] Implement `/download/:version/:arch` redirect endpoint
203+
- [ ] Update website download links to use the redirect
204+
- [ ] Update release workflow if `latest.json` needs changes
205+
- [ ] Test end-to-end: download via redirect, verify event logged
206+
207+
### Milestone 3: In-app analytics (PostHog)
208+
- [ ] Create PostHog project, get API key
209+
- [ ] Implement `$lib/telemetry/` module (capture, opt-out, anonymous ID)
210+
- [ ] Add opt-out toggle in Settings
211+
- [ ] Instrument starter events
212+
- [ ] Disable in dev mode
213+
- [ ] Verify events in PostHog dashboard
214+
215+
### Milestone 4: Privacy policy update
216+
- [ ] Rewrite website analytics section (Umami, self-hosted, cookieless)
217+
- [ ] Rewrite in-app analytics section (PostHog, anonymous, opt-out)
218+
- [ ] Add download tracking mention
219+
- [ ] Update data processors list
220+
- [ ] Shrink cookies section
221+
- [ ] Update `lastUpdated` date
222+
- [ ] Run checks: `./scripts/check.sh --check website-prettier --check website-build`

docs/tooling/infrastructure.md

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,54 @@ cd /opt/cmdr && git pull origin main
5050
For full setup details, see [deploying the website](../guides/deploy-website.md) and the
5151
[Listmonk README](../../infra/listmonk/README.md).
5252

53-
## Services
53+
## Umami (website analytics)
54+
55+
Self-hosted at `https://anal.veszelovszki.com`. Cookieless, GDPR-friendly. Used for getcmdr.com analytics.
56+
57+
- **Dashboard**: https://anal.veszelovszki.com (login required)
58+
- **getcmdr.com website ID**: `5ea041ae-b99d-4c31-b031-89c4a0005456`
59+
60+
### API access
61+
62+
The website's local `.env` file (at `apps/website/.env`) contains Umami API credentials:
63+
64+
```
65+
UMAMI_API_URL=https://anal.veszelovszki.com
66+
UMAMI_USERNAME=...
67+
UMAMI_PASSWORD='...' # single-quoted because it contains special chars
68+
```
69+
70+
These are for scripts and API calls only — the website runtime uses `PUBLIC_UMAMI_HOST` and
71+
`PUBLIC_UMAMI_WEBSITE_ID` instead.
72+
73+
**Authenticate** (returns a JWT token):
74+
75+
```bash
76+
cd apps/website && set -a && source .env && set +a
77+
TOKEN=$(curl -s -X POST "${UMAMI_API_URL}/api/auth/login" \
78+
-H 'Content-Type: application/json' \
79+
-d "$(jq -n --arg u "$UMAMI_USERNAME" --arg p "$UMAMI_PASSWORD" '{username: $u, password: $p}')" \
80+
| jq -r '.token')
81+
```
82+
83+
**List websites**:
84+
85+
```bash
86+
curl -s "${UMAMI_API_URL}/api/websites" -H "Authorization: Bearer $TOKEN" | jq '.'
87+
```
88+
89+
**Query stats** (for example, last 30 days):
90+
91+
```bash
92+
START=$(($(date +%s) * 1000 - 30 * 86400000))
93+
END=$(($(date +%s) * 1000))
94+
curl -s "${UMAMI_API_URL}/api/websites/5ea041ae-b99d-4c31-b031-89c4a0005456/stats?startAt=${START}&endAt=${END}" \
95+
-H "Authorization: Bearer $TOKEN" | jq '.'
96+
```
97+
98+
**Full API docs**: https://umami.is/docs/api
99+
100+
## Other services
54101

55102
| Service | Where | Access | Docs |
56103
| --- | --- | --- | --- |

0 commit comments

Comments
 (0)