A portable, framework-agnostic field-side observability pattern for React or React Native integrations.
BY stareezy-1/frontend-architecture-skill contributors, via Agentic Awesome SkillsUPDATED Oct 26, 2026
INTENDED FOR✳Claude Code>_Codex↗CursorInstall in a listed agent.
instructions.md
---
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
---
# 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 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** (provider exceptions are isolated where supported), 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, 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.
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 samples controlled conditions; field vitals estimate actual experiences for the observed population — 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.
---
## 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.
```ts
// constants/analytics.ts
export 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];
```
```ts
// 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 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.
```ts
// 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 {
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 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.
```ts
// services/analytics/adapters.ts
import 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-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.
```ts
// 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 safe
return import("firebase/analytics").then(({ logEvent }) => {
if (!hasConsent() || !analytics) return; // recheck after async work
logEvent(analytics, event, props);
});
};
```
```ts
// 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 server
if (!hasConsent()) return;
const { getAnalytics, isSupported } = await import("firebase/analytics");
if (!(await isSupported()) || !hasConsent()) return; // unsupported browser → no-op
const app = getApps()[0] ?? initializeApp(FIREBASE_CONFIG);
setFirebaseAnalytics(getAnalytics(app)); // adapter goes live after this
}
```
```ts
// 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.
```tsx
// 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
}
```
```tsx
// 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 — 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.
```ts
// services/analytics/web-vitals.ts
import { 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 | TTFB
value: m.value, // native units: CLS dimensionless; others milliseconds
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`).
- 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.
```ts
// 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.
```tsx
// error/ErrorBoundary.tsx (essence)
componentDidCatch(error: Error, info: ErrorInfo) {
track(ANALYTICS_EVENTS.CLIENT_ERROR, {
error_code: classifyKnownError(error), // allowlisted code, not raw error message
boundary: "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 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, 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.
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.
---
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
---
# 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 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** (provider exceptions are isolated where supported), 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, 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.
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 samples controlled conditions; field vitals estimate actual experiences for the observed population — 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.
---
## 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.
```ts
// constants/analytics.ts
export 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];
```
```ts
// 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 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.
```ts
// 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 {
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 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.
```ts
// services/analytics/adapters.ts
import 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-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.
```ts
// 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 safe
return import("firebase/analytics").then(({ logEvent }) => {
if (!hasConsent() || !analytics) return; // recheck after async work
logEvent(analytics, event, props);
});
};
```
```ts
// 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 server
if (!hasConsent()) return;
const { getAnalytics, isSupported } = await import("firebase/analytics");
if (!(await isSupported()) || !hasConsent()) return; // unsupported browser → no-op
const app = getApps()[0] ?? initializeApp(FIREBASE_CONFIG);
setFirebaseAnalytics(getAnalytics(app)); // adapter goes live after this
}
```
```ts
// 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.
```tsx
// 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
}
```
```tsx
// 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 — 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.
```ts
// services/analytics/web-vitals.ts
import { 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 | TTFB
value: m.value, // native units: CLS dimensionless; others milliseconds
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`).
- 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.
```ts
// 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.
```tsx
// error/ErrorBoundary.tsx (essence)
componentDidCatch(error: Error, info: ErrorInfo) {
track(ANALYTICS_EVENTS.CLIENT_ERROR, {
error_code: classifyKnownError(error), // allowlisted code, not raw error message
boundary: "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 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, 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.
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.
COMPATIBILITY & REFERENCES
Platform guidance
Imported SKILL.md format for Claude Code, Codex and Cursor. Review the complete instructions and required tools before use. Package integrity checked; individual agent behavior has not been tested.
Creates comprehensive frontend UI/UX designs including user interfaces, design systems, component libraries, responsive layouts, and accessibility implementations. Produces wireframes, mockups, design specifications, and implementation guidelines. Use when designing user interfaces, creating desi...
Expert in building scalable design systems with Atomic Design, design tokens, and theming infrastructure. Use when creating component libraries, implementing dark mode, or establishing typography and color systems. Covers multi-brand support, Storybook-driven development, and accessibility-first ...
Use when implementing Liquid Glass effects, reviewing UI for Liquid Glass adoption, debugging visual artifacts, optimizing performance, or requesting expert review of Liquid Glass implementation - provides comprehensive design principles, API patterns, and troubleshooting guidance from WWDC 2025....