Skill

Instrument typed field observability for React apps

A field-side observability skill for React/React Native - typed events, best-effort fan-out, real CWV, and consent gating.

Works with reactfirebasegoogle analyticsclarity

74
Spark score
out of 100
Updated last month
Version 1.0.0

Add to Favorites

Why it matters

Establish a production-grade, framework-agnostic observability system that captures real-user events, Core Web Vitals, and errors with a typed event taxonomy, best-effort provider fan-out, and consent-first telemetry-so you can answer what users are doing and experiencing without breaking the app when analytics fail.

Outcomes

What it gets done

01

Define a typed event vocabulary with canonical constants that prevent stringly-typed tracking drift and make the taxonomy compiler-checked

02

Fan out telemetry to multiple providers (GA, Clarity, Firebase) with isolated try-catch so one broken provider never crashes the app or blocks others

03

Report real-user Core Web Vitals (LCP, INP, CLS) from the field to complement synthetic Lighthouse lab scores

04

Gate all telemetry behind consent checks at the fan-out boundary so no events, vitals, or error reports fire before user opt-in

Install

Add it to your toolbox

Run in your project directory:

curl -fsSL https://spark.entire.vc/get/ag-frontend-observability | bash

Overview

Frontend Observability (the field side)

This skill establishes field-side observability for React/React Native: a typed event taxonomy, a best-effort non-blocking provider fan-out, real-user Core Web Vitals, and consent-gated telemetry. Use it when instrumenting an app with typed analytics events, real-user vitals reporting, consent gating, or error reporting at boundaries.

What it does

A portable, framework-agnostic field-side observability skill for React/React Native, the field complement to the frontend-lighthouse skill (Lighthouse is the synthetic lab gate pre-merge; this is what real users actually experience), living in a services/analytics/ module per frontend-architecture. Five core ideas: events are a typed vocabulary - canonical ANALYTICS_EVENTS constants with a union type, never inline strings, so typos are compile errors and the catalog is one reviewable file; the fan-out is best-effort and non-blocking - a single track(event, props) entry point iterates a registry of tiny provider adapters, each wrapped in its own try/catch and window-guarded, so a missing global, thrown provider, or unloaded script can never break the caller or the other providers; one SSR-safe entry point reached via a 'use client' AnalyticsProvider and a useAnalytics() hook that no-ops outside a provider or on the server; real-user Core Web Vitals (LCP, INP, CLS, FCP, TTFB via the web-vitals library) report through the same fan-out using the identical thresholds as the lighthouse skill, so lab-green-but-field-poor reveals a gap between test conditions and real devices/networks; and consent gates everything at the fan-out boundary in one place (hasConsent() checked once inside track(), not duplicated per call site), defaulting to false where GDPR/CCPA or Do Not Track require it, with props kept PII-light (ids and enums, never raw emails or free text). Firebase Analytics gets one conceptual adapter covering both web and React Native SDKs since they share the same logEvent(name, params) contract, with the web adapter lazily and asynchronously initialized to stay SSR-safe. Error reporting reuses the same fan-out (or a dedicated Sentry adapter) at deliberate route/segment-level error boundaries rather than a global swallow, reporting only unexpected errors - expected ones like validation failures or 404s (typed ApiError from frontend-data-contracts) are handled, not reported as crashes.

When to use - and when NOT to

Use it when instrumenting a React or React Native app with typed analytics events, real-user Core Web Vitals reporting, consent-gated telemetry, or error reporting at boundaries. It describes a field-side observability system, not a dashboard or a specific analytics vendor.

Inputs and outputs

Given an event name constant and optional props, track() dispatches to every registered provider adapter (best-effort, consent-gated), and reportWebVitals()/error boundaries feed real-user vitals and unexpected errors through the same fan-out.

Integrations

export const ANALYTICS_EVENTS = { PROJECT_CLICK: "project_click", /* ... */ } as const;

Pairs with any analytics vendor via a window-guarded adapter (Google Analytics, Clarity, Firebase Analytics, PostHog, OpenPanel), the web-vitals library for field CWV, and error-reporting tools like Sentry at ErrorBoundary catch points.

Who it's for

Frontend teams who want typed, consent-respecting, crash-proof analytics instrumentation - a single event vocabulary instead of stringly-typed track() calls scattered through the codebase, and a fan-out where a broken or absent analytics provider can never take down the app.

Source README

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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 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.

// 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.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.

// 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 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.

// 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 via useAnalytics() (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.
  • props are 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.

  1. Keep it under skills/frontend-observability/SKILL.md in a public GitHub repo.
  2. Keep the frontmatter name and high-signal description - discovery indexes match against it.
  3. Install with: npx skills add <org>/<repo> --skill "frontend-observability".
  4. Non-SKILL.md agents can be pointed here from AGENTS.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.

FAQ

Common questions

Discussion

Questions & comments · 0

Sign In Sign in to leave a comment.