Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion apps/sim/app/_shell/providers/posthog-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import type { PostHog } from 'posthog-js'
import { getEnv, isTruthy, publicEnvMissingAtModuleInit } from '@/lib/core/config/env'
import { settlePostHogClient } from '@/lib/posthog/client'
import { dropUnactionableExceptions } from '@/lib/posthog/exception-filter'

const logger = createLogger('PostHogProvider')

Expand All @@ -18,7 +20,10 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) {
const posthogEnabled = getEnv('NEXT_PUBLIC_POSTHOG_ENABLED')
const posthogKey = getEnv('NEXT_PUBLIC_POSTHOG_KEY')

if (!isTruthy(posthogEnabled) || !posthogKey) return
if (!isTruthy(posthogEnabled) || !posthogKey) {
settlePostHogClient(null)
return
}

Promise.all([import('posthog-js'), import('posthog-js/react')])
.then(([posthogModule, { PostHogProvider: PHProvider }]) => {
Expand Down Expand Up @@ -52,6 +57,14 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) {
capture_unhandled_rejections: true,
capture_console_errors: false,
},
/**
* Drops the browser artifacts that autocapture cannot help but
* see — resize-loop notices, opaque cross-origin failures, and
* cancelled requests. Filtering here rather than with a PostHog
* suppression rule keeps the list reviewable in the diff and stops
* the events before they leave the browser.
*/
before_send: dropUnactionableExceptions,
disable_session_recording: true,
session_recording: {
maskAllInputs: false,
Expand Down Expand Up @@ -88,13 +101,20 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) {
persistence: 'localStorage+cookie',
})
}
/**
* Releases anything captured while the imports above were in flight.
* Must run after `init`, since `capture` is a silent no-op until then.
*/
settlePostHogClient(posthog)

if (publicEnvMissingAtModuleInit) {
posthog.capture('runtime_env_missing_at_module_init')
}
clientRef.current = posthog
setProvider(() => PHProvider)
})
.catch((err) => {
settlePostHogClient(null)
logger.error('Failed to load PostHog', { error: err })
})
}, [])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { RefreshCw } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { truncate } from '@sim/utils/string'
import { ReactFlowProvider } from 'reactflow'
import { captureClientEvent } from '@/lib/posthog/client'
import { captureClientEvent, captureClientException } from '@/lib/posthog/client'
import { Panel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components'
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
Expand Down Expand Up @@ -102,9 +102,20 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
* — leaving an intermittent failure with no evidence to diagnose from.
* `error.name` is carried separately from the message because it is what
* separates the failure classes from each other.
*
* Reported twice, to two different consumers. `captureException` is what
* reaches PostHog Error Tracking: it parses the stack into `$exception_list`,
* which is what groups these into an issue and links the session replay — a
* custom event carrying the message as a string property is invisible there.
* The named event stays because it answers a different question, "how often
* does the canvas fall over", against a stable name that survives the
* error tracker's own grouping and resolution.
*/
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
const componentStack = errorInfo.componentStack ?? undefined
const reportedComponentStack = componentStack
? truncate(componentStack, MAX_REPORTED_COMPONENT_STACK)
: undefined

logger.error('Workflow canvas crashed', {
name: error.name,
Expand All @@ -113,12 +124,15 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
componentStack,
})

captureClientException(error, {
error_boundary: 'workflow_canvas',
component_stack: reportedComponentStack,
})

captureClientEvent('workflow_canvas_crashed', {
error_name: error.name,
error_message: error.message,
component_stack: componentStack
? truncate(componentStack, MAX_REPORTED_COMPONENT_STACK)
: undefined,
component_stack: reportedComponentStack,
})
}

Expand Down
27 changes: 27 additions & 0 deletions apps/sim/lib/posthog/client-disabled.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
captureClientEvent,
captureClientException,
settlePostHogClient,
} from '@/lib/posthog/client'

/**
* Lives in its own file because readiness is module-level and settles once.
* Vitest isolates the module registry per file, so this gets an unsettled gate
* to settle with `null` without `vi.resetModules()` and a dynamic import.
*/
describe('client capture when analytics is disabled', () => {
it('drops events instead of sending them, without throwing', async () => {
settlePostHogClient(null)

expect(() => captureClientEvent('login_page_viewed', {})).not.toThrow()
expect(() => captureClientException(new Error('boom'))).not.toThrow()

// Lets the gate's continuations run; a missing catch would surface here as
// an unhandled rejection and fail the test.
await Promise.resolve()
})
})
53 changes: 53 additions & 0 deletions apps/sim/lib/posthog/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* @vitest-environment node
*/
import type { PostHog } from 'posthog-js'
import { describe, expect, it, vi } from 'vitest'
import {
captureClientEvent,
captureClientException,
settlePostHogClient,
} from '@/lib/posthog/client'

/**
* Stands in for the initialized singleton. `capture` exists on the real
* instance long before `init` runs, which is what made the dropped-event bug
* invisible to a `typeof posthog.capture === 'function'` guard.
*/
function createFakePostHog() {
return {
capture: vi.fn(),
captureException: vi.fn(),
} as unknown as PostHog
}

describe('client capture', () => {
/**
* Readiness is module-level and settles exactly once, so the whole lifecycle
* runs as a single case. Split across separate cases, each one after the
* first would silently depend on an earlier case having settled the gate, and
* would fail when run in isolation or reordered.
*/
it('holds events until PostHog initializes, then sends them and everything after', async () => {
const posthog = createFakePostHog()

captureClientEvent('login_page_viewed', {})
await Promise.resolve()

expect(posthog.capture).not.toHaveBeenCalled()

settlePostHogClient(posthog)
await vi.waitFor(() => expect(posthog.capture).toHaveBeenCalledWith('login_page_viewed', {}))

captureClientEvent('signup_page_viewed', {})
await vi.waitFor(() => expect(posthog.capture).toHaveBeenCalledWith('signup_page_viewed', {}))

const error = new Error('canvas exploded')
captureClientException(error, { error_boundary: 'workflow_canvas' })
await vi.waitFor(() =>
expect(posthog.captureException).toHaveBeenCalledWith(error, {
error_boundary: 'workflow_canvas',
})
)
})
})
78 changes: 67 additions & 11 deletions apps/sim/lib/posthog/client.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,54 @@
import type { PostHog } from 'posthog-js'
import type { PostHogEventMap, PostHogEventName } from '@/lib/posthog/events'

/**
* Resolves with the initialized PostHog instance, or `null` when analytics is
* disabled or the library failed to load. Settled exactly once by
* {@link settlePostHogClient}, which `PostHogProvider` calls on every branch.
*
* This gate exists because `posthog.capture` is a hard no-op until
* `posthog.init()` has run: its entire body sits behind `if (this.__loaded)`,
* with no buffer and no warning, so a call made before init is discarded
* silently. `PostHogProvider` reaches init through two dynamic imports
* (`posthog-js` and `posthog-js/react`), while a caller only needs `posthog-js`
* — so anything captured on mount resolves first and lands in that dead window.
* Mount-time events (`login_page_viewed`, `landing_page_viewed`) and, worst of
* all, a crash report from an error boundary that fired during first paint were
* the events most reliably lost.
*/
let settlePostHog!: (instance: PostHog | null) => void
const postHogReady = new Promise<PostHog | null>((resolve) => {
settlePostHog = resolve
})

/**
* Publishes the outcome of PostHog initialization to the capture helpers.
* Called only by `PostHogProvider`. Repeat calls are no-ops.
*
* @param instance - The initialized instance, or `null` when analytics is off.
*/
export function settlePostHogClient(instance: PostHog | null): void {
settlePostHog(instance)
}

/**
* Runs `send` once PostHog is ready, swallowing everything. Analytics must
* never surface as an unhandled rejection — these helpers are called from
* error-reporting paths, where a throw would be captured as its own exception.
*/
function whenReady(send: (posthog: PostHog) => void): void {
postHogReady
.then((posthog) => {
if (posthog) send(posthog)
})
.catch(() => {})
}

/**
* Capture a client-side PostHog event from a non-React context (e.g. Zustand stores).
*
* Uses the same dynamic `import('posthog-js')` pattern as `session-provider.tsx`.
* Fully fire-and-forget — never throws, never blocks.
* Fully fire-and-forget — never throws, never blocks. Events captured before
* PostHog finishes initializing are held until it does rather than dropped.
*
* React components should use {@link captureEvent} with the `posthog` instance from `usePostHog()`.
*
Expand All @@ -16,15 +59,28 @@ export function captureClientEvent<E extends PostHogEventName>(
event: E,
properties: PostHogEventMap[E]
): void {
import('posthog-js')
.then(({ default: posthog }) => {
try {
if (typeof posthog.capture === 'function') {
posthog.capture(event, properties)
}
} catch {}
})
.catch(() => {})
whenReady((posthog) => {
posthog.capture(event, properties as Record<string, unknown>)
})
}

/**
* Report a caught error to PostHog Error Tracking.
*
* This is what puts a failure in front of the error tracker: `captureException`
* emits a `$exception` event carrying `$exception_list` — the parsed type,
* message, and stack frames that error tracking groups into an issue and links
* to a session replay. A custom event with the message copied into a string
* property looks equivalent on a dashboard but is invisible to that product,
* carries no stack, and cannot be grouped or resolved.
*
* @param error - The caught value. Coerced by PostHog into an exception list.
* @param properties - Extra context merged onto the `$exception` event.
*/
export function captureClientException(error: unknown, properties?: Record<string, unknown>): void {
whenReady((posthog) => {
posthog.captureException(error, properties)
})
}

/**
Expand Down
Loading
Loading