# Frappe Product Telemetry

> Add product telemetry to a Frappe app so it reports to Pulse, the way insights, helpdesk and wiki do. A checklist covering consent, the event catalogue file, naming and privacy rules, the backend and frontend wrappers, click events, the daily site scan, and tests. Use when the user wants usage analytics, product metrics, event tracking, Pulse events, or a telemetry spec for a Frappe app.

- Skill: `gajjug004/frappe-product-telemetry` (Agent Skill)
- Install (CLI): `npx skillmds@latest add gajjug004/frappe-product-telemetry`
- Raw SKILL.md: https://api.skillmd.com/api/skills/gajjug004/frappe-product-telemetry/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: gajjug004 (https://skillmd.com/u/gajjug004)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/gajjug004/frappe-product-telemetry

---


# Frappe Product Telemetry

Checklist for wiring a Frappe app to Pulse (`pulse.m.frappe.cloud`). Built from doing it for wiki; insights `develop` is the reference app to copy.

## How it works

Two halves, both ending in Pulse.

- **Backend.** `frappe.utils.telemetry.capture(event, app, properties={})`. No-op when telemetry is off, never raises. Anonymizes the user, adds site and team, pushes to a Redis queue that a scheduler job flushes. `interval="1d"` keeps one row per user per day.
- **Frontend.** `telemetryPlugin` from `@framework/ui` (frappe repo, `frappe/ui/src/telemetry`). It calls `boot_config`, loads the Pulse browser client from a CDN, and posts straight to Pulse. Pass `router` and it sends `pageview` per navigation on young sites, using the route pattern, never the real URL.
- `app_heartbeat` is free: frappe captures it on any `/api/method/<app>.*` request.

## Consent: do not add a setting

Telemetry is on only when all three hold (`pulse/client.py:is_enabled`):

1. `pulse_api_key` is in site config. Frappe Cloud sets it, self-hosted sites do not, so they send nothing.
2. `developer_mode` is off, unless `pulse_force_enabled` is set.
3. System Settings `enable_telemetry` is on.

Both halves read the same gate. Your app adds no setting of its own.

## Checklist

### 0. Prerequisites

- [ ] frappe-ui at or above `1.0.0-beta.63` (the `@framework/ui` peer floor).
- [ ] Link the plugin package in the SPA: `"@framework/ui": "link:../../frappe/ui"` in `package.json`, and `frameworkUI()` from `@framework/ui/vite` in `vite.config.js` plugins. The plugin dedupes vue/frappe-ui and re-runs your resolver for bare imports inside the package; without it `frappe-ui` resolves to nothing inside `@framework/ui`.
- [ ] Import subpaths with the explicit file: `@framework/ui/telemetry/index.ts`, not `@framework/ui/telemetry`.
- [ ] Know the cost: `frappe/ui` lives only on frappe `develop`, so the SPA stops building on older benches.

### 1. Write the questions first

- [ ] List the numbered product questions telemetry must answer ("how many sites are active weekly", "where do new sites stop", "is feature X worth maintaining").
- [ ] Each event must name a question it answers. An event that answers none goes under Planned and does not ship.

### 2. The catalogue file

- [ ] Create `docs/telemetry.md` as the only definition of an event: name, when it fires, every property, which question it answers.
- [ ] Include the question table, the naming rules, the privacy rules, a Retired section (what replaced each name) and a Not tracked section (what is a guarantee, not a decision).
- [ ] A new event or property lands in this file in the same PR as the code.

### 3. Naming and privacy rules

- [ ] `<object>_<verb>`, past tense, snake_case. Variation goes in properties, never the name: `query_created {interface: sql}`, not `sql_query_created`.
- [ ] Property keys flat. Values are enums, booleans or small counts only.
- [ ] Never send titles, slugs, names, content, search terms, emails or record ids. Count them, never name them.
- [ ] Big numbers go through one bucket helper (`duration_bucket`, `rows_bucket`).
- [ ] Map `error_kind` from exception classes, never from the message.
- [ ] Never pass a user. Frappe and the plugin add the anonymized id.

### 4. One wrapper per half

- [ ] `<app>/telemetry.py`: wrap frappe's `capture`, add the shared properties (`app_version`, `entry`), swallow every exception. ~20 lines.
- [ ] `frontend/src/telemetry.js`: wrap `useTelemetry()` and add the same properties.
- [ ] Serve the shared properties from the SPA's existing boot payload if it has one. Only add a whitelisted boot API if it does not.
- [ ] Install the plugin only once the user is signed in.

### 5. Tracer bullet before the rest

- [ ] One event per half, end to end: `capture("active_site")` in the SPA's `www` `get_context` for non-guest users, and `pageview` from the plugin's `router` option.
- [ ] Done when one event sits in the server queue and one leaves the browser with the right `app`.

### 6. Click events

- [ ] Backend by default: it runs once per real action, ad blockers cannot stop it, and it unit tests easily. The frontend sends only what the server never sees.
- [ ] Put each event in the controller or API that owns the action (`after_insert`, `on_update`, the whitelisted method).
- [ ] Reader pages (Jinja, guests) send nothing from the browser.
- [ ] Use `interval="1d"` for "was this used today". Note the daily row keeps the *first* send's properties, so pick properties that still make sense that way. The browser client has no `interval`; dedupe in the query instead.
- [ ] If bulk imports create records, add a `source` property so an import does not read as N authoring events.
- [ ] Fire from the composable or shared function, not the component, so every way in is counted once.

### 7. Daily site scan

Clicks cannot describe a site that installed the app two years ago. A scheduler job can.

- [ ] `<app>/telemetry_scan.py`, one daily job, sending `site_profile`: counts of what exists plus a timeline from `creation` timestamps.
- [ ] Check the consent gate explicitly at the top, so an opted-out site is never queried.
- [ ] One grouped SQL query per group, not a loop over records. Push flags (`LIKE`/`REGEXP` on content) into SQL so bodies never leave the query.
- [ ] Report distributions as median and max, never a per-record list.
- [ ] Count things the way the product resolves them, not field by field, or you double-count.
- [ ] Keep it under the 4096-byte property cap. Measure it on a real site. Split into several events only when it actually grows.
- [ ] Degrade to zeroes when a data source is missing, so one absent table never costs the send.

### 8. Tests

- [ ] Unit test per event group: patch your `capture`, assert each event fires once with the expected name and properties.
- [ ] Assert the negative cases: nothing for Guest, nothing when telemetry is off.
- [ ] Temp-revert each call to prove the test fails. A telemetry test that passes against no code is worthless.
- [ ] One test asserting every event name the code emits appears in `docs/telemetry.md`. That is what keeps the catalogue true.
- [ ] One Playwright test: `page.route` the Pulse CDN url and serve a stub client that records captures on `window`, with `boot_config` stubbed to `enabled: true`. No test ever reaches Pulse.

## Local verification

```bash
bench --site <site> set-config pulse_api_key test-key
bench --site <site> set-config pulse_force_enabled 1
```

Turn on `enable_telemetry` in System Settings. Remove both keys afterwards.

- Server queue: read it with `frappe.cache.lrange`. `get_debug_info(fetch_events=...)` returns an empty list on a non-empty queue (it `lindex`es against a list it `lpush`es), a frappe bug.
- Browser: watch the post to `https://pulse.m.frappe.cloud` in devtools, or intercept it in Playwright.

## Ship order

Phase 0 prerequisites, phase 1 tracer bullet, phase 2 click events, phase 3 daily scan. Commit the spec before the code, reconcile the spec after each phase.

