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).
Consent invariant ¶
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
- func Attach(t Tracker, lc bellui.Lifecycle)
- func Close(t Tracker) error
- func DisableAutoCapture()
- func EnableAutoCapture(t Tracker, opts ...CaptureOption)
- func Flush() error
- func Identify(id string, traits map[string]any)
- func NoCapture(content core.CanvasObject) core.CanvasObject
- func PostJSONGzip(url string, headers map[string]string, payload any) error
- func SetDefault(t Tracker)
- func SetEnabled(enabled bool)
- func Track(event string, props map[string]any)
- type BelluiStore
- type CaptureOption
- type Clock
- type Event
- type EventKind
- type Option
- type Sink
- type Store
- type Ticker
- type Tracker
Constants ¶
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 ¶
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 ¶
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 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 ¶
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.
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.
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.
type Option ¶
type Option func(*config)
Option configures a Tracker created by New.
func WithBackoff ¶
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 ¶
WithBatchSize sets the queue length that triggers a flush (default 30). A value <= 0 leaves the default.
func WithClock ¶
WithClock overrides the time source. Used by tests for deterministic interval and backoff behaviour.
func WithInterval ¶
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 ¶
WithJitterSource overrides the jitter source (a func returning a value in [0,1)) used by backoff. Used by tests for determinism.
func WithMaxSpool ¶
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 ¶
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 New ¶
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.
Source Files
¶
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. |