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 ¶
- Variables
- func Apply(events []event.Event, filters []Filter) []event.Event
- func ApplyMode(evs []event.Event, filters []Filter, anyMode bool) []event.Event
- func FirstUnknownProp(events []event.Event, filters []Filter) (string, []string)
- func GroupKey(props map[string]any, property string) string
- func IsSampler(name string) bool
- func IsUserAttr(prop string) bool
- func Keeper(filters []Filter) func(event.Event) bool
- func Matches(e event.Event, filters []Filter) bool
- func ScopeUsers(events []event.Event, filters []Filter, anyMode bool) []event.Event
- func StampFirstTouch(events []event.Event, prop string) []event.Event
- func StampFirstTouchAll(events []event.Event, props []string) []event.Event
- func StampForFilters(evs []event.Event, filters []Filter) []event.Event
- func ToNum(v any) float64
- func ToStr(v any) string
- func Validate(filters []Filter) error
- func WithoutSampler(evs []event.Event) []event.Event
- type Filter
- type FirstTouch
- type Group
- type Op
Constants ¶
This section is empty.
Variables ¶
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.
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 ApplyMode ¶ added in v0.9.1
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
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
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
IsSampler reports whether an event name is one this tool wrote about itself.
func IsUserAttr ¶ added in v0.48.0
IsUserAttr reports whether a property describes the VISITOR rather than the event, so a metric filtered by it must scope USERS (any-touch) instead of filtering events.
This is the single definition. There were two: this map, and a hand-written 9-case switch in internal/api, and they had drifted apart by "channel" and "platform". On those two properties the dashboard did event-level filtering — the exact failure its own code comment warns about — while /v1 and MCP did user-level. Filtering by platform=ios dropped every signup event, so the headline tile silently changed identity from "signup · 7d" to "page view · 7d" and the conversion number left the page, while /v1/trends answered the real figure at the same instant.
A predicate duplicated in two packages is a divergence with a start date.
func Matches ¶ added in v0.9.5
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
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 StampFirstTouchAll ¶ added in v0.21.0
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
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 ToNum ¶ added in v0.34.0
ToNum exposes the same coercion for callers that must ship Go's parse rather than re-derive it.
The flag definitions bundle sends this value to clients precisely so a browser never runs its own Number() over a comparand: Go and JavaScript disagree on "1_000", "0x10", " 5 " and "", and each disagreement quietly moves a user into a different experiment arm. Exporting the real function is the only way that guarantee holds — a reimplementation in flag/ would be the second definition it exists to prevent.
func ToStr ¶ added in v0.29.0
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
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
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 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.
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" )