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
  1. Engine surfaces dispatch native CustomEvents on window (or document). Each engine subsystem owns its emission and never reaches across to call analytics directly.
  2. The bridge in js/app.js translates raw events into normalized analytics actions (item_created, export_completed, …). This is where field renaming, dedup, and rollups happen.
  3. Adapters are functions registered with window.editorAnalytics.addAdapter(fn). Every tracked action calls every adapter with { action, category, timestamp, ...params }. The default gtag adapter 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 sizesize_bytes, durationMsduration_ms, videoDurationvideo_duration_s
exportFailed export_failed durationMsduration_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:

  1. The engine fires a CustomEvent at the meaningful moment:

    window.dispatchEvent(new CustomEvent('myFeatureRan', {
      detail: { variant: 'fast', items_affected: 7 }
    }));
    
  2. 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, not export-completed or export_completed).
  • trackEvent action names are snake_case (export_completed).
  • Field names in detail are camelCase; the bridge converts to snake_case when forwarding.
  • Wrap the dispatch in try/catch if it’s on a hot path that must not fail — see ExportEngine._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:

  1. Google Consent Mode v2 is initialized in editor.html before gtag.js loads, with analytics_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.
  2. The analytics.track() short-circuit in js/app.js re-checks isEnabled() 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 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_created carries an item type, not content. export_completed carries 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 gtag adapter no-ops if window.gtag isn’t present. PinePaper Studio’s hosted editor configures it; embedded uses don’t get analytics unless the embedder wires their own.
  • js/app.js — bridge + adapter registry (window.editorAnalytics)
  • js/ExportEngine.js_emitExportEvent helper + all export emission sites
  • js/ImageToolsManager.js — image lifecycle events (crop/mask/chroma key/upload)
  • js/SceneManager.js — scene lifecycle events (create/save/load/duplicate/reorder)
  • js/CapabilityDetector.jscapabilitiesDetected, qualityLevelChanged, performanceUpdate