| name | frontend-observability |
| description | 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 throw into the app or block other providers, a single track() entry point exposed through a context hook, real-user Core Web Vitals (RUM) reporting that complements lab Lighthouse budgets, error reporting at deliberate boundaries, and consent/privacy gating so nothing fires before opt-in. Provider-agnostic (Firebase Analytics, GA4, Microsoft Clarity, PostHog, OpenPanel, Sentry, Vercel/Cloudflare analytics) and works the same on web and React Native via one adapter shape. Use this skill when adding analytics or product tracking, instrumenting user actions, reporting Web Vitals from real users, wiring Firebase Analytics on web or React Native, wiring error reporting, gating telemetry on consent, or designing an event schema. Pairs with the frontend-lighthouse skill (lab budgets โ field reality). Works with React + Vite, Next.js, Remix, and Expo / React Native. |
| license | MIT โ use, copy, and adapt freely. |
| compatibility | Works with Claude Code, OpenCode, Codex, Cursor, Windsurf, Copilot, and any tool that reads SKILL.md or is pointed here from AGENTS.md / CLAUDE.md. |
| keywords | observability, analytics, telemetry, web vitals, rum, real user monitoring, event tracking, event taxonomy, track, provider fan-out, firebase, firebase analytics, ga4, google analytics, clarity, posthog, openpanel, sentry, error reporting, consent, privacy, gdpr, LCP, INP, CLS, react, react native, expo |
Frontend Observability (the field side)
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.
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];
track(ANALYTICS_EVENTS.GITHUB_CLICK, { url });
track("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
props shapes 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.
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;
for (const adapter of analyticsAdapters) {
try {
adapter(event, props);
} catch {
}
}
}
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.
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;
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);
};
export const analyticsAdapters: AnalyticsAdapter[] = [
googleAnalyticsAdapter,
clarityAdapter,
firebaseAdapter,
];
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.
import type { Analytics } from "firebase/analytics";
import type { AnalyticsAdapter } from "./adapters";
let analytics: Analytics | undefined;
export function setFirebaseAnalytics(instance: Analytics): void {
analytics = instance;
}
export const firebaseAdapter: AnalyticsAdapter = (event, props) => {
if (typeof window === "undefined" || !analytics) return;
void import("firebase/analytics").then(({ logEvent }) =>
logEvent(analytics!, event, props),
);
};
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;
if (!(await isSupported())) return;
const app = getApps()[0] ?? initializeApp(FIREBASE_CONFIG);
setFirebaseAnalytics(getAnalytics(app));
}
import analytics from "@react-native-firebase/analytics";
import type { AnalyticsAdapter } from "./adapters";
export const firebaseAdapter: AnalyticsAdapter = (event, props) => {
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.
"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 }) {
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;
}
"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.
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,
value: Math.round(m.name === "CLS" ? m.value * 1000 : m.value),
rating: m.rating,
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.js useReportWebVitals).
- 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.
let granted = false;
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
props PII-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
false where 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.
componentDidCatch(error: Error, info: ErrorInfo) {
track("client_error" as AnalyticsEvent, {
message: error.message, component: info.componentStack?.split("\n")[1]?.trim(),
});
}
- 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)
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.md in a public GitHub repo.
- Keep the frontmatter
name and high-signal description โ discovery indexes match against it.
- Install with:
npx skills add <org>/<repo> --skill "frontend-observability".
- Non-
SKILL.md agents can be pointed here from AGENTS.md / CLAUDE.md; Kiro can mirror it as a steering file.