analytics

package
v1.1.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 16, 2026 License: BSD-3-Clause Imports: 19 Imported by: 0

Documentation

Overview

Package analytics is an opt-in, consent-first product-analytics package that lives beside the toolkit (the theme/cjk placement pattern): it is never imported by Bellui core, and costs nothing unless an app imports it. bellui.App, the drivers and the widgets stay 100% analytics-unaware; the only toolkit integration point is the public bellui.Lifecycle (see Attach).

A Tracker starts DISABLED. Until Tracker.SetEnabled(true) is called, Tracker.Track and Tracker.Identify drop events on the floor — nothing is queued to disk and nothing leaves the process. The package generates NO device identifiers (no advertising ID, no fingerprint, no random install ID); an app supplies an identity only via Tracker.Identify, and only when it has consent. Disabling again (SetEnabled(false)) clears the on-disk spool. These properties are enforced by construction and covered by tests, which keeps the Google Play Data-safety / GDPR story honest: with consent withheld there is no data collection and no identifier to disclose.

Battery model

Per-event HTTP on cellular is the classic telemetry battery killer — the LTE radio cost is per-wake-up, not per-byte. This package is built so an app can instrument every interaction without pinning the radio:

  • Track is fire-into-queue, NEVER fire-into-socket. No code path performs synchronous per-event network I/O.
  • Batches flush when (defaults, all tunable via Option) the queue reaches 30 events, a 60s foreground interval elapses, the app enters the background, or Tracker.Flush is called explicitly.
  • The interval timer is suspended while backgrounded (mobile processes get frozen); the background-edge flush is the workhorse.
  • The queue is checkpointed to a disk spool so it survives process death and drains oldest-first at the next launch; the spool is bounded with oldest-first eviction so a dead sink cannot grow it without limit.
  • Batch bodies are gzipped by the Sink; one request per flush.
  • A failed flush returns the events to the spool and backs off exponentially with jitter — offline is a normal state, not an error.

Pure-Go apps cannot use Android WorkManager / iOS BGTaskScheduler (no JNI surface for OS-scheduled deferred upload); the compensation is bigger batches, longer intervals, a background-edge flush and a next-launch drain. A one- session analytics latency is acceptable by design.

Index

Constants

View Source
const (
	// DefaultBatchSize is the queue length that triggers a flush.
	DefaultBatchSize = 30
	// DefaultInterval is the foreground flush interval.
	DefaultInterval = 60 * time.Second
	// DefaultMaxSpool is the maximum number of events retained; the oldest are
	// evicted first once the cap is exceeded.
	DefaultMaxSpool = 1000
	// DefaultBackoffBase is the first retry delay after a failed flush.
	DefaultBackoffBase = 5 * time.Second
	// DefaultBackoffMax caps the exponential retry delay.
	DefaultBackoffMax = 10 * time.Minute
)

Default battery-model tuning. The defaults are deliberately radio- conservative (see the package doc).

Variables

This section is empty.

Functions

func Attach

func Attach(t Tracker, lc bellui.Lifecycle)

Attach wires a tracker's session lifecycle (session_start / session_end and the background-edge flush) to a bellui.Lifecycle, obtained from bellui.App.Lifecycle(). It is the only toolkit integration point. Trackers that were not created by New are ignored.

func Close

func Close(t Tracker) error

Close stops a tracker's background worker (see New). It does not flush; Flush first if a final delivery is wanted. Trackers not created by New are ignored.

func DisableAutoCapture

func DisableAutoCapture()

DisableAutoCapture removes any consumer installed by EnableAutoCapture, restoring the single-nil-check cost. Safe to call when none is installed.

func EnableAutoCapture

func EnableAutoCapture(t Tracker, opts ...CaptureOption)

EnableAutoCapture installs a core.OnActivation consumer that emits a "ui.activate" event to t for every interactive activation — a tap, or a screen-reader press/toggle/select (both route through the same driver Tapped dispatch). The event is derived from the PUBLIC accessibility snapshot (core.SnapshotAccessible): its properties are the widget's semantic role, its label, and its boolean state — and nothing else.

Everything inherits phase 1's guarantees: the event goes through t.Track, so it rides the same consent gate, disk spool, batching loop and backoff. It is impossible to wire auto-capture to a raw sink — it only ever calls Tracker.Track. With consent withheld (tracker disabled) nothing is captured.

Privacy guardrails (all enforced here, none optional):

  • Role, label and state ONLY. The mapper never reads Value or Description, so text content, Entry values and free-form descriptions never leave the process.
  • A target that reports core.AccessibleConcealed true (a password Entry) is excluded wholesale — belt-and-suspenders on top of never capturing values.
  • Any activation inside a NoCapture subtree is skipped.

Roles are mapped to a fixed verb vocabulary (press/toggle/select); an activation whose role has no meaningful activation (a label, a plain text box) is not captured. Use WithSampling to capture 1-in-N of the eligible activations for a high-frequency UI. Install once at startup, after creating the tracker; call DisableAutoCapture to remove the consumer.

The consumer runs on the main goroutine (the driver's input dispatch); Track is fire-into-queue, so it never blocks input.

func Flush

func Flush() error

Flush calls Flush on the default tracker.

func Identify

func Identify(id string, traits map[string]any)

Identify calls Identify on the default tracker.

func NoCapture

func NoCapture(content core.CanvasObject) core.CanvasObject

NoCapture wraps content in a layout-transparent container that marks its whole subtree opt-out: EnableAutoCapture skips any activation that occurs within it. It does not change hit-testing, focus or appearance — only the capture decision (option B: a top-down ancestor walk finds the marker; no CanvasObject change). Nest it around a sensitive region (a payment form, a login panel) to exclude the region wholesale.

func PostJSONGzip

func PostJSONGzip(url string, headers map[string]string, payload any) error

PostJSONGzip marshals payload to JSON, gzips it, and POSTs it to url with the given headers (Content-Type: application/json and Content-Encoding: gzip are set automatically). It is the shared transport for the vendor Sink subpackages: one gzipped request, an error on any non-2xx response. Bodies are drained and closed so connections are reused.

func SetDefault

func SetDefault(t Tracker)

SetDefault installs the package-level default Tracker used by the package functions Track, Identify, Flush and SetEnabled. Passing nil restores the safe no-op default.

func SetEnabled

func SetEnabled(enabled bool)

SetEnabled calls SetEnabled on the default tracker.

func Track

func Track(event string, props map[string]any)

Track calls Track on the default tracker.

Types

type BelluiStore

type BelluiStore struct {
	// contains filtered or unexported fields
}

BelluiStore persists the spool inside an app's storage sandbox via bellui.Storage. It is the default when a current app is available.

func NewBelluiStore

func NewBelluiStore(app bellui.App) *BelluiStore

NewBelluiStore returns a Store backed by the given app's storage sandbox.

func (*BelluiStore) Clear

func (s *BelluiStore) Clear() error

Clear implements Store.

func (*BelluiStore) Load

func (s *BelluiStore) Load() ([]byte, error)

Load implements Store.

func (*BelluiStore) Save

func (s *BelluiStore) Save(data []byte) error

Save implements Store.

type CaptureOption

type CaptureOption func(*captureConfig)

CaptureOption configures EnableAutoCapture.

func WithSampling

func WithSampling(n int) CaptureOption

WithSampling captures 1-in-n of the activations that would otherwise be emitted (deterministic counter, not wall-clock): the first eligible activation and every n-th after it produce an event, the rest are dropped. n <= 1 captures every activation. Sampling is applied after the privacy filters, so it is a true 1-in-n of the events that pass them.

type Clock

type Clock interface {
	// Now returns the current time.
	Now() time.Time
	// NewTicker returns a ticker firing every d. The tracker stops and
	// recreates the ticker across background/foreground transitions.
	NewTicker(d time.Duration) Ticker
}

Clock abstracts time so the interval-flush timer and backoff scheduling are deterministic in tests. The default [realClock] uses the standard library.

type Event

type Event struct {
	// Kind is KindTrack or KindIdentify.
	Kind EventKind `json:"kind"`
	// Name is the event name (empty for KindIdentify).
	Name string `json:"name,omitempty"`
	// Props are the event properties (KindTrack).
	Props map[string]any `json:"props,omitempty"`
	// Traits are the identity traits (KindIdentify).
	Traits map[string]any `json:"traits,omitempty"`
	// UserID is the app-supplied identity, if any. The package never
	// synthesises this value.
	UserID string `json:"user_id,omitempty"`
	// SessionID is the ephemeral, per-run session identifier. It is
	// regenerated every launch and never persisted as an identity, so it is
	// not a device or install identifier.
	SessionID string `json:"session_id,omitempty"`
	// Timestamp is the wall-clock time the event was recorded.
	Timestamp time.Time `json:"timestamp"`
}

Event is a single queued analytics record. Sinks translate it to their vendor wire format.

type EventKind

type EventKind string

EventKind distinguishes an explicit event from an identity association.

const (
	// KindTrack is a plain event recorded by [Tracker.Track].
	KindTrack EventKind = "track"
	// KindIdentify associates a stable user identity, recorded by
	// [Tracker.Identify].
	KindIdentify EventKind = "identify"
)

type Option

type Option func(*config)

Option configures a Tracker created by New.

func WithBackoff

func WithBackoff(base, max time.Duration) Option

WithBackoff sets the base and max delays for the exponential-with-jitter retry after a failed flush (defaults 5s / 10m). Non-positive values leave the respective default.

func WithBatchSize

func WithBatchSize(n int) Option

WithBatchSize sets the queue length that triggers a flush (default 30). A value <= 0 leaves the default.

func WithClock

func WithClock(clk Clock) Option

WithClock overrides the time source. Used by tests for deterministic interval and backoff behaviour.

func WithInterval

func WithInterval(d time.Duration) Option

WithInterval sets the foreground flush interval (default 60s). The timer is suspended while the app is backgrounded. A value <= 0 leaves the default.

func WithJitterSource

func WithJitterSource(f func() float64) Option

WithJitterSource overrides the jitter source (a func returning a value in [0,1)) used by backoff. Used by tests for determinism.

func WithMaxSpool

func WithMaxSpool(n int) Option

WithMaxSpool bounds the number of events retained across the in-memory ring and the disk spool (default 1000). Once exceeded, the oldest events are evicted first. A value <= 0 leaves the default.

func WithStore

func WithStore(s Store) Option

WithStore overrides the disk spool backend. By default the spool is backed by the current app's bellui.Storage when one exists, or an in-memory (non- persistent) store otherwise. Tests use this to inject a fake.

type Sink

type Sink interface {
	// Send delivers batch. A returned error causes the events to be retained
	// in the spool and retried later with backoff; Send must not block
	// indefinitely.
	Send(batch []Event) error
}

Sink delivers a batch of events to a backend. Implementations should gzip the body and perform exactly one request per Send. A Sink is invoked only from the tracker's background worker or from an explicit Tracker.Flush; it is never called on the Tracker.Track hot path.

Vendor adapters live in dumb single-file subpackages (analytics/ga4, analytics/posthog); apps can bring their own.

type Store

type Store interface {
	// Save overwrites the spool with data.
	Save(data []byte) error
	// Load returns the last saved spool, or (nil, nil) when none exists.
	Load() ([]byte, error)
	// Clear removes the spool.
	Clear() error
}

Store is the persistence backend for the disk spool. It stores a single opaque blob (the serialized event ring) that survives process death.

The default implementation is backed by bellui.Storage (see BelluiStore); tests inject an in-memory implementation.

type Ticker

type Ticker interface {
	// C is the channel on which ticks are delivered.
	C() <-chan time.Time
	// Stop halts the ticker.
	Stop()
}

Ticker mirrors the subset of *time.Ticker the tracker needs.

type Tracker

type Tracker interface {
	// Track queues an event. It is fire-into-queue: it never performs network
	// I/O and never blocks on a socket. When the tracker is disabled the call
	// is a no-op (the event is dropped, not spooled).
	Track(event string, props map[string]any)
	// Identify associates a stable, app-supplied identity with subsequent
	// events. Like Track it is queue-only and a no-op while disabled. Pass an
	// identity only when the app has consent; the package never generates one.
	Identify(id string, traits map[string]any)
	// Flush attempts to deliver all queued events now and returns the sink
	// error, if any. It is the one explicitly synchronous path (the caller
	// asked for it); it still performs a single batched request.
	Flush() error
	// SetEnabled toggles consent. Enabling drains any spool left by a previous
	// run (oldest-first). Disabling drops the in-memory queue and clears the
	// on-disk spool. A tracker is created disabled.
	SetEnabled(enabled bool)
}

Tracker is the consent-gated event queue. Obtain one from New.

func Default

func Default() Tracker

Default returns the package-level default Tracker.

func New

func New(sink Sink, opts ...Option) Tracker

New returns a consent-gated Tracker delivering batches to sink. The tracker starts DISABLED (see the package doc's consent invariant): call SetEnabled (true) once the app has consent.

When no WithStore option is given, the spool is backed by the current app's storage sandbox if a bellui.App exists, otherwise by a non-persistent in- memory store.

Directories

Path Synopsis
Package ga4 is a dumb analytics.Sink over the Google Analytics 4 Measurement Protocol.
Package ga4 is a dumb analytics.Sink over the Google Analytics 4 Measurement Protocol.
Package posthog is a dumb analytics.Sink over the PostHog batch capture API.
Package posthog is a dumb analytics.Sink over the PostHog batch capture API.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL