Skill v1.0.1
currentAutomated scan100/100+4 new
name: frontend-observability description: "A portable, framework-agnostic field-side observability pattern for React or React Native integrations." risk: critical source: https://github.com/stareezy-1/frontend-architecture-skill/tree/main/skills/frontend-observability source_repo: stareezy-1/frontend-architecture-skill source_type: community date_added: 2026-07-01 license: MIT license_source: https://github.com/stareezy-1/frontend-architecture-skill/blob/main/LICENSE metadata: version: "1.0.1"
Frontend Observability (the field side)
When to Use
Use this skill when you need a portable, framework-agnostic field-side observability pattern for React or React Native integrations. Defines a typed taxonomy and best-effort provider dispatch with explicit consent, privacy and runtime limitations.
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 thefield 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 aservices/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 (provider exceptions are isolated where supported), 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, not a scheduling guarantee.
track()dispatches to every provider, each with synchronous and asynchronous error handling. A missing global, a thrown provider, an unloaded script — ordinary caught exceptions/rejections do not propagate; synchronous vendor work can still block the thread. - 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 samples controlled conditions; field vitals estimate actual experiences for the observed population — 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.
Runtime and privacy contract
These are independent integration sketches, not complete compiled modules. Supply the missing globals, config, imports, Props and error classifier; pin vendor APIs. Try/catch cannot prevent synchronous CPU blocking, infinite loops or SDK background effects. Adapters must do bounded work, return their promises, avoid raw logging and share per-provider rate/queue limits. Use actual keyboard/SSR/unmount/rejection tests.
Allowlist property names, types and sizes before dispatch; TypeScript types do not validate runtime input. IDs can identify people; URLs may contain tokens or query PII. Send a route/category label rather than a raw href, error message or stack. Treat cross-vendor fan-out as separate data disclosures requiring the approved purposes and destinations.
A boolean track gate does not stop SDK auto-collection, cookies, session replay or queued sends. Configure collection disabled before native SDK startup, delay scripts/init until permitted, disable and clear provider state/queues on withdrawal, and recheck consent after every awaited initialization. A dynamic import may still execute module effects; choose SDK loading that satisfies actual requirements. Consent storage failures default to denied. Do not hydrate a module-global consent state from user requests on SSR. React Server Components cannot call this client hook; conventional SSR only uses the no-op outside the provider. Native vitals are not web Core Web Vitals, and React Native Firebase requires a compatible native build rather than arbitrary Expo Go.
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.tsexport const ANALYTICS_EVENTS = {PROJECT_CLICK: "project_click",GITHUB_CLICK: "github_click",RESUME_DOWNLOAD: "resume_download",CONTACT_SUBMISSION: "contact_submission",WEB_VITAL: "web_vital",CLIENT_ERROR: "client_error",} as const;export type AnalyticsEvent =(typeof ANALYTICS_EVENTS)[keyof typeof ANALYTICS_EVENTS];
// CORRECT — typed constant, autocompletes, can't typotrack(ANALYTICS_EVENTS.GITHUB_CLICK, { url });// WRONG — stringly-typed, drifts, no compile checktrack("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 fan-out with bounded adapter work
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.tsimport 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-infor (const adapter of analyticsAdapters) {try {void Promise.resolve(adapter(event, props)).catch(() => {// Drop provider failures; never recurse into analytics or log raw data.});} catch {/* best-effort: a failing/absent provider must never throw into thecaller 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.tsimport type { AnalyticsEvent } from "@/constants/analytics";// Supply AnalyticsGlobals type declarations and a platform-specific firebaseAdapter import.export type AnalyticsAdapter = (event: AnalyticsEvent,props?: Record<string, unknown>,) => void | Promise<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-safew.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 { hasConsent } from "./consent";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 safereturn import("firebase/analytics").then(({ logEvent }) => {if (!hasConsent() || !analytics) return; // recheck after async worklogEvent(analytics, event, props);});};
// services/analytics/firebase.init.ts — lazy, browser-only init (web)import { initializeApp, getApps } from "firebase/app";import { hasConsent } from "./consent";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 serverif (!hasConsent()) return;const { getAnalytics, isSupported } = await import("firebase/analytics");if (!(await isSupported()) || !hasConsent()) return; // unsupported browser → no-opconst 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.return 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. Verify Firebase event-name/parameter restrictions and reserved names for the pinned SDK. Snake_case is this taxonomy convention, not the full provider validation contract. 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 }) {// Stable context value avoids context-identity updates; other renders still occur.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 (<ahref={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 — but still verify the framework-specific client boundary and actual initialization effects.
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.tsimport { onLCP, onINP, onCLS, onFCP, onTTFB, type Metric } from "web-vitals";import { ANALYTICS_EVENTS } from "@/constants/analytics";import { track } from "./track";export function reportWebVitals(): void {const send = (m: Metric) =>track(ANALYTICS_EVENTS.WEB_VITAL, {name: m.name, // LCP | INP | CLS | FCP | TTFBvalue: m.value, // native units: CLS dimensionless; others millisecondsrating: m.rating, // good | needs-improvement | poorid: 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). - For field Core Web Vitals, assess LCP ≤2500ms, INP ≤200ms and CLS ≤0.1 at the relevant 75th percentile; do not compare a CLS×1000 value to0.1 or treat a single observation as the population. Lighthouse does not directly measure real-user INP.
- 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.tslet granted = false; // hydrate from a stored consent cookie/localStorage on initexport 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(ANALYTICS_EVENTS.CLIENT_ERROR, {error_code: classifyKnownError(error), // allowlisted code, not raw error messageboundary: "route", // bounded label, not a raw component stack}); // 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 needs a pinned-version adapter and its own initialization, opt-out and payload controls.
| 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, with native units and a declared field population.
- [ ] 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.