Skip to main content
New: Free scam checker: paste any SMS and find out in seconds Try it →

At Scale, Users Speak in Events: How EnfoldAI Turns Them into Answers and Decisions

Event-driven product analytics at EnfoldAI: one allowlisted Android logger, BigQuery facts rebuilt from the Firebase export, and a guarded text-to-SQL agent.

  • Mayank TalwarCo-founder and CTO
  • Dhruv KalraFounding Engineer
  • Boya Veera PavanFounding Engineer

15 October 202613 min read

SpecimenIllustrative; details changed

EnfoldAI · Setup · First launch

To use EnfoldAI protection, make it your default SMS app.

Set as default
Logged
This screen, and the button tapped
Never logged
Messages, numbers, contacts

In 2025, Indians reported 28.15 lakh cyber-fraud cases and lost ₹22,495 crore, according to Ministry of Home Affairs figures reported by ThePrint. A scam-protection app helps with that only on the phones where someone finished setting it up, and for EnfoldAI setup includes asking Android to make us the default SMS app.

At our own community sessions we can watch people install the app and see where they hesitate. Everywhere else, the only way to learn how people get through setup, which screens they come back to and where they give up is to have the app tell us, one event at a time.

This post follows those events from a tap on the phone to a decision about the product. Three ideas shaped the design. An event is a contract between the app and everyone who reads the data, so it needs a catalogue and a test. The warehouse rebuilds facts from the raw log rather than patching them, so any number can be recomputed and argued about. And a number earns a decision only when everyone means the same thing by it, which is harder than collecting it.

Background: event-driven analytics on Firebase and BigQuery

An event-driven architecture treats things that happened as immutable records. Producers emit them without knowing who will read them, and consumers build their own views from the stream. Product analytics fits that shape well, because the app shouldn't care whether a tap ends up in a funnel, a retention cohort or a campaign report.

Two platform facts shape our version of it. Since Android 10, an app becomes the default SMS app by requesting the SMS role through RoleManager, which shows the person a system prompt they can decline. So our onboarding crosses a screen we don't control, and our events have to describe what happened around it.

The second is how events reach the warehouse. We collect them with Google Analytics for Firebase and export them to BigQuery. Google's BigQuery Export documentation describes a daily export of the previous day's unsampled events, and a streaming export that is best-effort, may contain gaps and is deleted once the daily table is complete. Much of what follows is about living with that difference.

We treat every input to a number as untrusted until it's checked.

Input What can go wrong How we treat it
An event from the phone Battery management on some phones stops it from ever uploading Evidence of what someone tried; outcomes the server can confirm are counted from the server
Event names and fields Any engineer who ships a screen can add or rename one A catalogue with an allowlist and a contract test
The streaming export Best-effort, with gaps Provisional until the daily export replaces it
Test phones and our community sessions They look like real installs Dropped, or attributed to us, before anything is counted
A metric's definition Each query author can write their own One definition, shared by every view

Invariants

These had to hold whichever screen, export or query was misbehaving, and later sections refer back to them.

ID Invariant Enforced by
I1 An event whose name isn't in the catalogue never leaves the phone One logger with an allowlist
I2 A catalogued event name keeps its meaning A contract test that fails on a rename or deletion
I3 Every event says whether a person or the app caused it An actor field on every event
I4 Message text, phone numbers and contacts never appear in an event Written rules on event groups, and an allowlist on the fraud-report flow
I5 Recomputing a day never counts it twice Delete and insert inside one transaction
I6 A number that can't be trusted yet says so A freshness gate, and provisional days marked until the daily export lands
I7 Outcomes the server can confirm are counted from the server Sign-ups counted from accounts

Architecture overview

EnfoldAI's product-analytics architecture in four bands, with events flowing one way from the phone to the dashboard and decisions flowing back. On the Android phone, three producers — screens (screen views and taps named in the catalogue), navigation and lifecycle (tab changes and second sessions) and background work (jobs and notifications, marked as the app's) — feed one analytics logger, whose allowlist stops unknown names from leaving the phone and which adds the actor, app version and phone model, then hands events to the analytics SDK, which batches and uploads them and can drop events. A dashed line marks what leaves the phone: screens, taps, actor, app version and phone model. Below it, exports and server truth: backend accounts (sign-ups confirmed by our servers), the streaming export (within minutes; best-effort, provisional) and the daily export (next day; complete, unsampled, always wins). The warehouse band is a daily job over identity, users, events, installs, journeys and catalogue, where each write rebuilds recent days in one transaction, with a run log of which export fed each date, producing modelled tables of installs, people, journeys, steps and the event dictionary. The last band is the dashboard and ask agent behind a freshness gate with every query byte-capped, a product review of a question, a change and a comparison, and back to the phone as releases, kill switches and campaigns with a holdout.

Here's the path of one event, the tap on "Set as default" from the specimen above.

  1. Emit. The screen calls our analytics logger with the screen, the element and the actor. The logger checks the name against its allowlist, adds the app version and the phone's model, and hands the event to the SDK, which batches and uploads it.
  2. Export. The event appears in the streaming export within minutes, and in the complete daily export the next day.
  3. Model. A daily job rebuilds the recent days from the raw export. It resolves identity, writes one row per event and per install, assembles onboarding journeys and refreshes a catalogue of every event it has seen.
  4. Read. The dashboard reads those tables behind a freshness gate, our ask agent answers new questions, and the people deciding what to change next read both.

None of this arrived at once. In April 2026 a single logger with an allowlist replaced direct SDK calls and began recording screen views and taps across the app. The dashboard started in March by querying the analytics API directly, and between June and August it moved onto tables modelled from the raw export, with one definition of an install. Retention cohorts, the event dictionary and the ask agent followed in September.

The event contract: a catalogue with a contract test

One tap from the specimen, as the event the warehouse receives. On the phone, the setup screen (To use EnfoldAI protection, make it your default SMS app; Set as default) calls the analytics logger, which asks whether the name is in the catalogue, stops it there if not (I1), and adds the actor, version and model. The event's fields, with the value for this tap and what set it: shape, a tap, set by the catalogue; screen, default SMS setup, set by the screen; element, the Set as default button, set by the screen; actor, person, set by the logger; app version, 1.1.58, a build constant; phone, manufacturer and model, set by Android; install identifier, pseudonymous, set by the analytics SDK; account identifier, only after sign-in, set by the app. A red band marks what is never in an event (I4): message text, phone numbers and contacts. Outcomes are separate names — a prompt granted and a prompt declined are two names, never free text — and background work logs the same shapes with the actor set to the app.

Most product questions come down to which screen someone saw and what they tapped on it. So the backbone of our catalogue is two shapes, a screen view and a tap, each carrying the screen, the element and the actor. Dedicated events cover flows whose outcomes we count separately, such as a permission prompt that can be granted or declined.

Outcomes are separate element names rather than a free-text result. A toggle logs a different element when it turns on than when it turns off, and a failed sign-in is its own named outcome instead of a generic error event. That keeps every value in the catalogue, where the dashboard can list it and a reviewer can read it.

// Simplified for this post. Real names and fields differ.
enum class Actor { PERSON, APP }

object Analytics {
    private val catalogue = setOf(SCREEN_VIEW, TAP /* , outcome events */)

    fun log(name: String, screen: String, element: String, actor: Actor = Actor.PERSON) {
        if (name !in catalogue) return            // unknown names never leave the phone (I1)
        sink.log(name, mapOf(
            "screen" to screen, "element" to element, "actor" to actor.name,
            "app_version" to AppInfo.VERSION,      // a compile-time constant
        ))
    }
}

The catalogue is a set of constants, and a contract test fails the build if a catalogued name is renamed or deleted (I2). Event groups also carry written rules about what they may never hold, such as "Counts only — neither may ever carry a sender, a message, or a scam label." The fraud-report flow passes its extras through an allowlist, with a test that pins typed text out of it (I4). Changes to the logger and the catalogue sit in the highest-risk review lane our repository defines.

The actor field, added in July, separates what people do from what the app does on its own, such as a background job finishing or a notification arriving (I3). Without it, a screen the app opened would count as a screen someone chose to visit.

Events from the Android lifecycle, not just buttons

Two in-process patterns on the Android phone, so no single screen has to remember the app-wide events. In the analytics process, a navigation listener logs a view whenever the bottom tab changes, a lifecycle observer reports a person's second session from at most one caller behind a lock, and screens log taps and outcomes named in the catalogue; background work logs jobs and notifications with the actor set to the app. All feed the one analytics logger — allowlist and actor, the version a constant, the phone model read once — which hands events to the analytics SDK to queue and upload when the phone allows. A note records that the logger is now cheaper than the tap it records: it once made two uncached system calls per event, one is gone and one is remembered. In the in-app event bus, a background job saves a verdict, the bus publishes a refresh notice, and whichever screens show that data refresh; the analytics logger does not subscribe, because a refresh notice is not something a person did. A red box marks what is not covered: the phone decides whether an event uploads, and aggressive battery management on some phones drops first-session events.

A screen that forgets to log is silent rather than wrong, so we put the events that describe the app as a whole on Android's lifecycle instead of on individual screens. A listener on the bottom navigation logs a view whenever the destination changes, so tab views don't depend on each tab remembering to log. A process-wide lifecycle observer detects a person's second session, and a lock ensures that at most one caller ever reports it, however many screens start at once.

Inside the app, the same pattern does a different job. An in-process event bus tells screens when data has changed, so the code that saves a scam verdict doesn't need to know which screens show it. Analytics doesn't subscribe to that bus, because a refresh notice is a different fact from something a person did.

Instrumentation also has to cost less than the thing it measures. In September we looked into an "application not responding" report whose main thread was stuck waiting on the system. The stack had no frame of ours, and the fix doesn't claim to have proven the cause. But it found that our logger made two uncached calls into the system on every event, and the logger is reached from about 250 tap and scroll listeners across 58 files, so every tap on the home screen paid for two round trips on the main thread. One call is gone, because the app's version is a compile-time constant, and the other result is now remembered for the life of the process.

The limit is that a phone decides whether an event ever uploads. On some phones with aggressive battery management, first-session events never arrive, which is why the next sections count outcomes from the server wherever they can.

From raw export to facts: rebuild, never patch

The daily job that rebuilds recent days in dependency order, and how late data is handled. Inputs: the streaming export (today, within minutes, provisional), the daily export (yesterday, complete, replaces streaming data) and backend accounts (sign-ups our servers confirmed). Six stages run in order: identity (install and account identifiers joined), users (one row per person or install), events (one row per event, unknown fields kept), installs (one per device per IST install date), journeys and steps (onboarding paths and funnel steps) and the event catalogue (new events found, descriptions drafted). Every write deletes the window and inserts it again in one transaction, so a rerun cannot double-count (I5); sideloaded test phones are dropped, unreleased builds kept out and community-session installs marked as ours. Outputs: the modelled tables the dashboard reads, a run log of which export fed each date, and a dashboard that marks provisional dates and shows data not fresh yet for a stale funnel (I6). Late, never missing: until the daily export lands, a date is built from streaming data and marked provisional; dates are bucketed in IST while the export is sharded by UTC date, so scans read one extra day of shards.

A daily job turns the raw export into the tables the dashboard reads. It runs its stages in dependency order, starting with identity, then users, events and installs, then onboarding journeys and step tables, and finally the event catalogue. Each stage rebuilds a trailing window of recent days rather than adding to yesterday's numbers.

-- Simplified for this post. Real tables and windows differ.
BEGIN TRANSACTION;
DELETE FROM daily_facts WHERE event_date >= @window_start;
INSERT INTO daily_facts
SELECT * FROM rebuilt_facts WHERE event_date >= @window_start;
COMMIT TRANSACTION;

Because every write deletes and reinserts its window inside one transaction, running the job twice gives the same answer as running it once (I5), and a failed write leaves its table as it was. A run log records which export fed each date. Our pipeline notes describe the export as "late, never missing", so a recent date is built from the streaming table until the daily export lands, the daily export always wins, and the dashboard marks a provisional date until then (I6).

Sideloaded test phones are dropped when events are extracted, builds that never shipped are kept out of organic installs, and installs made during our community sessions are attributed to us rather than to a campaign.

The schema changes with every release, so the pipeline keeps any field it doesn't recognise as raw JSON instead of dropping it, and only ever adds columns. Every night it also rediscovers the events in the export and adds new ones to an event dictionary. A language model drafts a description for each new event, a person can confirm or rewrite it, and the pipeline never overwrites a description a person has confirmed.

Installs, people and one definition

Installs are devices, sign-ups are people, and every app version belongs to one funnel definition. Identity: before sign-in an event carries the install identifier from the analytics SDK; at sign-in the account is created or found; after sign-in it carries the install identifier plus the account identifier, and the model joins the two so a journey that starts anonymously reaches the person who finishes it. Counting: an install is one row per device per IST install date, one definition everywhere; a sign-up is counted from backend accounts because some phone events never upload (I7); a reinstall with an existing account is left out of the funnel so it is not read as drop-off. Steps are counted independently and are not forced to shrink, so a step some people skip does not create drop-off out of nothing. Funnel definitions by app version: along a timeline from oldest to newest versions, definition 1 owns the oldest range, definition 2 the range after an onboarding change, and definition 3 a new flow; every version belongs to exactly one definition, so a new flow never distorts an old funnel.

The hardest bugs in product analytics are definitions that disagree. In August a commit replaced the install counts on several screens, which "had silently drifted apart", with one definition used everywhere, one row per device per IST install date.

Installs are counted per device and sign-ups per person, because they answer different questions. Before sign-in an event carries an identifier for the app install, and after it an account identifier as well, so the model can join a journey that starts anonymously to the person who finishes it. Someone reinstalling with an existing account is left out of the onboarding funnel, since their reinstall would otherwise read as drop-off that never happened.

Where the server can confirm an outcome, the server is the source (I7). In July we investigated registered users with no analytics events at all and traced them to first-session events that never uploaded, concentrated on phones with aggressive battery management. The registered step of the funnel is now counted from accounts on our backend.

Onboarding changes from release to release, so each funnel definition owns a range of app versions, and every version is meant to belong to exactly one definition. A new flow gets a new funnel instead of distorting the old one. Steps are counted independently and aren't forced to shrink from one to the next, so a step that some people skip doesn't create drop-off out of nothing.

From a number to a decision

From a dashboard signal to a change that stays or goes. A dashboard signal (a step, a screen or a cohort moves) raises a question — which builds, which step, which people — with a warning to watch for self-selection: a path people chose can look better because they were more likely to finish anyway, and only a comparison we caused can separate the two. A change is either a release (a change to the app ships in a new build), compared by a version split across the funnel that owns those versions, or a campaign (a notification goes to an audience), compared against a hashed holdout, a deterministic slice that gets nothing. A decision diamond asks whether it was better than the comparison: yes keeps the change, no reverts or rethinks it and goes back to the question.

The dashboard has pages for onboarding, screens, retention, campaigns and the event dictionary. Every query runs under a cap on the bytes it may scan and behind a short cache, and a funnel whose data isn't fresh shows "data not fresh yet" instead of a number.

Splitting the funnel by app version has caught real problems. In two builds, sign-in waited on checkboxes, so to the person tapping it the button looked dead. A commit in the dashboard repository records why the gate was removed, "users used to tap a dead button and uninstall", and sign-up rates split by build are how we check that later builds stayed fixed.

Some decisions changed what we measure rather than the app. In September we added weekly seven-day retention because "the early churn that moves most was invisible" in monthly cohorts. Our notification campaigns also hold back a deterministic slice of their audience, chosen by hashing an identifier, so we compare people who got a message with people who didn't instead of reading a funnel that would have moved anyway.

And some data argued against a change. One onboarding path showed a better result than another, but our analysis judged it "most plausibly self-selection", because the people who chose that path were already more likely to finish. We didn't reorder onboarding on the strength of it.

Text-to-SQL with guardrails: plain-English questions on a guarded query runner

Code decides whether a model-written query runs. Pin the question down: a question in plain English from someone who does not write SQL; the event catalogue finds the event and never guesses a name; three questions — what, when and who — each with a recommended answer, and nothing is counted until the person replies. Guard, text checks: the model writes one query for the confirmed question and the guard allows one read-only statement over allow-listed tables written in full, with no schema introspection, no personal columns and no whole rows, identifiers counted but never returned, and a literal date filter on events; a refused query goes back to the model to rewrite. A dashed line marks that only a query that passed the guard reaches the warehouse. Warehouse: a dry run reports the tables it resolved, the result columns and the bytes it would scan; they are checked again and the query is refused for an off-limits table, an identifying column or too many bytes; then it runs under a hard cap on bytes billed and rows, against the modelled tables the dashboard reads. Answer: an aggregate answer with the number, what was counted, the dates, who was included and the SQL it ran; or people mode, a separate mode switched on deliberately, which writes a short fixed list of columns to a file, not the chat.

A dashboard answers the questions someone built a page for, and a product review keeps raising new ones, often from people who don't write SQL. Since September, our ask agent has taken those questions in plain English, and a language model writes the warehouse query. This is text-to-SQL, and the person asking usually cannot check the query, so nothing that matters rests on the model getting it right.

The agent first pins the question down. It finds the event in the catalogue instead of guessing a name, then asks three short questions, each with a recommended answer, about what is counted, over which dates and for whom. Nothing is counted until the person replies, and every answer states what it counted, the exact dates, who was included and the SQL it ran.

The agent reaches the warehouse through a runner that checks every query with a guard first. The guard allows one read-only statement over an allow-list of modelled tables written out in full, with no schema introspection. It refuses columns that identify a person and whole rows, and per-person identifiers can be counted but never returned, so the answer is an aggregate. A query on the events table needs a literal date filter, so no question scans all of history by accident. A refused query goes back to the model to rewrite.

A check that reads SQL as text can be fooled by an unusual spelling, so a dry run follows. It reports which tables the query really reads and which columns it returns, and both lists are checked again before the query runs under hard caps on bytes billed and rows returned. Questions about who did something, rather than how many, need a separate mode that must be switched on, and it writes a short, fixed list of columns, never contact details, to a file instead of the conversation.

Design considerations

Pillar How it shows up
Privacy Written rules on what each event group may never hold, an allowlist on the fraud-report flow, the phone's model as the only device detail, and a guard on every query the ask agent writes
Correctness One install definition, outcomes counted from the server, funnels tied to app versions, and test traffic kept out
Reliability Idempotent daily rebuilds, provisional days until the complete export lands, and a run log for every date
Performance Per-event details computed once per process instead of on every tap
Cost Byte caps on every dashboard and ask agent query, and a short cache on the dashboard

Lessons learned

  1. Treat events as an API. A catalogue and a contract test cost little compared with a year of dashboards built on names that quietly changed.
  2. Record who caused an event, or background work and notifications will look like engagement.
  3. Rebuild facts from the raw log instead of patching them, so recomputing is always safe and a bad day can simply be run again.
  4. Count outcomes where they can be confirmed. A phone's event is evidence of what someone tried, and the server knows what happened.
  5. Agree on definitions before arguing about numbers. Most of our analytics bugs were two correct queries answering different questions.
  6. Enforce the rules for model-written queries in code and in the warehouse, where the model's own mistakes cannot get past them.

Conclusion

Collecting events was the easy part. The work that made them useful was deciding what each one means, keeping that meaning stable across releases and being honest about which numbers can't be trusted yet. In an upcoming post we'll cover how we measure changes to the default SMS app step. If you want the other half of the system, the one that decides whether a message is a scam, start with how EnfoldAI spots a scam message.

If you're holding the phone

EnfoldAI's usage analytics record which screens you open and which buttons you tap, along with the app version, your phone's model and, once you've signed in, your account identifier, so we can see where setup gets stuck and fix it. They never include your messages, the numbers that text you or your contacts. If you skipped the step where Android asks you to choose a default SMS app, you can make EnfoldAI the default later from your phone's settings. Our privacy policy covers how we handle the data the app does collect.

What we leave out

We're happy to explain how our defences work, but not how to get around them. That's why this post leaves out thresholds and caps, the exact conditions that trigger each check, the names of our events, tables and columns, and hosting details. None of our protection depends on keeping the design secret, but there's no reason to hand out a tuning manual either.

Sources

Acknowledgments

This system is the work of EnfoldAI's Android, data and growth teams. We especially thank our founding engineers, Dhruv Kalra and Boya Veera Pavan.

AnalyticsAndroidData engineeringProduct