Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions contribs/gnodev/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ FLAGS
-txs-file ... load the provided transactions file (refer to the documentation for format)
-unsafe-api=true enable /reset and /reload endpoints which are not safe to expose publicly
-v=false enable verbose output for development
-web-analytics=false gnoweb: enable SimpleAnalytics tracking
-web-analytics-hostname ... gnoweb: override the SimpleAnalytics reported hostname (rendered as data-hostname on the SA script tag)
-web-help-remote ... gnoweb: web server help page's remote addr (default to <node-rpc-listener>)
-web-home ... gnoweb: set default home page, use `/` or `:none:` to use default web home redirect
-web-html=false gnoweb: enable unsafe HTML parsing in markdown rendering
Expand Down Expand Up @@ -172,6 +174,8 @@ FLAGS
-txs-file ... load the provided transactions file (refer to the documentation for format)
-unsafe-api=false enable /reset and /reload endpoints which are not safe to expose publicly
-v=false enable verbose output for development
-web-analytics=false gnoweb: enable SimpleAnalytics tracking
-web-analytics-hostname ... gnoweb: override the SimpleAnalytics reported hostname (rendered as data-hostname on the SA script tag)
-web-help-remote ... gnoweb: web server help page's remote addr (default to <node-rpc-listener>)
-web-home :none: gnoweb: set default home page, use `/` or `:none:` to use default web home redirect
-web-html=false gnoweb: enable unsafe HTML parsing in markdown rendering
Expand Down
28 changes: 22 additions & 6 deletions contribs/gnodev/app_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@ type AppConfig struct {
txsFile string

// Web Configuration
noWeb bool
webHTML bool
webListenerAddr string
webRemoteHelperAddr string
webWithHTML bool
webHome string
noWeb bool
webHTML bool
webListenerAddr string
webRemoteHelperAddr string
webWithHTML bool
webHome string
webAnalytics bool
webAnalyticsHostname string

// Resolver
resolvers varResolver
Expand Down Expand Up @@ -112,6 +114,20 @@ func (c *AppConfig) RegisterFlagsWith(fs *flag.FlagSet, defaultCfg AppConfig) {
"gnoweb: set default home page, use `/` or `:none:` to use default web home redirect",
)

fs.BoolVar(
&c.webAnalytics,
"web-analytics",
defaultCfg.webAnalytics,
"gnoweb: enable SimpleAnalytics tracking",
)

fs.StringVar(
&c.webAnalyticsHostname,
"web-analytics-hostname",
defaultCfg.webAnalyticsHostname,
"gnoweb: override the SimpleAnalytics reported hostname (rendered as data-hostname on the SA script tag)",
)

fs.Var(
&c.resolvers,
"resolver",
Expand Down
6 changes: 5 additions & 1 deletion contribs/gnodev/setup_web.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,17 @@ import (
"github.com/gnolang/gno/gno.land/pkg/gnoweb"
)

// setupGnowebServer initializes and starts the Gnoweb server.
// setupGnoWebServer initializes the gnoweb HTTP handler from the gnodev
// AppConfig, returning a 404 handler when gnoweb is disabled.
func setupGnoWebServer(logger *slog.Logger, cfg *AppConfig, remoteAddr string) (http.Handler, error) {
if cfg.noWeb {
return http.HandlerFunc(http.NotFound), nil
}

appcfg := gnoweb.NewDefaultAppConfig()
appcfg.UnsafeHTML = cfg.webHTML
appcfg.Analytics = cfg.webAnalytics
appcfg.AnalyticsHostname = cfg.webAnalyticsHostname
appcfg.NodeRemote = remoteAddr
appcfg.ChainID = cfg.chainId
if cfg.webRemoteHelperAddr != "" {
Expand All @@ -33,6 +36,7 @@ func setupGnoWebServer(logger *slog.Logger, cfg *AppConfig, remoteAddr string) (
"remote", appcfg.NodeRemote,
"helper_remote", appcfg.RemoteHelp,
"html", appcfg.UnsafeHTML,
"analytics", appcfg.Analytics,
"chain_id", cfg.chainId,
)
return router, nil
Expand Down
8 changes: 7 additions & 1 deletion gno.land/pkg/gnoweb/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ output_static := $(patsubst $(src_dir_static)/%, $(out_dir_static)/%, $(input_st
# esbuild config
src_dir_js := frontend/js
out_dir_js := $(PUBLIC_DIR)/js
input_js := $(shell find $(src_dir_js) -name '*.ts')
input_js := $(shell find $(src_dir_js) -name '*.ts' -not -name '*.d.ts')
# Separate shared and controller files
shared_js := $(src_dir_js)/controller.ts
controller_js := $(filter-out $(shared_js),$(input_js))
Expand Down Expand Up @@ -87,6 +87,12 @@ ts: $(esbuild) $(output_js)
$(out_dir_js)/controller.js: $(shared_js) $(esbuild)
NODE_ENV=production $(esbuild) $< --log-level=error --bundle --outfile=$@ --format=esm --minify

# sa-bootstrap is loaded as a synchronous classic <script src=...> so it blocks
# parsing until executed and guarantees window.sa_metadata is set before SA's
# async latest.js loads. It must be an IIFE, not an ES module.
$(out_dir_js)/sa-bootstrap.js: $(src_dir_js)/sa-bootstrap.ts $(esbuild)
NODE_ENV=production $(esbuild) $< --log-level=error --outfile=$@ --format=iife --minify

# Build controller files with shared chunk reference
$(out_dir_js)/%.js: $(src_dir_js)/%.ts $(out_dir_js)/controller.js
NODE_ENV=production $(esbuild) $< --log-level=error --bundle --outdir=$(out_dir_js) --format=esm --define:process.env.NODE_ENV="\"production\"" --minify --external:./controller.js
Expand Down
131 changes: 131 additions & 0 deletions gno.land/pkg/gnoweb/SIMPLEANALYTICS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# SimpleAnalytics taxonomy — gnoweb

This file documents every event gnoweb emits to SimpleAnalytics, what each event carries, what it deliberately does not carry, and how to add a new one. The taxonomy is designed for a future internal indexer / DX dashboard (see #5467) to consume alongside the SA dashboard: flat event names, stable dimension keys, bounded cardinality.

## Privacy posture

gno.land is privacy-first, including at the analytics layer.

We never collect:

- Wallet addresses, public keys, or transaction signatures
- Function arguments or return values
- Form input values (address inputs, parameter inputs, search queries)
- Cookies, persistent visitor IDs, fingerprinting data
- IP addresses beyond what SA's defaults handle (hashed, daily-rotated, never stored raw)
- Anything that distinguishes a person across sessions

All metadata is derived from public, server-side context (URL pattern, layout mode, view type, chain id) or from low-cardinality DOM signals (mode enum, `open` boolean, scroll threshold).

## Pageview metadata

Set on every pageview via `window.sa_metadata`. A synchronous classic
`<script src="sa-bootstrap.js" data-page-type="…" data-chain-id="…">` loads
before SA's async `latest.js`, reads its own `data-*` attributes, and
assigns `window.sa_metadata`. This keeps the bootstrap CSP-safe (no inline
script) and deterministic (classic scripts block parsing, so SA's async
scripts cannot start loading until the metadata is set).

| 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` (env constant) | low (one per deployment) |

## Custom events

### DISCOVER

| Event | Trigger | Payload |
|---|---|---|
| `search_action` | Header searchbar form submit | none (count only — never the query) |
| `network_popup_toggle` | `change` on the network-info popup checkbox | `{open: bool}` |
| `breadcrumb_click` | Click on any breadcrumb anchor | none |
| `back_navigation` | Browser back / forward (`popstate`) | none |
| `toc_toggle` | Native `<details>` toggle on `details.accordion` | `{open: bool}` |

### BUILD (action page)

| Event | Trigger | Payload |
|---|---|---|
| `mode_change` | Action header dispatches `mode:changed` | `{mode: 'secure' \| 'fast' \| 'url'}` |
| `send_mode_toggle` | Click on the send-mode label (Add/Remove from command) | `{active: bool}` |
| `qeval_preview` | qeval result element transitions between success and failure (placeholder text and error class read from `data-qeval-*` attrs on the element) | `{success: bool}` |
| `address_filled` | First non-empty value typed in `#action-user-address` | none (fires once per page-load) |
| `params_filled` | First non-empty value typed in any param input | none (fires once per page-load) |
| `submit_action` | Action form submission | `{func, pkgpath}` (capped at 64 / 128 chars) |

### Settings

| Event | Trigger | Payload |
|---|---|---|
| `theme_toggle` | Theme controller dispatches `theme:changed` with the user-chosen preference | `{theme: 'light' \| 'dark' \| 'system'}` |
| `devmode_toggle` | `change` on `#header-input-devmode` (3-dot menu on home) | `{enabled: bool}` |

### Package listing (user page)

| Event | Trigger | Payload |
|---|---|---|
| `list_filter_search` | Debounced (250ms) `input` on `#packages-search` | none (count only — never the query) |
| `list_sort_change` | `change` on `input[name="order-mode"]` | `{order: 'asc' \| 'desc'}` |
| `list_display_change` | `change` on `input[name="display-mode"]` | `{mode: 'display-grid' \| 'display-list'}` |

### Read engagement

| Event | Trigger | Payload |
|---|---|---|
| `scroll_depth` | Window scroll on source/help pages, fires once per threshold per page-load | `{threshold: '50' \| '75' \| '100', surface: 'source' \| 'action'}` |

### Copy actions

| Event | Trigger | Payload |
|---|---|---|
| `copy_action` | Click on any `button[data-controller~="copy"]`; kind inferred from `data-copy-*` attributes | `{kind: 'link' \| 'source' \| 'func_signature' \| 'gnokey_command' \| 'unknown'}` |

### Outbound

Two layers fire in parallel.

**Generic catch-all (SA `auto-events.js`, no code):** every outbound link, download, and `mailto:` click fires SA's built-in `outbound`, `download`, or `email` event with the host or filename as payload. Covers all outbound clicks regardless of tagging.

**Named priority outbounds (`outbound_<target>`):** anchors carrying `data-outbound="<target>"` fire an additional `outbound_<target>` event so high-traffic destinations show up under stable names in the dashboard rather than being aggregated by host.

| target | URL pattern |
|---|---|
| `docs` | docs.gno.land |
| `faucet` | faucet.gno.land |
| `status` | status.gnoteam.com |
| `github` | github.com/gnolang/* |
| `twitter` | twitter.com/_gnoland |
| `discord` | discord.gg/* |
| `youtube` | youtube.com/@_gnoland |

`data-outbound` is set in Go (`layout_footer.go`, `layout_header.go`) and rendered by `footer.html` / `ui/header_link`. To tag a new outbound: add `Outbound: "<target>"` to the `FooterLink` / `HeaderLink` in Go.

## Cardinality caps

| Dimension | Cap | Why |
|---|---|---|
| `func` (submit_action) | 64 chars | realm authors set this attribute freely |
| `pkgpath` (submit_action) | 128 chars | same |
| All enum payloads (`page_type`, `mode`, `kind`, `theme`, `surface`, `threshold`, `order`, `display`) | fixed enum set | enumerated above |
| Outbound target | fixed enum set | only `data-outbound` values defined in Go are emitted |

## Adding a new event

1. **Confirm it's not derivable.** If a dashboard query against existing pageviews + events would answer the question, do not add an event.
2. **Pick a stable, flat name.** snake_case, no namespacing colons. Match an existing prefix when applicable (`copy_*`, `list_*`, `outbound_*`, `*_toggle`, `*_change`).
3. **Define the payload schema.** Low cardinality only. Booleans or fixed enums preferred. No raw user input. Document the cap if a string field could grow.
4. **Wire the listener** in `frontend/js/analytics.ts`. Use capture phase if any controller stops propagation. Lazy-attach with `if (element)` for elements that may not exist on every page.
5. **Rebuild** `make -C gno.land/pkg/gnoweb ts` and commit both `frontend/js/analytics.ts` and the generated `public/js/analytics.js`.
6. **Document the event in this file** under the appropriate section.
7. **If the event needs a server-side data attribute** (e.g. `data-outbound`), extend the relevant struct and template in `components/`.

## Files

- `frontend/js/analytics.ts` — event delegation source of truth
- `public/js/analytics.js` — esbuild output, embedded via `go:embed`
- `components/analytics.go` — `AnalyticsData`, `ClassifyPageType`
- `components/layouts/analytics.html` — script wiring (sa-bootstrap + SA scripts + analytics.js)
- `frontend/js/sa-bootstrap.ts` — classic IIFE that reads `data-*` attrs and sets `window.sa_metadata`
- `components/layout_footer.go` + `layouts/footer.html` — footer outbound tagging
- `components/layout_header.go` + `layouts/header.html` — header outbound tagging
28 changes: 17 additions & 11 deletions gno.land/pkg/gnoweb/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ type AppConfig struct {
UnsafeHTML bool
// Analytics enables SimpleAnalytics.
Analytics bool
// AnalyticsHostname, when non-empty, is rendered as data-hostname on the
// SimpleAnalytics script tag to override the hostname SA reports.
// Set this when the site listens on a host SA would otherwise report
// incorrectly (for example a non-default port in local development).
AnalyticsHostname string
// NodeRemote is the remote address of the gno.land node.
NodeRemote string
// NodeRequestTimeout define how much time a request to the remote node should live before timeout.
Expand Down Expand Up @@ -107,14 +112,15 @@ func NewRouter(logger *slog.Logger, cfg *AppConfig) (http.Handler, error) {
buildTime := time.Now().Format("20060102150405") // YYYYMMDDHHMMSS

staticMeta := StaticMetadata{
Domain: cfg.Domain,
AssetsPath: assetsBase,
ChromaPath: chromaStylePath,
RemoteHelp: cfg.RemoteHelp,
ChainId: cfg.ChainID,
Analytics: cfg.Analytics,
BuildTime: buildTime,
Banner: cfg.Banner,
Domain: cfg.Domain,
AssetsPath: assetsBase,
ChromaPath: chromaStylePath,
RemoteHelp: cfg.RemoteHelp,
ChainId: cfg.ChainID,
Analytics: cfg.Analytics,
AnalyticsHostname: cfg.AnalyticsHostname,
BuildTime: buildTime,
Banner: cfg.Banner,
}

// Configure Markdown renderer
Expand Down Expand Up @@ -144,15 +150,15 @@ func NewRouter(logger *slog.Logger, cfg *AppConfig) (http.Handler, error) {
mux := http.NewServeMux()

// Handle web handler with redirect middleware
mux.Handle("/", RedirectMiddleware(httphandler, cfg.Analytics))
mux.Handle("/", RedirectMiddleware(httphandler, staticMeta))

// Register faucet URL to `/faucet` if specified
if cfg.FaucetURL != "" {
mux.Handle("/faucet", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, cfg.FaucetURL, http.StatusFound)
components.RedirectView(components.RedirectData{
To: cfg.FaucetURL,
WithAnalytics: cfg.Analytics,
To: cfg.FaucetURL,
Analytics: staticMeta.RedirectAnalytics(),
}).Render(w)
}))
}
Expand Down
39 changes: 38 additions & 1 deletion gno.land/pkg/gnoweb/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,12 @@ func TestAnalytics(t *testing.T) {

router.ServeHTTP(response, request)

assert.Contains(t, response.Body.String(), "sa.gno.services")
body := response.Body.String()
assert.Contains(t, body, "sa.gno.services")
assert.Contains(t, body, "js/analytics.js")
assert.Contains(t, body, "js/sa-bootstrap.js")
assert.Contains(t, body, "auto-events.js")
assert.Regexp(t, `data-page-type="[a-z]+"`, body, "page_type must populate with a non-empty enum value")
})
}
})
Expand All @@ -227,6 +232,38 @@ func TestAnalytics(t *testing.T) {
})
}
})

t.Run("page_type", func(t *testing.T) {
// Verifies ClassifyPageType's output reaches the sa-bootstrap data-page-type
// attribute (which seeds window.sa_metadata client-side) for representative
// routes.
expected := map[string]string{
"/": "home",
"/r/gnoland/blog": "realm",
"/r/gnoland/blog$help": "help",
"/r/gnoland/blog/admin.gno": "source",
"/r/sys/users": "realm",
}

cfg := NewDefaultAppConfig()
cfg.NodeRemote = remoteAddr
cfg.Analytics = true
logger := log.NewTestingLogger(t)
router, err := NewRouter(logger, cfg)
require.NoError(t, err)

for route, pageType := range expected {
t.Run(route, func(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, route, nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)

body := response.Body.String()
want := fmt.Sprintf(`data-page-type="%s"`, pageType)
assert.Contains(t, body, want, "route %q should emit %s", route, want)
})
}
})
}

func TestHealthEndpoints(t *testing.T) {
Expand Down
Loading
Loading