falcon-event-tracker
v0.5.0
Published
Shared BigQuery event write/read for Avada Falcon apps — see _docs/product-analytics/ADR.md
Readme
falcon-event-tracker
Shared BigQuery event write/read for the 6 Avada Falcon apps. Design + trade-offs:
_docs/product-analytics/ADR.md in the product-analytics repo.
Published on the public npm registry as
falcon-event-tracker, not@avada/event-trackeras the ADR originally planned — publishing to the internalregistry.avada.iounder the@avadascope needs Tech Lead-granted publish permission (ADR §7 point 3) that wasn't available yet. Functionally identical; only the install name differs. Re-publish under@avada/event-trackeronce that permission lands, and update every consuming app'spackage.json+ imports.
Event types
Every feature is described by attributes (menu, card, group, scope, payload), not by
inventing a new event type — adding a 23rd feature to an app means adding a constant, not a
schema change. There are exactly 10, defined in src/schema.ts:
| Event | Fires when |
| --------------------- | ---------------------------------------------------------------------------- |
| app_opened | The app is opened (once per session/visit). |
| menu_viewed | A menu landing page is viewed (no card). |
| feature_opened | A specific feature/card screen is viewed. |
| card_clicked | A feature card is clicked from a landing/grid page. |
| feature_started | A merchant-initiated action begins (button click) — never for cron/auto-run. |
| feature_completed | The action finished successfully — only fire when genuinely certain. |
| feature_applied | A save/publish/toggle-style action the merchant took (not started→completed).|
| feature_failed | The action failed — always include payload.error_reason. |
| cross_sell_clicked | A card links out to another app's App Store listing, not an in-app feature. |
| screen_left | The merchant navigates away from a screen (derived, not user-initiated). |
Install
pnpm add falcon-event-trackerEnv vars — just 1
GOOGLE_CLOUD_CREDENTIALS_JSON=<base64 of the service account JSON key>Base64-encoded, not raw JSON — base64 -w0 key.json (macOS: base64 -i key.json | tr -d '\n').
Raw JSON breaks when piped through shell echo into a deployed app's .env file (unescaped
quotes/newlines); base64 is always shell-safe. The package decodes it internally.
That's the only thing an installing app configures — the shared service account
(same value across all 6 apps, ADR §7). Project/dataset/table are hardcoded in
src/config.ts (plaza-staging-3 / product_analytics / events) since every
app writes to the exact same table; there's nothing per-app to set. Missing the
credential disables tracking silently — trackEvent() becomes a no-op, it never
throws and never blocks the caller.
Usage — Koa apps (SEO Suite and the rest of Falcon), 1-line setup
// app.js — right after your session/auth middleware, before routes
import { setupEventTracker } from "falcon-event-tracker/koa";
setupEventTracker(app, { appId: "seo-suite", routeMap: SEO_ROUTE_MAP });This mounts two things:
POST /api/track-event— ready-made route for frontend-observable events (feature_started,menu_viewed,card_clicked,screen_left...). Your frontend POSTs here (same origin, uses your app's existing session — never talks to BigQuery or this package directly). Requires a body parser already mounted upstream (koa-bodyparseror equivalent) — this route doesn't parse the body itself.shopIdis read fromctx.state.user.shopIDby default (the@avada/coresession convention); passgetShopIdto override.Auto-tracking middleware — fires
feature_completed/feature_failedfor every request matching a route inSEO_ROUTE_MAP(same shape as SEO Suite's existingconfig/activityTracking.js, ADR §3):const SEO_ROUTE_MAP = { "POST /rule": { menu: "search-optimization", card: "meta-tags" }, "POST /optimize/image": { menu: "performance", card: "image-compression" }, // ... };Need extra payload (
credits_used,duration_ms,item_count...) on an auto-tracked event? Set it in the handler, no extra import needed:ctx.state.eventPayload = { credits_used: 2, item_count: 500 };
Omit routeMap to mount only the ingest route (e.g. if you'd rather call
trackEvent() by hand everywhere). appId defaults to process.env.APP_ID if
omitted.
Usage — frontend (browser)
trackEvent/trackJobOutcome above are server-only (they insert into BigQuery directly — never
ship a service-account credential to the browser). The frontend instead POSTs to your app's own
/track-event route (mounted by setupEventTracker, see above). falcon-event-tracker/browser
is a tiny, zero-dependency helper for that POST — write it once per app, not by hand:
import { createTrackEvent } from "falcon-event-tracker/browser";
import { fetchAuthenticatedApi } from "./yourAppFetchWrapper"; // (path, {method, body}) => Promise
export const trackEvent = createTrackEvent({ fetcher: fetchAuthenticatedApi });
trackEvent("feature_started", { menu: "ai-content", card: "meta-title", scope: "single" });Most call sites fire several events (started/completed/failed) for the same
{menu, card, scope} — createFeatureTracker locks that base in once so you don't repeat it:
import { createFeatureTracker } from "falcon-event-tracker/browser";
const trackFix = createFeatureTracker(trackEvent, {
menu: "seo-audit",
card: "onpage",
scope: "single",
element: "ai_fix_issue",
});
trackFix("feature_started", { credits_used: 1, credit_balance: 4 });
trackFix("feature_completed", { credits_used: 1 });Only reach for createFeatureTracker when 2 or more trackEvent calls in the same function
genuinely share the same base — it's a dedup helper, not a mandatory wrapper. A single call, or
calls whose menu/card differ per iteration (e.g. a loop over rule types), stay as plain
trackEvent(...) calls; forcing the abstraction there adds indirection for nothing.
React apps — useScreenTracker fires app_opened once, then menu_viewed/feature_opened/
screen_left on every route change, from a resolver you own (pathname → {menu, card, group}).
Router-agnostic — pass whatever pathname your router gives you. The resolver is just a lookup
table keyed by URL segment, one entry per menu × card:
function resolveScreen(pathname) {
const [menu, card] = pathname.split("/").filter(Boolean);
if (!menu) return null;
return { menu, card }; // card omitted → fires menu_viewed instead of feature_opened
}import { useScreenTracker } from "falcon-event-tracker/react";
import { useLocation } from "react-router-dom";
function ScreenTracker() {
const location = useLocation();
useScreenTracker({ pathname: location.pathname, resolveScreen, trackEvent });
return null;
}Mount <ScreenTracker /> once at the root of your routes — it covers app_opened,
menu_viewed, feature_opened, and screen_left for every screen with zero per-page wiring.
Usage — background jobs (Pub/Sub, cron, queue workers)
A job that keeps running after the HTTP request that triggered it has ended
(bulk generation, sitemap build, any fan-out worker) is the only thing that
knows how it actually turned out — the frontend that started it is long gone.
trackJobOutcome turns a plain success boolean into the right event, so every
worker across every Falcon app reports outcomes the same way:
import { trackJobOutcome } from "falcon-event-tracker";
// at the end of a Pub/Sub subscriber, once the job is fully done
await trackJobOutcome({
appId: "seo-suite",
success: true, // → feature_completed; false → feature_failed
shopId,
plan: shop.plan,
menu: "ai-content",
card: "meta-title",
scope: "bulk",
payload: { item_count: resources.length },
});Usage — any runtime (non-Koa, or manual calls)
Backend, after knowing the outcome (feature_completed/feature_failed — fire
at the END of the handler, once, not awaited on the response path):
import { trackEvent } from "falcon-event-tracker";
trackEvent({
appId: "seo-suite",
eventType: "feature_completed",
shopId: ctx.state.user.shopID, // from session — never from request body
menu: "ai-content",
card: "meta-title",
scope: "single",
payload: { credits_used: 2, duration_ms: 840 },
}).catch(() => {}); // already never throws, .catch is defence in depth onlyShopify shop/redact webhook:
import { deleteShopEvents } from "falcon-event-tracker";
await deleteShopEvents("seo-suite", shopId);CS shop lookup:
import { queryShopEvents } from "falcon-event-tracker";
const rows = await queryShopEvents({ appId: "seo-suite", shopId, limit: 50 });Tracking principles (read before instrumenting a new feature)
- No button, no
feature_started. A cron job, auto-scan, or system-triggered action never gets astartedevent — there was no merchant click to start. Report its outcome withtrackJobOutcomeinstead (see above). - Only fire
feature_completedwhen genuinely certain. If the frontend can't reliably know the outcome (network drop, tab closed mid-request), don't guess — let the backend confirm it instead (a route-map middleware watching the real HTTP response, or a background job emitting its own outcome). - Bulk operations are ONE event with
payload.item_count, never N events per item. A "fix 12 issues" button fires a singlefeature_started/feature_completedpair withitem_count: 12, not 12 pairs. - Cross-app promotional cards use
cross_sell_clicked, not a feature event. A card that links out to another app's App Store listing isn't "a feature nobody uses" — it's advertising, and mixing it into feature-usage numbers skews them. - Conditionally-hidden features need visibility tracking, not just usage. If a card only
shows for some shops (a flag, a plan tier, an A/B bucket), fire
feature_openedwithpayload: {visibility_check: true, visible: boolean}on mount even when the merchant never clicks anything — otherwise "0 uses" is indistinguishable from "nobody could see it." - Never log what the merchant typed. Titles, descriptions, URLs, anchor text, prompt content — log that they saved something (a count, a boolean, an enum), never what they saved.
Gotchas
shopIdmust come from your app's own authenticated session — this package trusts whatever you pass it, it has no way to verify a shop's identity itself.- Don't
JSON.stringify()payloadyourself — pass a plain object. event_typemust be one of the 10 inEVENT_TYPES(schema.ts). Adding a new one needs PO sign-off (spec §7) — it's not just a code change.- Streaming inserts can take 10-30s to become query-visible. Don't
queryShopEventsimmediately aftertrackEvent()in a test and expect to see the row. - No batching — each
trackEvent()call is one insert. Fine at the scale this spec targets (ADR §2); revisit only if a single app's write volume alone approaches millions/day.
