Frontend Observability (the field side)
When to Use
Use this skill when you need a portable, framework-agnostic field-side observability system for any React or React Native app. Establishes one typed event taxonomy (canonical event-name constants, never inline strings), a best-effort non-blocking provider fan-out so a failing or absent analytics provider can never...
Portable skill — readable by Claude Code, OpenCode, Codex, Cursor, Windsurf, and others. This skill describes a field-side observability system — event taxonomy, provider fan-out, real-user vitals, error reporting, consent — not a dashboard or a specific vendor. It is the field complement to the frontend-lighthouse skill: Lighthouse is the lab gate (synthetic, pre-merge); this is the field (what real users actually experience). It lives in a
services/analytics/module per the frontend-architecture skill.
The goal: you can answer "what are real users doing, and what are they experiencing?" — with a
typed event vocabulary (no stringly-typed track("clicked_thing") scattered everywhere), a
fan-out that is best-effort (a broken provider never breaks the app), real Core Web Vitals
from the field, and consent respected before anything fires.
0. The five core ideas
- Events are a typed vocabulary. Event names are canonical constants with a union type — never inline string literals. The taxonomy is reviewable in one file and the compiler rejects typos.
- Fan-out is best-effort and non-blocking.
track()dispatches to every provider, each in its own try/catch. A missing global, a thrown provider, an unloaded script — none can throw into the caller or stop the other providers. - One entry point, SSR-safe. A single
track(event, props)is the only way to record. It's reached through a context hook that no-ops outside a provider and on the server, so instrumented components render safely anywhere. - Field vitals complement lab budgets. Real-user LCP/INP/CLS are reported to the same fan-out. Lighthouse proves the build can be fast; field vitals prove it is — together they close the loop.
- Consent gates everything. No telemetry (events, vitals, error reports with PII) fires before opt-in. Consent state is checked at the fan-out boundary, not sprinkled through call sites.
1. Directory layout
The system is one service module plus its constants (per frontend-architecture).
src/
├── constants/
│ └── analytics.ts ← canonical event names + AnalyticsEvent union
├── services/analytics/
│ ├── index.ts ← barrel: track, adapters, types
│ ├── track.ts ← the best-effort fan-out (single entry point)
│ ├── adapters.ts ← one (event, props) => void per provider, window-guarded
│ ├── web-vitals.ts ← report real-user LCP/INP/CLS into track()
│ └── consent.ts ← consent gate read by the fan-out
├── providers/
│ └── AnalyticsProvider.tsx ← 'use client' context exposing useAnalytics().track
└── error/
└── ErrorBoundary.tsx ← reports caught render errors via the fan-out
2. The event taxonomy (typed, never inline)
One file owns every event name. Components reference constants; the union type makes typos a compile error and the catalog a single source of truth.
// constants/analytics.ts
export const ANALYTICS_EVENTS = {
PROJECT_CLICK: "project_click",
GITHUB_CLICK: "github_click",
RESUME_DOWNLOAD: "resume_download",
CONTACT_SUBMISSION: "contact_submission",
} as const;
export type AnalyticsEvent =
(typeof ANALYTICS_EVENTS)[keyof typeof ANALYTICS_EVENTS];
// CORRECT — typed constant, autocompletes, can't typo
track(ANALYTICS_EVENTS.GITHUB_CLICK, { url });
// WRONG — stringly-typed, drifts, no compile check
track("github-click"); // ❌ silently a different event from "github_click"
Hard rules:
- No inline event-name strings anywhere; only
ANALYTICS_EVENTS.*. - Event names are snake_case and stable — renaming one breaks historical dashboards, so treat the catalog as a contract.
- Keep
propsshapes small and PII-light (see §6); prefer ids over names, never raw emails.
3. Best-effort, non-blocking fan-out
track() is the single entry point. It iterates the adapter registry, guarding each call so one
provider can't affect the caller or the others.
// services/analytics/track.ts
import type { AnalyticsEvent } from "@/constants/analytics";
import { analyticsAdapters } from "./adapters";
import { hasConsent } from "./consent";
export function track(
event: AnalyticsEvent,
props?: Record<string, unknown>,
): void {
if (!hasConsent()) return; // §6 — nothing fires before opt-in
for (const adapter of analyticsAdapters) {
try {
adapter(event, props);
} catch {
/* best-effort: a failing/absent provider must never throw into the
caller or block dispatch to the remaining providers. */
}
}
}
Each adapter is a tiny (event, props) => void that guards its provider global — it no-ops on
the server (no window) and when the provider script is absent, so a missing or unloaded provider
never throws.
// services/analytics/adapters.ts
export type AnalyticsAdapter = (
event: AnalyticsEvent,
props?: Record<string, unknown>,
) => void;
export const googleAnalyticsAdapter: AnalyticsAdapter = (event, props) => {
const w =
typeof window !== "undefined" ? (window as AnalyticsGlobals) : undefined;
if (!w || typeof w.gtag !== "function") return; // SSR-safe + absent-safe
w.gtag("event", event, props ?? {});
};
export const clarityAdapter: AnalyticsAdapter = (event) => {
const w =
typeof window !== "undefined" ? (window as AnalyticsGlobals) : undefined;
if (!w || typeof w.clarity !== "function") return;
w.clarity("event", event);
};
// The registry track() fans out across. Exported + mutable so tests can swap
// in a recording sink to assert dispatch.
export const analyticsAdapters: AnalyticsAdapter[] = [
googleAnalyticsAdapter,
clarityAdapter,
firebaseAdapter,
// posthogAdapter, openPanelAdapter, …
];
3.1 Firebase Analytics — one adapter, two platforms
Firebase Analytics ships two SDKs that share the same logEvent(name, params) contract, so a
single conceptual adapter covers both web and React Native — only the import and the "is it
available?" guard differ. On web the adapter never imports the SDK at module top level (it's
browser-only and async), so it stays SSR-safe.
// services/analytics/adapters.firebase.web.ts — Firebase JS SDK (web)
import type { Analytics } from "firebase/analytics";
import type { AnalyticsAdapter } from "./adapters";
// Held after a lazy, browser-only init (below) so the adapter stays synchronous + SSR-safe.
let analytics: Analytics | undefined;
export function setFirebaseAnalytics(instance: Analytics): void {
analytics = instance;
}
export const firebaseAdapter: AnalyticsAdapter = (event, props) => {
if (typeof window === "undefined" || !analytics) return; // SSR-safe + not-yet-ready safe
void import("firebase/analytics").then(({ logEvent }) =>
logEvent(analytics!, event, props),
);
};
// services/analytics/firebase.init.ts — lazy, browser-only init (web)
import { initializeApp, getApps } from "firebase/app";
import { getAnalytics, isSupported } from "firebase/analytics";
import { setFirebaseAnalytics } from "./adapters.firebase.web";
import { FIREBASE_CONFIG } from "@/constants/analytics";
export async function initFirebaseAnalytics(): Promise<void> {
if (typeof window === "undefined") return; // never on the server
if (!(await isSupported())) return; // unsupported browser → no-op
const app = getApps()[0] ?? initializeApp(FIREBASE_CONFIG);
setFirebaseAnalytics(getAnalytics(app)); // adapter goes live after this
}
// services/analytics/adapters.firebase.native.ts — @react-native-firebase/analytics (RN / Expo)
import analytics from "@react-native-firebase/analytics";
import type { AnalyticsAdapter } from "./adapters";
export const firebaseAdapter: AnalyticsAdapter = (event, props) => {
// RN: no window; the native module is present once the app boots.
void analytics().logEvent(event, props);
};
Same shape, two files. Resolve the platform variant by file extension
(adapters.firebase.native.ts via Metro's .native.ts resolution, or a Platform.OS switch) so
the registry, track fan-out, consent gate, taxonomy, and useAnalytics hook never change
across platforms. Firebase's event-name rules (snake_case, lowercase, ≤ 40 chars) line up with the
taxonomy rules in §2, so the canonical ANALYTICS_EVENTS constants are valid Firebase event names
as-is. Gate initFirebaseAnalytics() on consent (§6) — Firebase also exposes
setAnalyticsCollectionEnabled(false) to harden the opt-out.
Why this shape: analytics is the last thing that should crash an app. A vendor script that fails to load, a global that isn't there yet, an adapter that throws on a malformed prop — all are contained. The registry being exported and mutable makes dispatch unit-testable without mounting any provider.
4. The provider + hook (SSR-safe entry)
A 'use client' context exposes track through useAnalytics(). Outside a provider (tests,
server) it returns a no-op, so instrumented components never throw in isolation.
// providers/AnalyticsProvider.tsx
"use client";
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { track as trackEvent } from "@/services/analytics";
import type { AnalyticsEvent } from "@/constants/analytics";
interface AnalyticsContextValue {
track: (event: AnalyticsEvent, props?: Record<string, unknown>) => void;
}
const AnalyticsContext = createContext<AnalyticsContextValue | null>(null);
export function AnalyticsProvider({ children }: { children: ReactNode }) {
// track is module-level and stable → memoize once, never re-render consumers.
const value = useMemo<AnalyticsContextValue>(
() => ({ track: trackEvent }),
[],
);
return (
<AnalyticsContext.Provider value={value}>
{children}
</AnalyticsContext.Provider>
);
}
const NOOP: AnalyticsContextValue = { track: () => undefined };
export function useAnalytics(): AnalyticsContextValue {
return useContext(AnalyticsContext) ?? NOOP; // safe outside a provider / on server
}
// a tracked leaf — Server Components can't use the hook, so wrap in a thin client component
"use client";
export function TrackedGithubLink({ href, children }: Props) {
const { track } = useAnalytics();
return (
<a
href={href}
onClick={() => track(ANALYTICS_EVENTS.GITHUB_CLICK, { url: href })}
>
{children}
</a>
);
}
The provider does no work during render — track is stable and adapters guard their own
window access — so it's safe to mount at the root, including in SSR/RSC trees.
5. Real-user Core Web Vitals (the lab/field loop)
Report field vitals through the same fan-out. This is the complement to the lighthouse skill: the lab gate sets the budget; the field tells you whether real users hit it.
// services/analytics/web-vitals.ts
import { onLCP, onINP, onCLS, onFCP, onTTFB, type Metric } from "web-vitals";
import { track } from "./track";
export function reportWebVitals(): void {
const send = (m: Metric) =>
track("web_vital" as AnalyticsEvent, {
name: m.name, // LCP | INP | CLS | FCP | TTFB
value: Math.round(m.name === "CLS" ? m.value * 1000 : m.value),
rating: m.rating, // good | needs-improvement | poor
id: m.id,
});
onLCP(send);
onINP(send);
onCLS(send);
onFCP(send);
onTTFB(send);
}
- Call
reportWebVitals()once on the client (e.g. in the analytics provider's effect, or Next.jsuseReportWebVitals). - Use the same metrics and thresholds as the lighthouse skill (LCP ≤ 2500, INP ≤ 200, CLS ≤ 0.1) so lab and field speak the same language.
- Lab budget green + field "poor" = a gap between your test conditions and real devices/networks — exactly what field RUM exists to reveal.
6. Consent and privacy gating
Telemetry fires only after opt-in, checked once at the fan-out boundary (§3) — not duplicated at every call site.
// services/analytics/consent.ts
let granted = false; // hydrate from a stored consent cookie/localStorage on init
export function setConsent(value: boolean): void {
granted = value;
}
export function hasConsent(): boolean {
return granted;
}
Hard rules:
track()early-returns when consent is absent — no events, no vitals, no error PII before opt-in.- Keep
propsPII-light: ids and enums, not emails/names/free text. Treat anything user-entered as sensitive. - Respect "Do Not Track" / regional regimes (GDPR/CCPA) by defaulting consent to
falsewhere required. - Error reports must scrub PII before leaving the device.
7. Error reporting at boundaries
Caught render errors and unhandled rejections go through the same fan-out (or a dedicated Sentry adapter), at deliberate boundaries — not a global swallow.
// error/ErrorBoundary.tsx (essence)
componentDidCatch(error: Error, info: ErrorInfo) {
track("client_error" as AnalyticsEvent, {
message: error.message, component: info.componentStack?.split("\n")[1]?.trim(),
}); // or sentryAdapter(error, info)
}
- Place boundaries at route/segment level (per the frontend-architecture page-directory model), so a crash degrades one surface, not the app.
- Pair with the data layer's typed
ApiError(frontend-data-contracts §6): report unexpected errors; expected ones (validation, 404) are handled, not reported as crashes.
8. Provider & framework adapters
The taxonomy + fan-out are constant; each provider is one window-guarded adapter.
| Provider | Adapter call |
|---|---|
| Firebase (web) | logEvent(analytics, name, props) (firebase/analytics, lazy browser init) |
| Firebase (RN/Expo) | analytics().logEvent(name, props) (@react-native-firebase/analytics) |
| GA4 | window.gtag("event", name, props) |
| Microsoft Clarity | window.clarity("event", name) |
| PostHog | window.posthog?.capture(name, props) |
| OpenPanel | op("track", name, props) or op.track(name, props) |
| Sentry | Sentry.captureException(error) (error adapter) |
| Framework | Wiring |
|---|---|
| Next.js | AnalyticsProvider in the root layout (client boundary); call initFirebaseAnalytics() in a client effect; vitals via useReportWebVitals. |
| React + Vite / Remix | provider at app root; initFirebaseAnalytics() + reportWebVitals() in a top-level effect. |
| Expo / React Native | swap the web-vitals source for RN performance APIs and the web provider scripts for native SDKs (@react-native-firebase/analytics, Amplitude, PostHog-RN); the taxonomy, track fan-out, consent gate, and useAnalytics hook are unchanged. The Firebase adapter is the same shape — it guards the native module instead of window (see §3.1). |
9. Conventions checklist (enforce in review)
- Event names are canonical constants with a union type — zero inline event strings.
-
track()is the single entry point; reached viauseAnalytics()(no-op outside a provider/SSR). - Every adapter guards its provider global and no-ops when absent or on the server.
- Each adapter call is individually try/caught — one provider can't break the app or the others.
- Consent is checked once at the fan-out; nothing fires before opt-in.
-
propsare PII-light (ids/enums, not emails/names); error reports scrub PII. - Real-user Web Vitals report through the same fan-out, using the lighthouse skill's metrics/thresholds.
- Error boundaries are placed per route/segment and report unexpected errors only.
- The provider does no render-time work; the context value is memoized/stable.
- Mutating the adapter registry (tests) is the dispatch-observation seam — no real provider mounted in tests.
10. How to apply this skill
Adding analytics to a project: create constants/analytics.ts (taxonomy), services/analytics/
(track + adapters + consent), and AnalyticsProvider. Mount the provider at the root; wrap tracked
leaves in thin client components.
Adding an event: add a constant to ANALYTICS_EVENTS, then track(ANALYTICS_EVENTS.NEW_ONE, props)
at the interaction. Never inline the string.
Wiring Firebase Analytics (web + RN): add a firebaseAdapter to the registry using the
platform-resolved files in §3.1 (firebase/analytics on web behind a lazy browser-only
initFirebaseAnalytics(); @react-native-firebase/analytics on native). Gate init on consent. The
taxonomy and fan-out are untouched — Firebase is just one more entry in analyticsAdapters.
Closing the lab/field loop: wire reportWebVitals() and compare field ratings against the
lighthouse skill's budgets; investigate any "lab green / field poor" gap.
Reviewing observability: run the checklist in §9. The highest-value catches are inline event strings (taxonomy drift), an un-guarded adapter (a provider that can crash the app), and telemetry firing before consent.
Publishing / installing this skill
This skill follows the Anthropic SKILL.md format and is portable across agents.
- Keep it under
skills/frontend-observability/SKILL.mdin a public GitHub repo. - Keep the frontmatter
nameand high-signaldescription— discovery indexes match against it. - Install with:
npx skills add <org>/<repo> --skill "frontend-observability". - Non-
SKILL.mdagents can be pointed here fromAGENTS.md/CLAUDE.md; Kiro can mirror it as a steering file.
Limitations
- Use this skill only when the task clearly matches its upstream source and local project context.
- Verify commands, generated code, dependencies, credentials, and external service behavior before applying changes.
- Do not treat examples as a substitute for environment-specific tests, security review, or user approval for destructive or costly actions.