Analytics Event Contract
Owner: Engineering · Schema version: 1 · Last updated: 2026-08-12
This document is the single source of truth for every analytics event PinePaper
sends. A release cannot add an event through the standard bridge
(window.editorAnalytics.track / window.trackEditorEvent) without it being
listed here — the Jest contract tests in __tests__/AnalyticsContract.test.js
enforce this.
Principles
- One user action → one GA4 hit. All dispatch goes through
window.editorAnalytics.track(); there are no directgtag('event', …)calls in editor code or pwa-analytics.js. - Consent first.
analytics.isEnabled()short-circuits every event when the user has Do Not Track, a persisted local opt-out, or?notrack=1. - No PII. Events never carry canvas/image content, free text, prompts, filenames, emails, raw error strings, or raw/high-cardinality IDs. The PII-key guard strips these in dev (loudly) and the tests enforce the same whitelist in CI.
- Low cardinality. Every custom-dimension value is a governed enum.
Numeric values are bucketed (e.g.
under_10s,10_30s), never raw.
Common context (auto-attached to every editor event)
| Key | Type | Values |
|---|---|---|
event_schema_version |
string | "1" |
surface |
enum | editor |
entry_point |
enum | direct, landing_cta, template, recent_project, shared_link |
platform_form_factor |
enum | desktop, tablet, mobile |
app_version |
string | PinePaper release version (build-injected) |
session_stage |
enum | new, returning (sessionStorage marker, not a persistent profile) |
Event taxonomy
Activation / entry
| Event | Trigger | Params |
|---|---|---|
editor_opened |
Editor fully usable, once per session | entry_point, surface, app_version |
start_path_selected |
User picks template / AI / import / blank | start_path (template·ai·import·blank), ui_location |
template_gallery_opened |
Gallery becomes visible | entry_point |
template_selected |
User explicitly chooses a template | template_category, template_tier, selection_source |
blank_canvas_started |
Blank project started | canvas_preset, selection_source |
import_started |
Image/file import begins | import_type, ui_location |
Milestones (one per session each)
| Event | Trigger | Params |
|---|---|---|
creation_milestone_reached |
First time a session reaches a level | milestone (first_content·first_customization·first_outcome), creation_path |
creation_abandoned |
User leaves before first_outcome (pagehide, once) |
last_milestone, active_duration_bucket, creation_path |
Outcomes
| Event | Trigger | Params |
|---|---|---|
project_saved |
Local save / autosave after meaningful work | save_mode (auto·manual), project_state (new·existing) |
export_completed |
Export succeeds | format, duration_bucket, size_bucket, creation_path |
export_failed |
Export fails | format, failure_stage, error_category |
share_started |
Share flow opens | share_destination |
share_completed |
Share succeeds | share_destination |
continue_project_opened |
Returning user resumes a project | project_age_bucket, entry_point |
Existing interaction events (kept, not milestones)
item_created, animation_applied, undo, redo, tool_selected,
item_deleted, import_dialog_opened, canvas_cleared, canvas_size_changed,
image_uploaded, image_crop_applied, image_mask_applied,
image_chroma_key_applied, lasso_*, gpu_filter_*, mobile_tab,
magic_*, code_*, cloud_*, chat_handoff_opened, clientllm_failure,
assistant_report_consent, and the PWA lifecycle events (pwa_install_eligible,
pwa_install_choice, pwa_installed, pwa_launched_standalone).
Interaction events (item_selected, hovers, pane switches, slider drags, undo,
canvas resize) are deliberately not milestones and never count toward
activation.
Migration note
template_loaded remains during migration and is then renamed/mapped to the
template_selected contract without double firing. During the overlap both fire
(one legacy, one new), then template_loaded is removed.
Bucketing + error categorization
Never send raw durations / sizes / errors. js/core/analyticsCore.js exports
pure helpers used by the editor bridge:
bucketDuration(s)→under_10s·10_30s·30_60s·over_60sbucketActiveDuration(s)→under_30s·30s_2m·2_10m·over_10mbucketSize(bytes)→under_1mb·1_5mb·5_20mb·over_20mbbucketProjectAge(days)→under_1d·1_7d·7_30d·over_30dcategorizeExportError(e)→unsupported_capability·encoding_rendering·memory_size·permission_download·unknowncreationPathFromStart(path)→template·ai·import·blank
Event dispatch points
The bridge listens for these CustomEvents (emitted by the editor subsystems):
| CustomEvent | Emitted by | GA4 events |
|---|---|---|
itemCreated |
PinePaper.create | item_created + first_content milestone |
templateLoaded |
TemplateManager | template_loaded + template_selected + first_content |
imageUploaded |
ImageToolsManager | image_uploaded + first_content |
blankCanvasStarted |
onboarding picker (UX-01) | blank_canvas_started + first_content |
exportStarted / exportCompleted / exportFailed |
ExportEngine | export lifecycle + first_outcome |
projectSaved |
HistoryManager._persistToStorage | project_saved + first_outcome |
shareStarted / shareCompleted |
ExportEngine share dialog | share lifecycle + first_outcome |
projectResumed |
session-recovery restore | continue_project_opened |
Milestones (first_content, first_customization, first_outcome) are
one-per-session via a sessionStorage flag; creation_abandoned fires on
pagehide only when the user started but never reached first_outcome.
Banned parameter keys (stripped by the PII guard)
error, message, prompt, content, text, description, detail,
filename, file_name, src, url, path, href, source, email,
user_id, userid, uid, id, *_id.
Prefer bucketed categories (error_category, duration_bucket, size_bucket)
over raw values.
Adding an event
- Add a row to this table with: owner, business question, trigger, parameters.
- Add a test case to
__tests__/AnalyticsContract.test.js. - Use
trackEvent(name, params)(orwindow.trackEditorEvent) — nevergtagdirectly.