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.
telemetryPluginfrom@framework/ui(frappe repo,frappe/ui/src/telemetry). It callsboot_config, loads the Pulse browser client from a CDN, and posts straight to Pulse. Passrouterand it sendspageviewper navigation on young sites, using the route pattern, never the real URL. app_heartbeatis 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):
pulse_api_keyis in site config. Frappe Cloud sets it, self-hosted sites do not, so they send nothing.developer_modeis off, unlesspulse_force_enabledis set.- System Settings
enable_telemetryis 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/uipeer floor). - Link the plugin package in the SPA:
"@framework/ui": "link:../../frappe/ui"inpackage.json, andframeworkUI()from@framework/ui/viteinvite.config.jsplugins. The plugin dedupes vue/frappe-ui and re-runs your resolver for bare imports inside the package; without itfrappe-uiresolves to nothing inside@framework/ui. - Import subpaths with the explicit file:
@framework/ui/telemetry/index.ts, not@framework/ui/telemetry. - Know the cost:
frappe/uilives only on frappedevelop, 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.mdas 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}, notsql_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_kindfrom 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'scapture, add the shared properties (app_version,entry), swallow every exception. ~20 lines. -
frontend/src/telemetry.js: wrapuseTelemetry()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'swwwget_contextfor non-guest users, andpageviewfrom the plugin'srouteroption. - 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 nointerval; dedupe in the query instead. - If bulk imports create records, add a
sourceproperty 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, sendingsite_profile: counts of what exists plus a timeline fromcreationtimestamps. - 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/REGEXPon 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.routethe Pulse CDN url and serve a stub client that records captures onwindow, withboot_configstubbed toenabled: true. No test ever reaches Pulse.
Local verification
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 (itlindexes against a list itlpushes), a frappe bug. - Browser: watch the post to
https://pulse.m.frappe.cloudin 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.