Skip to content

feat(gnoweb): add SimpleAnalytics metadata and custom events#5554

Open
gfanton wants to merge 10 commits intognolang:masterfrom
gfanton:feat/gnoweb-sa-events-ux9
Open

feat(gnoweb): add SimpleAnalytics metadata and custom events#5554
gfanton wants to merge 10 commits intognolang:masterfrom
gfanton:feat/gnoweb-sa-events-ux9

Conversation

@gfanton
Copy link
Copy Markdown
Member

@gfanton gfanton commented Apr 20, 2026

Summary

Adds the full v1 SimpleAnalytics event surface to gnoweb. Pageviews carry stable metadata; custom events cover the DISCOVER, BUILD, package listing, read engagement, and outbound funnels. All event payloads are derived from server-side context or low-cardinality DOM signals — no raw user input is forwarded.

Refs #5467.

Pageview metadata

Set on every pageview via window.sa_metadata from the inline script in layouts/analytics.html, before SA's async latest.js loads.

Key Source Cardinality
page_type components.ClassifyPageType(mode, view) 11: home, user, pure, realm, source, help, directory, status,
redirect, explorer, other
chain_id cfg.ChainID one per deployment

View type takes precedence over mode: a Source view inside a Realm
mode is source, not realm.

Custom events

Event Trigger Payload
copy_action Click on button[data-controller~="copy"] {kind: link | source | func_signature | gnokey_command | unknown}
submit_action Action form submit {func, pkgpath} (capped 64 / 128)
search_action Header searchbar submit none
network_popup_toggle change on #searchbar-server-popup-toggle {open: bool}
breadcrumb_click Click in breadcrumb none
back_navigation popstate none
toc_toggle <details> toggle {open: bool}
mode_change mode:changed CustomEvent {mode}
send_mode_toggle Click on send-mode label {active: bool}
qeval_preview qeval result transitions success ⇄ failure {success: bool}
address_filled First non-empty value in #action-user-address none (once per page-load)
params_filled First non-empty value in any param input none (once per page-load)
theme_toggle theme:changed CustomEvent {theme: light | dark | system}
devmode_toggle change on #header-input-devmode {enabled: bool}
list_filter_search Debounced 250ms input on #packages-search none
list_sort_change change on input[name="order-mode"] {order}
list_display_change change on input[name="display-mode"] {mode}
scroll_depth Window scroll on source/help pages {threshold: 50 | 75 | 100, surface: source | action}
outbound_<target> Click on a[data-outbound] none

targetdocs, faucet, status, github, twitter, discord,
youtube. SA's built-in outbound / download / email events
continue to fire for everything else.

Architecture

  • DOM event delegation lives in frontend/js/analytics.ts (loaded as a module). Listeners use the capture phase where a controller calls stopPropagation on the bubble (copy, submit). One-shot listeners self-detach after firing.
  • controller-theme.ts and controller-action-header.ts dispatch typed CustomEvents on document; payload types are augmented via frontend/js/events.d.ts so analytics consumes them without runtime assertions.
  • The qeval result element exposes its placeholder string and error class as data-qeval-* attributes. The action controller writes these constants and the analytics observer reads them, sharing the contract via the DOM rather than duplicating string literals.
  • data-outbound is set in Go on the FooterLink / HeaderLink structs and rendered by the templates.

Hostname override

AppConfig.AnalyticsHostname (gnoweb) and --web-analytics-hostname (gnodev) render data-hostname on the SA script tag. SA's library defaults to location.host (which includes the port); the override lets a non-default-port deployment (typically local dev) report under
the bare hostname registered in the dashboard.

Documentation

SIMPLEANALYTICS.md documents the full taxonomy, privacy posture, cardinality caps, and the procedure for adding new events.


Generated by Claude.

@github-actions github-actions Bot added 📦 ⛰️ gno.land Issues or PRs gno.land package related 🌍 gnoweb Issues & PRs related to gnoweb and render labels Apr 20, 2026
@Gno2D2 Gno2D2 requested a review from alexiscolin April 20, 2026 12:23
@Gno2D2
Copy link
Copy Markdown
Collaborator

Gno2D2 commented Apr 20, 2026

🛠 PR Checks Summary

🔴 Changes related to gnoweb must be reviewed by its codeowners

Manual Checks (for Reviewers):
  • IGNORE the bot requirements for this PR (force green CI check)
Read More

🤖 This bot helps streamline PR reviews by verifying automated checks and providing guidance for contributors and reviewers.

✅ Automated Checks (for Contributors):

🟢 Maintainers must be able to edit this pull request (more info)
🔴 Changes related to gnoweb must be reviewed by its codeowners

☑️ Contributor Actions:
  1. Fix any issues flagged by automated checks.
  2. Follow the Contributor Checklist to ensure your PR is ready for review.
    • Add new tests, or document why they are unnecessary.
    • Provide clear examples/screenshots, if necessary.
    • Update documentation, if required.
    • Ensure no breaking changes, or include BREAKING CHANGE notes.
    • Link related issues/PRs, where applicable.
☑️ Reviewer Actions:
  1. Complete manual checks for the PR, including the guidelines and additional checks if applicable.
📚 Resources:
Debug
Automated Checks
Maintainers must be able to edit this pull request (more info)

If

🟢 Condition met
└── 🟢 And
    ├── 🟢 The base branch matches this pattern: ^master$
    └── 🟢 The pull request was created from a fork (head branch repo: gfanton/gno)

Then

🟢 Requirement satisfied
└── 🟢 Maintainer can modify this pull request

Changes related to gnoweb must be reviewed by its codeowners

If

🟢 Condition met
└── 🟢 And
    ├── 🟢 The base branch matches this pattern: ^master$
    └── 🟢 A changed file matches this pattern: ^gno.land/pkg/gnoweb/ (filename: gno.land/pkg/gnoweb/Makefile)

Then

🔴 Requirement not satisfied
└── 🔴 Or
    ├── 🔴 Or
    │   ├── 🔴 And
    │   │   ├── 🔴 Pull request author is user: alexiscolin
    │   │   └── 🔴 This user reviewed pull request: gfanton (with state "APPROVED")
    │   └── 🔴 And
    │       ├── 🟢 Pull request author is user: gfanton
    │       └── 🔴 This user reviewed pull request: alexiscolin (with state "APPROVED")
    └── 🔴 And
        ├── 🟢 Not (🔴 Pull request author is user: alexiscolin)
        ├── 🔴 Not (🟢 Pull request author is user: gfanton)
        └── 🔴 Or
            ├── 🔴 This user reviewed pull request: alexiscolin (with state "APPROVED")
            └── 🔴 This user reviewed pull request: gfanton (with state "APPROVED")

Manual Checks
**IGNORE** the bot requirements for this PR (force green CI check)

If

🟢 Condition met
└── 🟢 On every pull request

Can be checked by

  • Any user with comment edit permission

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 20, 2026

Codecov Report

❌ Patch coverage is 95.89041% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
gno.land/pkg/gnoweb/app.go 83.33% 2 Missing ⚠️
gno.land/pkg/gnoweb/components/analytics.go 95.83% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@gfanton gfanton marked this pull request as ready for review April 22, 2026 12:01
Copy link
Copy Markdown
Member

@alexiscolin alexiscolin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work on the base. A few broader thoughts, gathered by theme.

Missing builder signals we might want to check (never the input or output but the intention) → to discuss:

  • DISCOVER:
    • searchbar submit / click
    • network popup open click
    • breadcrumb navigation (clicks vs URL typed vs back button)
    • TOC open/close click
  • Settings:
    • theme toggle (retention signal) click
    • devmode toggle (explicit "I'm a builder" signal) click
  • BUILD on the action page — highest-signal events for builder intent:
    • safe/fast/url mode change click
    • send-mode toggle click
    • qeval preview success/failure click
    • address_filled and params_filled as booleans only (NEVER the values)
  • Package listing (user page):
    • sort order change click
    • display mode change click
    • list filter search (count only, NEVER the query) click
  • Read engagement:
    • scroll depth thresholds (50/75/100%) on source and action pages
    • plus the time-on-page already captured by SA

These are cheap to instrument and would give a lot more visibility on the journey.

SimpleAnalytics features we may have left on the table:

  • inline-events.js is not loaded. It's the privacy-first way to tag outbound links in HTML (<a data-event="outbound_docs_quickstart">) without writing JS delegation. Simpler than the TS click handler for the outbound use case, and it lets us split the outbound bucket into meaningful segments (docs vs Discord vs GitHub vs faucet vs playground) in the SA dashboard.
  • No priority tagging on outbound links yet. Right now everything goes to the generic "outbound" bucket via auto-events.js. Without named events, DISCOVER-stage attribution collapses.
  • Goals and funnels not configured in the SA dashboard. Custom events are raw data; SA will only chart funnels if someone defines them in the SA UI. Worth confirming who owns that step.
  • UTM parameter convention not defined. SA captures utm_source/medium/campaign automatically, but only if marketing puts them on Twitter/Discord/blog/email links. Without a documented convention (e.g. utm_source=discord&utm_campaign=grants_march), we lose most of the "where did this builder come from" signal.
  • SA has a data export API. If the DX dashboard from #5467 will consume SA later (alongside the indexer), it's worth shaping the taxonomy today with that consumer in mind: flat event names, stable dimension keys, bounded cardinality. 👍

Outbound-link strategy: a short list of "priority outbounds" worth named events would be something like: docs, GitHub repo, Discord, faucet, playground, Twitter. Everything else stays in the generic bucket from auto-events.js.

Taxonomy doc → to discuss: a short SIMPLEANALYTICS.md or ADR would help consolidate (a) what the events carry, (b) what they deliberately don't (IMPORTANT since gno.land is and will remain privacy-first, including at the SimpleAnalytics layer.: no raw query, no function args, no wallet, no IP beyond SA defaults, no cookies, no fingerprinting), (c) how to add a new event, (d) cardinality caps per dimension.

Comment on lines +28 to +30
if (btn.hasAttribute("data-copy-text-value")) kind = "link";
else if (btn.hasAttribute("data-copy-remote-value")) kind = "snippet";
fire("copy_action", { kind });
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small analytics nit: snippet groups source-code copy (reading intent) with gnokey command copy (about-to-execute intent), two very different stages of the builder funnel. The data-copy-remote-value prefix already carries the info:

Suggested change
if (btn.hasAttribute("data-copy-text-value")) kind = "link";
else if (btn.hasAttribute("data-copy-remote-value")) kind = "snippet";
fire("copy_action", { kind });
const remote = btn.getAttribute("data-copy-remote-value") ?? "";
let kind = "unknown";
if (btn.hasAttribute("data-copy-text-value")) {
kind = "link";
} else if (remote === "source-code") {
kind = "source";
} else if (remote.startsWith("func-")) {
kind = "func_signature";
} else if (remote.startsWith("action-function-")) {
kind = "gnokey_command";
}
fire("copy_action", { kind });

Comment on lines +44 to +45
const raw = article.getAttribute("data-action-function-name-value") ?? "";
fire("submit_action", { func: raw.slice(0, MAX_FUNC_NAME) });
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The event carries func but not the pkgpath, so we can tell which function was submitted but not which realm it's on. The <div> already exposes data-action-function-pkgpath-value, and pkgpaths are public so no privacy concern:

Suggested change
const raw = article.getAttribute("data-action-function-name-value") ?? "";
fire("submit_action", { func: raw.slice(0, MAX_FUNC_NAME) });
const name = article.getAttribute("data-action-function-name-value") ?? "";
const pkg = article.getAttribute("data-action-function-pkgpath-value") ?? "";
const func = name.slice(0, MAX_FUNC_NAME);
const pkgpath = pkg.slice(0, 128);
fire("submit_action", { func, pkgpath });

Comment on lines +3 to +8
<script>
window.sa_metadata = {
page_type: {{ .PageType }},
chain_id: {{ .ChainId }}
};
</script>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A follow-up to add CSP (in the dedicated place - not sure it was done) with nonces would be worth opening?

return "home"
case ViewModeUser:
return "user"
case ViewModePackage:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Package or Pure?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right, pure is better

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually i need to change ViewModePure as well

gfanton added 2 commits April 23, 2026 13:01
Adds the full v1 SimpleAnalytics taxonomy: copy/submit refinements,
DISCOVER (search, breadcrumb, network popup, back, toc), BUILD (mode,
send, qeval, address/params), Settings (theme, devmode), package
listing (filter, sort, display), scroll depth, and named outbound
events for priority destinations.

- gnodev: --web-analytics and --web-analytics-hostname flags
- gnoweb: AnalyticsHostname plumbed through StaticMetadata for
  data-hostname override (required when SA cannot identify the host,
  e.g. local dev on a non-default port)
- analytics.ts: 14 listeners covering 18 event names; onClick helper;
  one-shot listeners self-detach
- theme/mode: typed CustomEvent dispatch via DocumentEventMap
- qeval: placeholder text and error class moved to data-* attributes
  so the contract is shared between controller and analytics
- footer/header: data-outbound on tagged anchors (docs, github,
  discord, faucet, status, twitter, youtube)
- new SIMPLEANALYTICS.md: privacy posture, taxonomy, cardinality caps,
  procedure for adding events
@gfanton
Copy link
Copy Markdown
Member Author

gfanton commented Apr 23, 2026

Thanks for the thorough review. I've addressed all of them, and also added --web-analytics and --web-analytics-hostname flags to gnodev to verify the dashboard is correctly receiving the events end-to-end.

Existing-event refinements

  • copy_action.kinddone, split link | source | func_signature | gnokey_command | unknown (no more bundling reading-intent with about-to-execute intent)
  • submit_actiondone, now carries both func and pkgpath (capped at 64 / 128 chars)
  • ViewModePackage page_type → done, renamed to pure

Events

  • DISCOVER:

    • searchbar submit / click → done, search_action on form.searchbar submit (count only, never the query)
    • network popup open click → done, network_popup_toggle {open: bool} on #searchbar-server-popup-toggle change
    • breadcrumb navigation (clicks vs URL typed vs back button) → partial. Clicks: breadcrumb_click. Back/forward: back_navigation (popstate). URL-typed: not emitted (no DOM signal); inferable in dashboard as a pageview with no preceding internal-nav event in the session.
    • TOC open/close click → done, toc_toggle {open: bool} on details.accordion toggle event
  • Settings:

    • theme toggle (retention signal) → done, theme_toggle {theme: light|dark|system} via theme:changed CustomEvent dispatched by ThemeController.toggle
    • devmode toggle (explicit "I'm a builder" signal) → done, devmode_toggle {enabled: bool} on #header-input-devmode change
  • BUILD (action page):

    • safe/fast/url mode change click → done, mode_change {mode} via mode:changed CustomEvent (secure | fast | url)
    • send-mode toggle click → done, send_mode_toggle {active: bool} on the send-mode label click
    • qeval preview success/failure → done, qeval_preview {success: bool} via MutationObserver on success⇄failure transitions (placeholder skipped). The placeholder text and error class live as data-qeval-* attributes on the result element so the controller and analytics share the same source of truth.
    • address_filled / params_filled as booleans only (NEVER the values) → done, address_filled and params_filled events with no payload, fire once per page-load on first non-empty input
  • Package listing (user page):

    • sort order change click → done, list_sort_change {order} on input[name="order-mode"] change
    • display mode change click → done, list_display_change {mode} on input[name="display-mode"] change
    • list filter search (count only, NEVER the query) → done, list_filter_search (no payload) on #packages-search input, debounced 250ms
  • Read engagement:

    • scroll depth thresholds (50/75/100%) on source and action pages → done, scroll_depth {threshold: 50|75|100, surface: source|action}, fires once per threshold per page-load
    • time-on-page → already covered by SA's defaults, no code needed
  • Outbound attribution:

    • named outbound events for priority destinations → done, outbound_<target> (docs, faucet, status, github, twitter, discord, youtube) via data-outbound on tagged anchors. SA's built-in outbound / download / email continue to fire as a catch-all.

About other comments

Goals and funnels not configured in the SA dashboard. Custom events are raw data; SA will only chart funnels if someone defines them in the SA UI. Worth confirming who owns that step.

Yes, this needs to be configured on the dashboard side — probably once we have enough events flowing to define the funnels meaningfully.

Taxonomy doc → to discuss: a short SIMPLEANALYTICS.md or ADR would help consolidate (a) what the events carry, (b) what they deliberately don't (IMPORTANT since gno.land is and will remain privacy-first, including at the SimpleAnalytics layer.: no raw query, no function args, no wallet, no IP beyond SA defaults, no cookies, no fingerprinting), (c) how to add a new event, (d) cardinality caps per dimension.

Good idea went with SIMPLEANALYTICS.md (in gno.land/pkg/gnoweb/). It documents the privacy posture explicitly, the full event taxonomy, the cardinality caps per dimension, and the procedure for adding a new event. Also serves as the public surface so people can see exactly what we collect. We just need to keep it up to date as new events land.

A follow-up to add CSP (in the dedicated place - not sure it was done) with nonces would be worth opening?

Hmm probably in a followup, this need to be configured infra side anyway

inline-events.js is not loaded. It's the privacy-first way to tag outbound links in HTML (<a data-event="outbound_docs_quickstart">)

I went with data-outbound + the click delegate in analytics.ts instead. Reason: keeping all event-firing logic in one place (the TS file) makes the taxonomy easier to audit and to keep in sync with SIMPLEANALYTICS.md. inline-events.js would split the contract across HTML attributes and JS. We get the same per-target segmentation either way. Happy to switch if you prefer have it inline.

UTM parameter convention not defined.

Out of scope for this PR I think

SA has a data export API. If the DX dashboard from #5467 will consume SA later (alongside the indexer), it's worth shaping the taxonomy today with that consumer in mind: flat event names, stable dimension keys, bounded cardinality.

That's exactly the shape SIMPLEANALYTICS.md is written in: flat event names (no namespacing colons), stable dimension keys, hard cardinality caps per dimension (table at the bottom of the doc). Should be straightforward for an indexer-side consumer to ingest alongside the chain data.

Outbound-link strategy: docs, GitHub repo, Discord, faucet, playground, Twitter

Tagged 7 targets in this PR: docs, github, discord, faucet, twitter, status, youtube (No playground tag yet)

gfanton added 4 commits April 23, 2026 16:21
Replace the inline window.sa_metadata script with a synchronous
classic sa-bootstrap.js that reads its own data-* attributes and
assigns the metadata. A classic <script src=...> blocks HTML parsing
until it finishes, so window.sa_metadata is always set before SA's
async latest.js can start loading — deterministic ordering, no race.

Works under a strict CSP (script-src 'self' https://sa.gno.services)
without needing 'unsafe-inline' or per-request nonces.
- Remove dead HeadData.Analytics bool (never read)
- Drop redundant handler write of FooterData.Analytics.Hostname; single
  source of truth is IndexLayout deriving analytics fields from HeadData
- Centralize redirect-view AnalyticsData construction in a new
  StaticMetadata.RedirectAnalytics() helper (was duplicated between
  faucet handler and redirect middleware)
- Replace `as` type casts with instanceof narrowing in analytics.ts and
  sa-bootstrap.ts for runtime-safe DOM access
- Trim redundant WHAT comments in controller-form-exec.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🛠️ gnodev 🌍 gnoweb Issues & PRs related to gnoweb and render 📦 ⛰️ gno.land Issues or PRs gno.land package related

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

3 participants