query

package
v0.30.0 Latest Latest
Warning

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

Go to latest
Published: Aug 5, 2026 License: MIT Imports: 8 Imported by: 0

Documentation

Overview

Package query is the segmentation backbone: filter events by their properties and break them down (group by) a property. Every report (funnel, retention, trends) gets filtering + breakdown by composing these over the event slice before the deterministic compute — the thing that makes analytics powerful.

Index

Constants

This section is empty.

Variables

View Source
var NonProduction = map[string]bool{
	"development": true,
	"preview":     true,
	"staging":     true,
	"test":        true,
	"ci":          true,
}

NonProduction lists the env values hidden from every default-scoped query. The browser SDK stamps localhost and dev tunnels as "development", and Netlify deploy previews plus staging/preview/dev subdomains as "preview"; anyone can set their own via init({env}).

An explicit set, NOT `env != "production"`. The two failure modes are not symmetric: showing a bit of preview traffic is noise the viewer can filter away, while hiding real traffic is invisible from the dashboard and reads as "your product recorded nothing today". So an unrecognised value stays VISIBLE — someone who writes env:"prod" or env:"live" must never have their production numbers silently disappear because it did not match a magic string.

View Source
var SamplerEvent = map[string]bool{
	"$geo_check":     true,
	"$site_readable": true,
	"$ai_crawl":      true,
}

SamplerEvent names the events THIS TOOL writes into the operator's own instance: the GEO runner's AI-visibility checks, its site-readability scans, and the server-side AI-crawler records. They are our robot, not the product's users.

Left in a product-activity report they are actively wrong, not merely noisy. They arrive under one synthetic distinct_id on a daily schedule, so that identity reads as a user who returns every single day — it inflates retention, it inflates MAU, it lands in lifecycle as a permanently-loyal cohort member, and it competes for the anomaly slot with an event name that has no reader-facing wording at all.

This lived as a private helper inside internal/insight, which is why the verdict card was the ONLY surface that excluded them. Measured on a live instance: the verdict read "Day-1 retention 4% of 114 users" while the ask bar, three inches above it on the same page at the same moment, answered "Day-1 retention is 6% ... of 119 users". Same metric, same instant, two numbers — on the product whose footer promises "ask == dashboard == MCP, all from one report engine". One exported definition now, so a surface can only diverge on purpose.

The names are literals rather than imports of aivis.CheckEvent / insight.ReadableEvent / aicrawl.CrawlEvent because query sits underneath all three. TestSamplerNamesMatchTheirSources in internal/insight holds the two ends together.

Functions

func Apply

func Apply(events []event.Event, filters []Filter) []event.Event

func ApplyMode added in v0.9.1

func ApplyMode(evs []event.Event, filters []Filter, anyMode bool) []event.Event

ApplyMode is Apply with an any/all switch: all = every filter must match (the default AND), any = at least one must (the OR mode of the dashboard's filter builder). Dev-traffic exclusion applies in both modes, before the user filters.

func FirstUnknownProp added in v0.9.1

func FirstUnknownProp(events []event.Event, filters []Filter) (string, []string)

FirstUnknownProp returns the first filter property that uses a POSITIVE operator (eq/contains/gt/lt/in/set/regex — ones that can only match when the property exists) yet appears on NO event, plus a sorted list of the properties that DO exist. This is how a filtered report tells a typo ("plann=pro" → no such property) apart from a genuine empty result, instead of silently returning 0 as if it were the honest answer. Negative operators (neq/notin/notset/notcontains) are skipped: a missing property legitimately satisfies them. Returns ("", nil) when every positive filter is known.

func GroupKey added in v0.29.0

func GroupKey(props map[string]any, property string) string

GroupKey is the breakdown bucket label for one event's property: "(none)" when the property is missing OR explicitly null. A JSON null carries no information the way "" or "pro" does — an SDK sending track("signup", {plan: null}) means "no plan", and bucketing it under its own stringified label ("" here, "<nil>" in trends) made one group render as two different segments depending on which endpoint you asked, neither of them clickable.

func IsSampler added in v0.29.0

func IsSampler(name string) bool

IsSampler reports whether an event name is one this tool wrote about itself.

func Keeper added in v0.21.0

func Keeper(filters []Filter) func(event.Event) bool

func Matches added in v0.9.5

func Matches(e event.Event, filters []Filter) bool

Apply returns the events matching ALL filters — and enforces the one default scope of the whole query layer: events stamped env=development are EXCLUDED unless the filters explicitly reference "env". Localhost traffic polluting production funnels is the classic silent report-corruptor; asking for dev data stays one filter away (env eq development). Living inside Apply means every surface (HTTP API, MCP, dashboard) inherits the same rule — the agreement test depends on that. Matches reports whether a single event satisfies ALL of the filters (AND). Unlike Apply it does NOT apply the default dev-env exclusion — that's a stream-level concern; callers that want it pre-filter the stream with Apply first. Used for per-step conditions in sequenced cohorts, where each step constrains one event by name + its own property filters.

func ScopeUsers added in v0.9.1

func ScopeUsers(events []event.Event, filters []Filter, anyMode bool) []event.Event

ScopeUsers keeps every event of any user who has at least one event matching the filters — user-level scoping (vs Apply's event-level). Funnels use this so a filter on a user attribute (plan, device) that isn't present on every step event scopes the POPULATION, not the events, and later steps aren't dropped. Empty filters = unchanged.

func StampFirstTouch added in v0.9.1

func StampFirstTouch(events []event.Event, prop string) []event.Event

func StampFirstTouchAll added in v0.21.0

func StampFirstTouchAll(events []event.Event, props []string) []event.Event

StampFirstTouchAll is StampFirstTouch for several properties in ONE pass.

Callers used to chain it: `for _, p := range eightProps { evs = StampFirstTouch(evs, p) }`. Because stamping copies every event's property map, that is eight full copies of history and eight fresh maps per event — 1.6M map allocations for 200k events, and measured as 47% of everything the dashboard allocated. Stamping N properties at once allocates one map per event instead of N, because each pass only ever ADDED a key.

Batching is safe precisely because the properties are distinct: stamping "device" never changes any event's "country" value, so the first-touch lookup for a later property reads the same input either way and the result is identical. The parity test in query_test.go pins that equivalence against the chained implementation rather than trusting the argument.

func StampForFilters added in v0.9.3

func StampForFilters(evs []event.Event, filters []Filter) []event.Event

StampForFilters stamps every acquisition/user-attribute property a filter targets onto the user's whole stream, so "signups where device=mobile" (or referrer/utm/country/…) means "signups by users who came in on mobile" — the same number the dashboard and ask bar report — instead of a silent 0 because the signup event never carried the property. Product/event props (plan, amount) are left untouched; they're set at the step itself, so an event-level filter is right. Both GET /v1 and the MCP tools call this before applying filters.

The stamped value is the user's first touch EXCEPT where they have an event that actually satisfies the filter, in which case that value wins. Pure first touch made this surface disagree with the other two on any user whose attribute changed between sessions: u1 lands on desktop, returns on mobile, signs up — "signups where device=mobile" measured 0 on /v1 and MCP (the mobile pageview was overwritten with desktop before filtering) and 1 on the dashboard (query.ScopeUsers) and the ask bar (segFilterUsers), which both keep every event of a user with AT LEAST ONE match. Two answers to one question is the failure this engine exists to prevent, and any-touch is the semantic the majority of surfaces already implement, so this one moves. Single-valued users — the overwhelming majority, and everything the covenant tests cover — score identically under either rule.

func ToStr added in v0.29.0

func ToStr(v any) string

ToStr is the engine's ONE property stringifier, exported so every breakdown surface labels a group the same way. trends.ComputeBreakdown used to carry its own copy that rendered a nil as "<nil>" while this one rendered "" — the same three events came back as `{"value":""}` from /v1/breakdown and `("<nil>", 2)` from /v1/trends?breakdown=, and the drill-down filter the legend generates (f=plan:eq:<nil>) matched neither, so a real segment opened as an empty report.

func Validate added in v0.2.0

func Validate(filters []Filter) error

Validate rejects malformed filters up front. An unrecognized op would otherwise match NOTHING and every report would return zeros that look like a real answer — the exact silent-wrong-number failure this engine exists to prevent.

func WithoutSampler added in v0.29.0

func WithoutSampler(evs []event.Event) []event.Event

WithoutSampler drops this tool's own writes from a product-activity view.

Use it for every report that answers "what did USERS do" — retention, stickiness, lifecycle, active users, paths, sessions, anomalies. Do NOT use it for the AI-visibility and AI-crawler reports: those events are their INPUT, and filtering them there empties the pane.

Returns the input untouched when there are none, which is every instance that never turned GEO on — so the common path allocates nothing.

Types

type Filter

type Filter struct {
	Property string `json:"property"`
	Op       Op     `json:"op"`
	Value    any    `json:"value"`
}

Filter is a single predicate over an event property. Filters combine with AND.

type FirstTouch added in v0.21.0

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

FirstTouch holds each user's earliest value for a set of properties. Building it is a scan that allocates nothing per event; applying it is what costs, because stamping has to copy a property map.

Separating the two matters because the population you must LOOK AT to find a user's first touch (all of history — the country is on the landing pageview) is much larger than the population you need to STAMP (in segmentBlame, only the two event names in the funnel step being blamed). Fused together, every caller paid to copy history.

func BuildFirstTouch added in v0.21.0

func BuildFirstTouch(events []event.Event, props []string) *FirstTouch

BuildFirstTouch scans every event once and records, per property, each user's value from their earliest event carrying it. Nothing is copied.

func (*FirstTouch) Stamp added in v0.21.0

func (f *FirstTouch) Stamp(events []event.Event) []event.Event

Stamp returns a copy of events carrying each user's first-touch values. The input is never mutated: callers re-read the original events to decide what a conversion event natively carries, and stamped values leaking back into that check would silently skip properties that must be stamped.

type Group

type Group struct {
	Value  string        `json:"value"`
	Events []event.Event `json:"-"`
	Count  int           `json:"count"`
}

Group is one breakdown bucket: a property value and its events.

func Breakdown

func Breakdown(events []event.Event, property string) []Group

Breakdown groups events by a property value, sorted by count descending. Events missing the property fall into "(none)" — and so does an explicit JSON null, see GroupKey.

type Op

type Op string

Op is a filter comparison.

const (
	Eq          Op = "eq"
	Neq         Op = "neq"
	Contains    Op = "contains"
	Gt          Op = "gt"
	Lt          Op = "lt"
	In          Op = "in"     // value is one of a list — expresses OR over one property (source in [hn, twitter])
	NotIn       Op = "notin"  // value is none of a list (or the property is missing)
	Set         Op = "set"    // the property exists on the event (value ignored)
	NotSet      Op = "notset" // the property is missing (value ignored)
	Regex       Op = "regex"  // value is a Go regexp matched against the stringified property
	NotContains Op = "ncontains"
)

Jump to

Keyboard shortcuts

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