Install
$ agentstack add skill-stareezy-1-frontend-architecture-skill-frontend-observability ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
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.
// constants/analytics.ts
export const ANALYTICS_EVENTS = {
PROJECT_CLICK: "project_click",
GITHUB_CLICK: "github_click",
RESUME_DOWNLOAD: "resume_download",
CONTACT_SUBMISSION: "contact_submission",
} as const;
export type AnalyticsEvent =
(typeof ANALYTICS_EVENTS)[keyof typeof ANALYTICS_EVENTS];
// CORRECT — typed constant, autocompletes, can't typo
track(ANALYTICS_EVENTS.GITHUB_CLICK, { url });
// WRONG — stringly-typed, drifts, no compile check
track("github-click"); // ❌ silently a different event from "github_click"
Hard rules:
- No inline event-name strings anywhere; only
ANALYTICS_EVENTS.*. - Event names are snake_case and stable — renaming one breaks historical dashboards, so treat the catalog as a contract.
- Keep
propsshapes small and PII-light (see §6); prefer ids over names, never raw emails.
3. Best-effort, non-blocking fan-out
track() is the single entry point. It iterates the adapter registry, guarding each call so one provider can't affect the caller or the others.
// services/analytics/track.ts
import type { AnalyticsEvent } from "@/constants/analytics";
import { analyticsAdapters } from "./adapters";
import { hasConsent } from "./consent";
export function track(
event: AnalyticsEvent,
props?: Record,
): 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,
) => 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 {
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) => void;
}
const AnalyticsContext = createContext(null);
export function AnalyticsProvider({ children }: { children: ReactNode }) {
// track is module-level and stable → memoize once, never re-render consumers.
const value = useMemo(
() => ({ track: trackEvent }),
[],
);
return (
{children}
);
}
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 (
track(ANALYTICS_EVENTS.GITHUB_CLICK, { url: href })}
>
{children}
);
}
The provider does no work during render — track is stable and adapters guard their own window access — so it's safe to mount at the root, including in SSR/RSC trees.
5. Real-user Core Web Vitals (the lab/field loop)
Report field vitals through the same fan-out. This is the complement to the lighthouse skill: the lab gate sets the budget; the field tells you whether real users hit it.
// services/analytics/web-vitals.ts
import { onLCP, onINP, onCLS, onFCP, onTTFB, type Metric } from "web-vitals";
import { track } from "./track";
export function reportWebVitals(): void {
const send = (m: Metric) =>
track("web_vital" as AnalyticsEvent, {
name: m.name, // LCP | INP | CLS | FCP | TTFB
value: Math.round(m.name === "CLS" ? m.value * 1000 : m.value),
rating: m.rating, // good | needs-improvement | poor
id: m.id,
});
onLCP(send);
onINP(send);
onCLS(send);
onFCP(send);
onTTFB(send);
}
- Call
reportWebVitals()once on the client (e.g. in the analytics provider's effect, or Next.jsuseReportWebVitals). - Use the same metrics and thresholds as the lighthouse skill (LCP ≤ 2500, INP ≤ 200, CLS ≤ 0.1) so lab and field speak the same language.
- Lab budget green + field "poor" = a gap between your test conditions and real devices/networks — exactly what field RUM exists to reveal.
6. Consent and privacy gating
Telemetry fires only after opt-in, checked once at the fan-out boundary (§3) — not duplicated at every call site.
// services/analytics/consent.ts
let granted = false; // hydrate from a stored consent cookie/localStorage on init
export function setConsent(value: boolean): void {
granted = value;
}
export function hasConsent(): boolean {
return granted;
}
Hard rules:
track()early-returns when consent is absent — no events, no vitals, no error PII before opt-in.- Keep
propsPII-light: ids and enums, not emails/names/free text. Treat anything user-entered as sensitive. - Respect "Do Not Track" / regional regimes (GDPR/CCPA) by defaulting consent to
falsewhere required. - Error reports must scrub PII before leaving the device.
7. Error reporting at boundaries
Caught render errors and unhandled rejections go through the same fan-out (or a dedicated Sentry adapter), at deliberate boundaries — not a global swallow.
// error/ErrorBoundary.tsx (essence)
componentDidCatch(error: Error, info: ErrorInfo) {
track("client_error" as AnalyticsEvent, {
message: error.message, component: info.componentStack?.split("\n")[1]?.trim(),
}); // or sentryAdapter(error, info)
}
- Place boundaries at route/segment level (per the frontend-architecture page-directory model), so a crash degrades one surface, not the app.
- Pair with the data layer's typed
ApiError(frontend-data-contracts §6): report unexpected errors; expected ones (validation, 404) are handled, not reported as crashes.
8. Provider & framework adapters
The taxonomy + fan-out are constant; each provider is one window-guarded adapter.
| Provider | Adapter call | | ---------------------- | ---------------------------------------------------------------------------- | | Firebase (web) | logEvent(analytics, name, props) (firebase/analytics, lazy browser init) | | Firebase (RN/Expo) | analytics().logEvent(name, props) (@react-native-firebase/analytics) | | GA4 | window.gtag("event", name, props) | | Microsoft Clarity | window.clarity("event", name) | | PostHog | window.posthog?.capture(name, props) | | OpenPanel | op("track", name, props) or op.track(name, props) | | Sentry | Sentry.captureException(error) (error adapter) |
| Framework | Wiring | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Next.js | AnalyticsProvider in the root layout (client boundary); call initFirebaseAnalytics() in a client effect; vitals via useReportWebVitals. | | React + Vite / Remix | provider at app root; initFirebaseAnalytics() + reportWebVitals() in a top-level effect. | | Expo / React Native | swap the web-vitals source for RN performance APIs and the web provider scripts for native SDKs (**`
…
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: stareezy-1
- Source: stareezy-1/frontend-architecture-skill
- License: MIT
- Homepage: https://skills.sh/stareezy-1/frontend-architecture-skill
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.