Events
What lands in the database, and how to add to it. Two layers stack up on every install: the SDK autocaptures a fixed set of events plus a per-session context blob; the server derives another layer from the request itself — device, campaign, geography, bot verdict — that no SDK has to send. Everything below is what you get before writing a single line of instrumentation.
The autocapture matrix
Same event names on all three platforms, different coverage — a browser exposes a DOM to walk, a game engine does not. Automatic means it happens with no code from you; wire it up means the SDK emits it but cannot know when without a one-line hook.
| Event | Web | React Native | Unity |
|---|---|---|---|
pageview: /path | AutomaticEvery load + SPA route change | Wire it upuseScreenTracking(pathname) | AutomaticScene loads; Screen() for in-scene UI |
click: Label | AutomaticAny interactive element | AutomaticTaps on anything pressable | AutomaticuGUI only — not world objects |
scroll: 75% | Automatic25/50/75/90/100 per page | Wire it upSpread useScrollDepth() on a scroller | Not available |
form_submit: name | AutomaticEvery form submit | Not available | Not available |
input: field | AutomaticFocus + change, names only | Not available | Not available |
leave: /path | AutomaticOn pagehide, with time + depth | AutomaticScreen change + backgrounding | AutomaticScene change + pause/quit |
identify | You call itYou call it at sign-in | You call itYou call it at sign-in | You call itYou call it at sign-in |
| custom events | You call itcapture() | You call itcapture() | You call itCapture() |
Sessions, identity, batching, retry, and offline buffering are automatic everywhere and are not listed above — see sessions and identity.
Props on autocaptured events
Every event carries ts, type, name, and path. These props ride along, and are queryable with --by props.<key>:
| Event | Props |
|---|---|
pageview | title (when the platform has one) |
click | selector (a short DOM path, RN component path, or Unity hierarchy path), text, href (web), interactive, trusted |
scroll | depth — 25, 50, 75, 90, or 100 |
form | form, fields — field names, never values |
input | field, form, action (focus or change) |
leave | duration_s, plus max_scroll on web and React Native |
Labels follow one rule across platforms, so click: Buy now means the same thing in a game and on a landing page: visible text first (collapsed, ≤50 chars), then the accessible label, then an identifier, then the element/GameObject name. A click on an unlabelled control shows up under its raw name — that is the signal to add label text, not to hand-instrument the button.
Web specifics
The script tag hooks pushState, replaceState, and popstate, so client-side routers produce real pageviews. leave fires once per page load — on pagehide, carrying duration_s and max_scroll — not on every SPA transition. Input events are debounced to one per field per second, and password and hidden fields are skipped entirely rather than reported by name.
The tracker also runs eight environment checks (webdriver flags, headless UAs, automation artifacts, an offscreen honeypot link, untrusted click events, and so on) and ships only the names of the ones that fired. Those feed the bot score — they are not fingerprinting, and no value from them is stored per visitor.
React Native specifics
Taps are captured by walking React’s fiber tree from the touch target to the nearest onPress, so pressables from any UI kit are covered without registration. Movement over ~8px counts as a swipe, not a tap. Screen views are the one thing the SDK cannot infer — it has no idea what your router is — so useScreenTracking is required for pageviews and for the leave events that carry per-screen time.
Backgrounding the app closes the current screen’s stint and force-flushes; time spent in the background is never counted toward it. If the app is killed with events still queued, they are replayed on the next launch under their original session id, so a crash does not silently eat the tail of a session. That requires a storage adapter — omit it and ids reset every launch.
Unity specifics
Single-mode scene loads become pageview: /scene-name; additive loads are ignored, since a streamed chunk or a UI overlay is not navigation. Clicks are raycast through the EventSystem to the nearest Selectable or IPointerClickHandler, which covers Canvas UI and nothing else — world-space objects and UI Toolkit need explicit Capture. Pointer travel beyond ~8dp is a drag, not a click.
A single-scene game gets exactly one pageview unless you call AgentHog.Screen("/shop") as UI panels change — worth doing, because screen paths are what funnels and time-per-screen are built from. Pausing or quitting emits leave and flushes; resuming after the idle window starts a new session and re-emits the current screen as its entry. Unsent batches survive to the next launch via PlayerPrefs.
Session context and auto-registered props
Once per session (and again whenever register() changes), the SDK sends a context blob: landing URL, referrer, screen size, viewport, timezone, and language. On mobile the landing URL is the deep link that launched the app when there is one — so install and campaign attribution rides the same pipe as a web ?utm_source= — and a synthesized app://name/path otherwise.
The mobile SDKs also register props onto every event, with no setup:
| Prop | Where it comes from |
|---|---|
platform | React Native and Capacitor: ios/android. Unity: ios, android, webgl, standalone, editor |
app_version | Your configured version (Unity defaults to Application.version) |
os_version | Platform version string |
device_model | Unity only |
engine | Unity only — e.g. unity 2022.3.10f1 |
platform, app_version, and os_version read as bare dimensions in the CLI: ah events --by platform, no props. prefix needed.
What the server derives
None of this is sent by the SDK — it is computed at ingest and at sweep, and is what most dashboard breakdowns actually group on.
| Derived | From |
|---|---|
| browser, os, device_type | The User-Agent. Mobile SDKs send an identifying UA (myapp/1.0 AgentHogRN/0.3.0 (ios 17.4)) so they never look like an HTTP library |
| geo_country, asn, asn_name | An ASN/geo lookup on the request IP — country granularity only |
| entry_path, referrer, utm_*, fbclid, gclid | Parsing the landing URL; the full entry query string is kept in landing_params |
| duration_s, pageviews, exit_path, max_scroll | Rolled up from the session’s own events |
| engaged, bounce, converted, is_returning | ≥30s or ≥2 interactions; ≤1 pageview and <10s with no interaction; any event matching a project goal; a prior session from the same visitor |
| classification, bot_score | Client signals + datacenter ASN + crawler UA → human, unknown, suspected_bot, crawler, server, or test |
Sessions and identity
A session rotates after 30 minutes idle on every platform, and the anonymous id persists across them — first-party cookie plus localStorage on web, your storage adapter in React Native, PlayerPrefs in Unity. There is no third-party cookie and no cross-site identifier anywhere in the stack.
identify(email) is what merges a person across devices. Games rarely have an email; a stable user_id trait stitches just as well. reset() at sign-out mints a new anonymous id and session so the next player is not attributed to the last one.
What is never captured
No field values, ever
Forms and inputs report field names only. Password and hidden inputs are skipped outright — not even their names are sent — so a submitted login form yields form_submit: login with fields: ["email"] and nothing else.
No raw IP addresses
The IP is used at ingest for geo/ASN lookup and then discarded. What is stored is a salted hash that rotates daily, so the same visitor’s sessions cannot be linked by IP across days — not by us, not by anyone with database access.
No replay, no keystrokes, no screenshots
There is no session-recording product hiding in the tracker. Keyboard activity contributes a single boolean used for bot scoring; nothing about what was typed leaves the device.
Whatever you put in props
The one place PII can enter is custom event props, which are stored verbatim and readable by anyone with dashboard access. Send ids and categories, not tokens, secrets, or profile blobs.
Extending it with custom events
Autocapture covers navigation and interaction. It cannot know that a checkout succeeded or a boss was defeated — that is what capture is for. Four calls, the same on every platform:
| Call | What it does |
|---|---|
capture(name, props) | A custom event. Name stored verbatim, props queryable |
identify(email, traits) | Attaches identity to this visitor and merges them across devices |
tag(name, value) | Sugar for a single trait; also emits tag: <name> |
register(props) | Props merged into every subsequent event — build channel, cohort. For A/B variants use real feature flags: flag() stamps $ff/<key> automatically |
Web — the global exists only in the browser, only after the script runs:
window.agenthog.capture('checkout_completed', { plan: 'pro', cents: 4900 })
window.agenthog.identify('[email protected]', { plan: 'pro' })
window.agenthog.register({ variant: 'b' })React Native — anywhere inside the provider:
const ah = useAgentHog()
ah.capture('photo_sent', { recipients: 3 })
ah.identify(email, { clerk_id: user.id })
ah.reset() // sign-outUnity — static calls, safe no-ops before init or when disabled:
AgentHog.Capture("level_complete", new Dictionary<string, object> {
["level"] = 12, ["deaths"] = 3, ["seconds"] = 94,
});
AgentHog.Screen("/shop"); // in-scene UI states
AgentHog.Identify(traits: new Dictionary<string, object> { ["user_id"] = playerId });Some events have no client to fire them — a subscription renews, a webhook lands, a nightly job decides someone churned. Those go straight to the ingest endpoint from your backend: server to server.
Naming rules that keep events queryable
- · Values go in props, not in names.
capture('plan_selected', { plan: 'pro' })can be split with--by props.plan.plan_selected_prois a name you will have to grep for forever. - · Keep the autocapture shapes. If you emit screen views by hand, emit
pageview: /path. The CLI, funnels, and paths all parse theprefix: subjectform. - · Short, readable, stable.
checkout_completed, notevt_CHECKOUT_v2and never a UUID. Names are the query surface — an agent composes questions from them without re-deriving your schema. - · Instrument outcomes, not every tap. Clicks are already captured. Custom events are for the things that mean something happened.
Reading it back
Everything above is queryable the moment it lands — the web tracker flushes every 5s or 10 events, React Native and Unity every 10s or 20 events.
ah events --since 24h # everything, newest first ah events top --by platform # what is firing, split by platform ah events --name checkout_completed --by props.plan ah goals set signup "form_submit: waitlist"
Or open the dashboard. Not integrated yet? Install an SDK first — or point your agent at the skill and let it do the wiring.