Skip to main content

Analytics + errors

ImtehanHub fans every tracked event out to three analytics platforms (Firebase, Amplitude, Microsoft Clarity), and routes every error to Sentry. All four providers are wrapped in a single facade so call sites don't know which platforms are configured, and all four fail-open — a missing env key is a warning, never a crash.

TL;DRtrack(name, props) from src/lib/analytics/ fans out to Firebase + Amplitude + Clarity. logger.error(err, context) from src/lib/errors/ reports to Sentry with PII scrubbed. Missing env keys = provider stays disabled, others continue.

The three analytics platforms — why three

Each tool sees a different slice of reality:

PlatformWhat it's best atWhy we use it
Firebase AnalyticsFunnel + cohort views inside the Firebase console, free, plays well with FirestoreWe're already on Firebase free tier
AmplitudeProduct analytics — retention, paths, segmentsFree tier covers a Pakistani-scale app
Microsoft ClarityAnonymised session replays + heat mapsLets us watch a session without storing PII

We deliberately do not use Google Analytics 4 (overlaps with Firebase), Mixpanel (paid quickly), or behavioural-ad networks (privacy mismatch).

The track facade

// src/lib/analytics/track.ts (sketch)
import { analytics as firebaseAnalytics } from '@/config/firebase';
import { logEvent } from 'firebase/analytics';
import * as amplitude from '@amplitude/analytics-browser';
import { clarity } from '@/lib/analytics/clarity';
import { logger } from '@/utils/logger';

export function track(name: string, props: Record<string, unknown> = {}): void {
try {
if (firebaseAnalytics) {
logEvent(firebaseAnalytics, name, props);
}
} catch (e) { logger.warn('[analytics] firebase track failed', e); }

try {
if (amplitude.getInstance().getApiKey()) {
amplitude.track(name, props);
}
} catch (e) { logger.warn('[analytics] amplitude track failed', e); }

try {
clarity?.track(name, props);
} catch (e) { logger.warn('[analytics] clarity track failed', e); }
}

Each provider is wrapped in its own try/catch so a misbehaving SDK can't poison the others. Each is gated on "is this provider configured" — no env key, no call.

Event taxonomy

Every event name uses snake_case + a verb-noun shape:

EventWhen
test_startedUser taps Start on the configure page
test_submittedAfter scoring, before result render
bookmark_added / bookmark_removedStar toggle
signin_attempted / signin_succeeded / signin_failedGoogle sign-in flow
theme_changedAny theme knob touched (with which knob in props)
community_submission_createdCommunity submission of any kind
community_vote_castUpvote/downvote
community_flag_raisedA flag is filed
referral_signup_observedInbound referral signup (from referrer's perspective)
subscription_upgrade_initiatedClick Upgrade — opens payment in new tab
page_viewRoute change (fired by use-page-view-tracking.ts)

Props are typed via TypeScript helpers:

// src/lib/analytics/event-types.ts (sketch)
type Events =
| { name: 'test_started'; props: { classId: ClassId; subjectId: string; chapterId: string; mode: 'mcq' | 'short' | 'long' } }
| { name: 'test_submitted'; props: { /* ... */ score: number; durationSec: number } }
| { name: 'theme_changed'; props: { knob: 'appearance' | 'accentColor' | /* ... */; value: string } }
// ...

export function trackTyped<E extends Events>(event: E): void {
return track(event.name, event.props);
}

Typed helpers reject calls that lie about props at compile time. The plain track(name, props) form is still available for ad-hoc one-offs but the typed form is preferred.

Microsoft Clarity — privacy mode

Clarity is wired in privacy-strict mode:

  • All form inputs are masked (<input type="text"> shows *** in replays).
  • Buttons, links, and labels are visible (you need to see what a user clicked).
  • No user identifier is passed to Clarity — replays are session-only and not cross-correlatable.

The config:

// src/lib/analytics/clarity.ts (sketch)
if (import.meta.env.VITE_CLARITY_PROJECT_ID) {
window.clarity('mask', 'input, textarea, select');
window.clarity('mask', '[data-mask]');
// No identify() call — sessions are anonymous
}

That gives us heat maps and rage-click detection without ever knowing whose session it was.

Fail-open posture

The four providers are wired so that missing env keys = silent disable, never a runtime error:

// src/config/firebase.ts (sketch)
export const analytics = import.meta.env.VITE_FIREBASE_MEASUREMENT_ID
? getAnalytics(firebaseApp)
: null;
// src/main.tsx (sketch)
if (import.meta.env.VITE_AMPLITUDE_API_KEY) {
amplitude.init(import.meta.env.VITE_AMPLITUDE_API_KEY, { /* ... */ });
}

Local development without analytics env keys "just works" — events are silently dropped. Reviewable in the logger at debug level.

Sentry — error tracking

Errors go through src/lib/errors/error-service.ts:

// src/lib/errors/error-service.ts (sketch)
import * as Sentry from '@sentry/react';
import { logger } from '@/utils/logger';

export const errorService = {
init() {
if (!import.meta.env.VITE_SENTRY_DSN) return;
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.MODE,
integrations: [Sentry.browserTracingIntegration()],
tracesSampleRate: 0.1,
beforeSend(event) {
// Scrub PII
if (event.user) {
delete event.user.email;
delete event.user.username;
}
if (event.request?.headers) {
delete event.request.headers['Authorization'];
delete event.request.headers['Cookie'];
}
return event;
},
});
},

report(err: unknown, context?: Record<string, unknown>): void {
logger.error('[error] caught', err, context);
if (import.meta.env.VITE_SENTRY_DSN) {
Sentry.captureException(err, { extra: context });
}
},
};

Every caught error in services / hooks / repositories flows through errorService.report, which:

  1. Logs via logger.error (so devs see it in the console).
  2. Captures to Sentry if configured.
  3. PII-scrubs beforeSend.

Error boundary

The root layout wraps the router in a Sentry error boundary:

<Sentry.ErrorBoundary fallback={<ServerErrorPage />} showDialog={false}>
<RouterProvider router={router} />
</Sentry.ErrorBoundary>

A render-time crash anywhere in the tree falls through to the branded /500 page (src/pages/error/ServerErrorPage.tsx) and the error is reported with the React component stack.

Banned packages reminder

Two Capacitor packages are explicitly banned:

  • @capacitor-firebase/crashlytics
  • @capacitor-firebase/performance

Both produce noisy runtime errors on Android, require Gradle plugin wiring, and force Firebase Console configuration. We use Sentry for crash reporting (already wired) and Amplitude / Clarity for perf-adjacent metrics. The CLAUDE.md root file has the verification grep:

grep -rE "@capacitor-firebase/(crashlytics|performance)" --include="package.json" .
# must return zero matches

Logger as the unified entry point

For non-error diagnostic logging, use the logger directly:

import { logger } from '@/utils/logger';

logger.debug('[test] starting submission', { uid, chapterId });
logger.info('[referral] granted pro', { uid, expiresAt });
logger.warn('[storage] write failed, falling back', err);
logger.error('[gdrive] upload rejected', err);

Logger levels default to warn in both dev and prod. Override at runtime:

window.__setLogLevel('debug'); // see everything in this tab

Or set VITE_LOG_LEVEL=debug for build-time defaults.

Performance metrics — without the banned packages

For the few real-user performance signals we want:

  • Page-view latencyuse-page-view-tracking.ts emits page_view with loadDurationMs measured against performance.now() between route start and first paint.
  • API call durations — TanStack Query hooks log queryDurationMs in the onSettled handler.
  • Long tasks — a tiny PerformanceObserver listens for longtask entries and emits long_task_observed events.

All of those flow through track() and land in Amplitude where they aggregate cleanly.

Common questions

In Pakistan, no specific law applies. Internationally, our analytics fingerprint (no advertising IDs, no cross-site tracking, no PII in events) keeps us inside GDPR's "legitimate interests" category. If we ever ship in the EU formally we'll add a banner.

What about Google Tag Manager / Facebook Pixel / ad pixels?

None present. We do not load third-party tags. The analytics SDKs ship as first-party bundled npm packages.

Why doesn't Firebase Analytics conflict with Amplitude?

They observe overlapping events but serve different teams. Firebase Analytics surfaces in the Firebase Console (next to Firestore data); Amplitude has stronger funnel + retention analysis. We pay for neither.

Will Sentry exhaust the free tier?

Free tier = 5,000 errors / month. At our user volume that's plenty. Sampling traces at 10% keeps performance-trace spend down. If we approach the cap we'll raise it via better filtering.

Next