Skip to content

Refactor: introduce a typed analytics facade over PostHog #35

Open
spiderocious wants to merge 1 commit into
xt42io:mainfrom
spiderocious:feat/typed-analytics
Open

Refactor: introduce a typed analytics facade over PostHog #35
spiderocious wants to merge 1 commit into
xt42io:mainfrom
spiderocious:feat/typed-analytics

Conversation

@spiderocious
Copy link
Copy Markdown

@spiderocious spiderocious commented Jun 2, 2026

What changed

Adds a small typed analytics layer in src/lib/analytics/ and routes every
analytics call through it, replacing the ~42 raw posthog.capture('name', {...})
and ~14 posthog.captureException(...) calls that were scattered across 8 files.

The new layer:

  • event-payloads.ts — one named payload type per event (the Payloads namespace),
    the single description of what each event carries.
  • events.ts — the AnalyticsEvents map (event name → payload) that emit is typed
    against, plus ANALYTICS_EVENTS, a method-name → event-name constant guarded by
    satisfies so a typo or stale name fails to compile.
  • use-analytics.ts — a useAnalytics() hook exposing one named method per event
    (e.g. analytics.fileOpened({ file_id, method })) plus captureError(err, ctx?).
    Every event funnels through a single emit() — the only place a provider is named.

Every call site now uses the facade. usePostHog() is no longer imported across the
app; it lives only inside the hook.

Why

  • Type safety. Event names and payloads are checked at compile time — a typo or a
    wrong/missing property is now a build error instead of a silent mismatch in PostHog.
  • One place to change the provider. Adding a second analytics provider, sending an
    event server-side, or swapping PostHog out is a one-line change in emit() rather than
    edits across dozens of call sites.
  • Discoverability. analytics. autocompletes the full event catalog, and each event's
    payload is a named, documented type.

No behavior change: the same events fire with the same names and payloads. PostHog remains
the underlying provider and the existing PostHogProvider wiring is untouched.

Notes

  • Events that were previously fired with slightly different payloads at different call
    sites are now unified under one payload type per event (verified site by site; no fields
    dropped).
  • One thing that would have made this cleaner: if the repo were a monorepo with a shared
    package, this analytics layer could live there so the backend could emit the same
    typed events too. With the current structure it's frontend-only, which is fine for now —
    happy to revisit if a shared package ever appears.

Checklist

  • npm test passes (added src/__tests__/use-analytics.test.ts)
  • npx tsc --noEmit introduces no new type errors
  • biome check clean on all changed/added files

Summary by cubic

Introduced a typed analytics facade that replaces direct posthog-js/react calls. Centralizes event names and payloads, adds compile-time safety, and keeps behavior unchanged.

  • Refactors
    • Added src/lib/analytics/ with event-payloads.ts, events.ts, use-analytics.ts, and index.ts.
    • useAnalytics() exposes one method per event and captureError, with all calls funneled through a single emit().
    • Replaced ~42 posthog.capture(...) and ~14 exception calls across the app; removed usePostHog imports from posthog-js/react.
    • Typed event catalog via AnalyticsEvents and guarded ANALYTICS_EVENTS to catch name/payload mistakes at build time.
    • Added unit tests to verify event forwarding and error reporting.
    • PostHog remains the provider; event names and payloads are unchanged.

Written for commit 3bdf9b9. Summary will update on new commits.

Review in cubic

- Replaced all instances of PostHog's `usePostHog` with a new `useAnalytics` hook.
- Created a centralized analytics facade to handle event tracking with typed payloads.
- Defined event payloads and event names in separate files for better organization and type safety.
- Updated all components and routes to use the new analytics methods for event tracking.
- Added unit tests for the new analytics hook to ensure correct event capturing and error handling.
@vercel
Copy link
Copy Markdown

vercel Bot commented Jun 2, 2026

@spiderocious is attempting to deploy a commit to the Akinkunmi Team on Vercel.

A member of the Team first needs to authorize it.

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 13 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="frontend/src/lib/analytics/use-analytics.ts">

<violation number="1" location="frontend/src/lib/analytics/use-analytics.ts:25">
P2: Unguarded PostHog client usage in centralized analytics facade — `posthog` may be undefined before initialization or if PostHog fails to load, causing every analytics call in the app to throw a runtime TypeError.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

return useMemo(() => {
function emit<E extends EmptyEvents>(event: E): void
function emit<E extends keyof AnalyticsEvents>(event: E, props: AnalyticsEvents[E]): void
function emit<E extends keyof AnalyticsEvents>(event: E, props?: AnalyticsEvents[E]): void {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Unguarded PostHog client usage in centralized analytics facade — posthog may be undefined before initialization or if PostHog fails to load, causing every analytics call in the app to throw a runtime TypeError.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At frontend/src/lib/analytics/use-analytics.ts, line 25:

<comment>Unguarded PostHog client usage in centralized analytics facade — `posthog` may be undefined before initialization or if PostHog fails to load, causing every analytics call in the app to throw a runtime TypeError.</comment>

<file context>
@@ -0,0 +1,107 @@
+  return useMemo(() => {
+    function emit<E extends EmptyEvents>(event: E): void
+    function emit<E extends keyof AnalyticsEvents>(event: E, props: AnalyticsEvents[E]): void
+    function emit<E extends keyof AnalyticsEvents>(event: E, props?: AnalyticsEvents[E]): void {
+      posthog.capture(event, props ?? undefined)
+    }
</file context>

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for flagging, I already looked into it(I thought about it ahead), but I don't think this one needs a change.

usePostHog() is typed to return a non-nullable PostHog (declare const usePostHog: () => PostHog in posthog-js 1.369.3), and the whole app renders inside <PostHogProvider> in __root.tsx, which supplies the client via context before any route renders. Since every useAnalytics() consumer is a descendant of that provider, posthog is always defined at the point emit runs — a posthog?.capture(...) guard would be defending against a state the type says can't occur (and TS wouldn't narrow it anyway).

Worth noting this isn't introduced by the facade: it's the same unguarded usePostHog().capture(...) that was already in all ~8 call sites before this refactor — I just moved it into one place. If anything, centralizing it means there'd be exactly one line to change if a guard ever became necessary.

PostHog is also fail-safe here by design: if the script is blocked or the token is bad, the client object still exists and .capture() no-ops rather than throwing, so the "fails to load → every call throws" path doesn't really happen.

The only scenario where it'd matter is calling useAnalytics() from a component mounted outside PostHogProvider — nothing in the app does that today, and if it did, that'd be a loud misuse bug rather than something a guard should silently absorb. Happy to add a one-line if (!posthog) return in emit if you'd prefer the defensiveness, but I'd lean against it given the types and provider setup.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant