Telemetry & analytics
PinePaper has a built-in, provider-agnostic telemetry pipe. Editor events flow through a single bridge (js/app.js) into pluggable adapters, with Google Analytics (gtag) wired by default. Drop in Mixpanel, Amplitude, PostHog, or a custom logger by registering one function.
This document is for:
- Embedders who want PinePaper events to land in their analytics stack
- Contributors who want to add a new tracked action
- Anyone trying to figure out why a listener seems to fire but no event reaches their dashboard
Three-layer architecture
Engine surfaces Bridge Adapters
(PinePaper, ExportEngine, …) (js/app.js) (your choice)
───────────────────────────────── ───────────────────────── ───────────────
window.dispatchEvent(new CustomEvent → window.addEventListener('…') → gtag('event', …)
('exportStarted', { detail })) → trackEvent(action, params) Mixpanel.track(…)
posthog.capture(…)
↑ pluggable
- Engine surfaces dispatch native
CustomEvents onwindow(ordocument). Each engine subsystem owns its emission and never reaches across to call analytics directly. - The bridge in
js/app.jstranslates raw events into normalized analytics actions (item_created,export_completed, …). This is where field renaming, dedup, and rollups happen. - Adapters are functions registered with
window.editorAnalytics.addAdapter(fn). Every tracked action calls every adapter with{ action, category, timestamp, ...params }. The defaultgtagadapter ships built-in.
The split is deliberate: subsystems stay analytics-agnostic (easier to test, no globals coupling), and the bridge can evolve event semantics without touching engine code.
Registering an adapter
// After the editor has loaded (window.editorAnalytics is set at app boot)
window.editorAnalytics.addAdapter((event) => {
// event === { action, category: 'editor', timestamp, ...params }
mixpanel.track(event.action, event);
});
Adapter functions must not throw — the pipe wraps each call in try/catch to keep one buggy adapter from breaking the rest. Throwing still costs a thrown-and-swallowed error in dev logs.
You can register multiple adapters. They fire in registration order. Returning a value is ignored.
Export lifecycle events
Exports are the highest-signal events. Every export emits exactly one exportStarted and exactly one terminal event (exportCompleted or exportFailed). The bridge dedupes a 500 ms race window for the rare case where the video pipeline’s onError handler and the surrounding catch both fire (see ExportEngine.startVideoExport).
| Event | Fired by | detail payload |
|---|---|---|
exportStarted |
ExportEngine.exportWithFormat / startVideoExport |
{ format, duration?, fps?, quality? } — video adds the resolved settings |
exportCompleted |
ExportEngine._downloadSVG, _downloadPNG, startVideoExport (success) |
{ format, variant?, size, durationMs, width?, height?, fps?, videoDuration?, dpi? } |
exportFailed |
videoExporter.onError, startVideoExport catch, PNG promise reject |
{ format, error, durationMs } |
Bridge translation (in js/app.js):
| CustomEvent | trackEvent action | Notable param renames |
|---|---|---|
exportStarted |
export_started |
format only (start payload is intentionally lean) |
exportCompleted |
export_completed |
size → size_bytes, durationMs → duration_ms, videoDuration → video_duration_s |
exportFailed |
export_failed |
durationMs → duration_ms |
Listening directly (without the bridge)
If you want raw export telemetry without going through window.editorAnalytics, listen to the CustomEvent directly:
window.addEventListener('exportCompleted', (e) => {
const { format, size, durationMs } = e.detail;
console.log(`[my-app] ${format} export of ${size} bytes in ${durationMs}ms`);
});
This is what the bridge itself does. Multiple listeners coexist fine.
What else the bridge tracks
Already wired in js/app.js (trackEvent calls):
| Action | Source |
|---|---|
item_created |
itemCreated event |
item_deleted |
app.deleteSelected wrap |
item_selected |
selectionChanged event (count > 0) |
animation_applied |
app.modify wrap |
template_loaded |
templateLoaded event |
mobile_tab |
window.setMobileTab wrap |
tool_selected |
toolChanged event |
undo / redo |
HistoryManager.undo / .redo wraps |
magic_auto_animate |
MagicSystem.autoAnimate wrap |
magic_remix_style |
MagicSystem.remixStyle wrap |
magic_suggest |
MagicSystem.suggestAnimations wrap |
canvas_cleared |
canvasCleared event |
canvas_size_changed |
canvasSizeChanged event |
image_uploaded |
imageUploaded event |
image_crop_applied |
cropApplied event |
image_mask_applied |
maskApplied event |
image_chroma_key_applied |
chromaKeyApplied event |
lasso_activated / _applied / _cancelled |
lasso* events |
gpu_filter_preset |
[data-gpu-preset] button clicks |
gpu_filter_slider |
hue/sat/light/brightness/contrast sliders |
cutout_style_applied |
inline in cutout-style click handler |
shader_effect_applied |
inline in effect handler |
shape_created |
shapeCreated event |
import_dialog_opened |
app.showImportDialog wrap |
All ~95 raw events the codebase dispatches are available to listen for directly. The bridge only forwards a curated subset — anything else, register your own listener.
Adding a new tracked action
Most new tracking just needs:
-
The engine fires a CustomEvent at the meaningful moment:
window.dispatchEvent(new CustomEvent('myFeatureRan', { detail: { variant: 'fast', items_affected: 7 } })); -
The bridge listens (add near the export listeners around
js/app.js:13700):window.addEventListener('myFeatureRan', (e) => { trackEvent('my_feature_ran', { variant: e.detail?.variant, items_affected: e.detail?.items_affected }); });
Conventions:
- Event names are camelCase (
exportCompleted, notexport-completedorexport_completed). trackEventaction names are snake_case (export_completed).- Field names in
detailare camelCase; the bridge converts to snake_case when forwarding. - Wrap the dispatch in
try/catchif it’s on a hot path that must not fail — seeExportEngine._emitExportEvent.
Namespacing: pinepaper: prefix
Four events use the pinepaper: prefix (pinepaper:itemCreated, pinepaper:itemEffect, pinepaper:callbackDisabled, pinepaper:templateOnLoadComplete). The prefix is reserved for events that fire inside an embedder’s page where unprefixed names might collide with their own analytics. The unprefixed forms (itemCreated etc.) are the older convention used by 90+% of the codebase.
New events should prefer the unprefixed form unless the event is meant to escape the editor’s scope.
Opting out of telemetry
The hosted editor at pinepaper.studio loads Google Analytics 4 by default. Users have three ways to disable it; each is honored both for the current session and persisted across sessions where applicable.
Three opt-out signals (any one wins)
| Signal | Persistence | Set by |
|---|---|---|
navigator.doNotTrack === '1' |
Browser-level, per-session | Browser preference (Firefox DNT, etc.) |
localStorage.pp_telemetry_optout = '1' |
Per-browser, persistent | Footer “Don’t track me” toggle, window.editorAnalytics.disable() |
?notrack=1 query param |
Per-page-load only | URL — useful for shared previews |
How it actually blocks tracking
Two layers, defense-in-depth:
- Google Consent Mode v2 is initialized in
editor.htmlbeforegtag.jsloads, withanalytics_storage: 'denied'. A synchronous check then grants consent only if none of the three opt-out signals are set. With consent denied, GA4 doesn’t set cookies, doesn’t send the page_view, and queues subsequent events without dispatching them. - The
analytics.track()short-circuit injs/app.jsre-checksisEnabled()on every call. Even if a future adapter ignored Consent Mode, no event would ever reach it when the user has opted out.
Toggling at runtime (footer button or editorAnalytics.disable()) calls gtag('consent', 'update', { analytics_storage: 'denied' }) so the change takes effect immediately — you don’t have to reload.
The “Don’t track me” footer button
The editor footer has a <button id="telemetryOptOutBtn"> that toggles state, persists the choice, and shows a toast. If DNT is on at the browser level, the button is disabled and labeled “Tracking disabled by your browser” — per the DNT spec, site UI must not encourage re-enabling against the browser’s signal.
The button is hidden on mobile (along with the rest of the footer) — programmatic opt-out via window.editorAnalytics.disable() is the mobile path; a dedicated mobile UI is on the backlog.
Programmatic opt-out
// Disable telemetry permanently on this browser
window.editorAnalytics.disable();
// Check current state
window.editorAnalytics.isEnabled(); // false
// Re-enable (subject to DNT)
window.editorAnalytics.enable();
The telemetryOptOutChanged CustomEvent fires on window whenever either method is called — useful if you have additional adapters to gate.
For embedders
If you embed PinePaper and don’t want any telemetry at all:
window.editorAnalytics._adapters = [];
The default gtag adapter no-ops anyway when window.gtag isn’t present, so a typical embed (without GA installed on the host page) is already tracker-free.
Why we track
To understand which features are used, spot bugs that hit real users, and prioritize product work. Not to sell data, profile users, or feed ads. The hosted editor is free; analytics fund product development. Design data — items, text, images, exports — is processed entirely client-side and is never transmitted to our servers or to Google. The telemetry events themselves (page views, export_started, etc.) do flow to Google with a pseudonymous _ga client ID — that ID is itself personal data under GDPR Article 4, which is why we use Consent Mode rather than relying on the design-data-is-local argument alone.
GDPR / CCPA / regional laws
PinePaper Studio relies on Google Consent Mode v2 defaults (analytics denied until granted). The opt-out is explicit and recorded — the localStorage key pp_telemetry_optout is the persistent record. GA4 also receives anonymize_ip: true on the config call.
The combination (default-denied + explicit toggle + DNT respect + anonymize_ip) is Google’s recommended pattern for EU/UK GDPR, the California CCPA/CPRA “Do Not Sell or Share” signal, and the UK PECR cookie rules. We do not bundle a separate CMP — the opt-out controls are the consent mechanism. Embedders under stricter obligations can call editorAnalytics.disable() from their CMP’s “Reject all” callback.
What the footer button does NOT do:
- It does not delete already-collected GA data (use GA’s user-deletion API for that, scoped to GA Client ID).
- It does not clear the GA4 cookie if it was set before the user toggled (subsequent visits won’t write new ones, but the prior cookie remains until expiry or browser clears it).
- It is not a substitute for a Consent Management Platform (CMP) for sites with stricter EU obligations — wire your own CMP and call
editorAnalytics.disable()from its “Reject” callback.
Debugging
In dev, set window.__pinepaper_debug = true and tail the console. The engine guards its noisier debug logs on that flag; telemetry dispatches themselves are silent, but the listener side-effects (trackEvent('…')) propagate to whatever adapter you registered, so logging your adapter is the easiest way to see the pipe:
window.editorAnalytics.addAdapter((e) => console.log('[telemetry]', e.action, e));
Privacy notes
- The engine never auto-attaches PII.
item_createdcarries an item type, not content.export_completedcarries size and dimensions, not the export blob. - If you instrument content (e.g. logging template names, scene text), that’s on the adapter side. Embedders are responsible for compliance.
- The default
gtagadapter no-ops ifwindow.gtagisn’t present. PinePaper Studio’s hosted editor configures it; embedded uses don’t get analytics unless the embedder wires their own.
Related files
js/app.js— bridge + adapter registry (window.editorAnalytics)js/ExportEngine.js—_emitExportEventhelper + all export emission sitesjs/ImageToolsManager.js— image lifecycle events (crop/mask/chroma key/upload)js/SceneManager.js— scene lifecycle events (create/save/load/duplicate/reorder)js/CapabilityDetector.js—capabilitiesDetected,qualityLevelChanged,performanceUpdate