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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,14 @@ NEXT_PUBLIC_E2B_DOMAIN=e2b.dev
### PostHog analytics key
# NEXT_PUBLIC_POSTHOG_KEY=

### PostHog source map upload (build-time only, used by @posthog/nextjs-config).
### Configure these as build env vars in the Vercel project settings (production,
### and optionally preview) — they are not needed for local development.
### POSTHOG_API_KEY is a personal API key with "error tracking: write" and
### "organization: read" scopes. POSTHOG_PROJECT_ID is found in project settings.
# POSTHOG_API_KEY=
# POSTHOG_PROJECT_ID=

### Enable billing features: set to 1 to enable
### When enabled, both BILLING_API_URL and NEXT_PUBLIC_STRIPE_BILLING_URL must be provided
# NEXT_PUBLIC_INCLUDE_BILLING=0
Expand Down
117 changes: 112 additions & 5 deletions bun.lock

Large diffs are not rendered by default.

13 changes: 12 additions & 1 deletion next.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// NOTE: related to src/configs/rewrites.ts
import path from 'node:path'
import { withPostHogConfig } from '@posthog/nextjs-config'
import type { NextConfig } from 'next'
import { DOCUMENTATION_DOMAIN } from './src/configs/documentation'

Expand Down Expand Up @@ -140,4 +141,14 @@ const config: NextConfig = {
skipTrailingSlashRedirect: true,
}

export default config
export default withPostHogConfig(config, {
personalApiKey: process.env.POSTHOG_API_KEY ?? '',
projectId: process.env.POSTHOG_PROJECT_ID,
host: 'https://us.posthog.com',
sourcemaps: {
enabled: Boolean(process.env.POSTHOG_API_KEY),
Comment thread
huv1k marked this conversation as resolved.
releaseName: 'dashboard',
releaseVersion: process.env.VERCEL_GIT_COMMIT_SHA,
deleteAfterUpload: true,
},
})
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@
"devDependencies": {
"@biomejs/biome": "^2.4.4",
"@playwright/test": "^1.59.1",
"@posthog/nextjs-config": "^1.9.55",
"@tailwindcss/postcss": "^4.0.15",
"@types/bun": "^1.2.5",
"@types/node": "22.10.10",
Expand Down
9 changes: 6 additions & 3 deletions src/app/dashboard/[teamSlug]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { DASHBOARD_TEAMS_LIST_QUERY_OPTIONS } from '@/core/application/teams/que
import { DASHBOARD_USER_PROFILE_QUERY_OPTIONS } from '@/core/application/user/queries'
import { auth } from '@/core/server/auth'
import DashboardLayoutView from '@/features/dashboard/layouts/layout'
import { DashboardPostHogErrorBoundary } from '@/features/dashboard/posthog-error-boundary'
import Sidebar from '@/features/dashboard/sidebar/sidebar'
import { OryPostHogIdentityBridge } from '@/features/ory-posthog-identity-bridge'
import { HydrateClient, prefetchAsync, trpc } from '@/trpc/server'
Expand Down Expand Up @@ -73,9 +74,11 @@ export default async function DashboardLayout({
<div className="flex h-full max-h-full min-h-0 w-full flex-1 overflow-hidden">
<Sidebar />
<SidebarInset>
<DashboardLayoutView params={params}>
{children}
</DashboardLayoutView>
<DashboardPostHogErrorBoundary>
<DashboardLayoutView params={params}>
{children}
</DashboardLayoutView>
</DashboardPostHogErrorBoundary>
</SidebarInset>
</div>
</div>
Expand Down
24 changes: 24 additions & 0 deletions src/app/global-error.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,40 @@
'use client'

import posthog from 'posthog-js'
import { useEffect } from 'react'
import ErrorBoundary from '@/ui/error'

export default function GlobalError({
error,
}: {
error: Error & { digest?: string }
}) {
useEffect(() => {
const key = process.env.NEXT_PUBLIC_POSTHOG_KEY
if (!key) return

// global-error renders outside RootLayout/ClientProviders, so PostHogProvider
// never ran posthog.init. Initialize a minimal client here so root-layout and
// provider failures are still reported instead of silently dropped.
if (!posthog.__loaded) {
posthog.init(key, {
api_host: '/ph-proxy',
ui_host: 'https://us.posthog.com',
capture_exceptions: false,
})
posthog.register({
environment: process.env.NEXT_PUBLIC_VERCEL_ENV ?? 'development',
})
}

posthog.captureException(error)
}, [error])

return (
<ErrorBoundary
description="Sorry, something went wrong with the application."
error={error}
report={false}
/>
)
}
36 changes: 36 additions & 0 deletions src/features/dashboard/posthog-error-boundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
'use client'

import {
PostHogErrorBoundary,
type PostHogErrorBoundaryFallbackProps,
} from 'posthog-js/react'
import { ErrorIndicator } from '@/ui/error-indicator'
import Frame from '@/ui/frame'

// Not the shared ErrorBoundary: it calls captureException, which would
// double-report since PostHogErrorBoundary already reports the caught error.
function Fallback({ error }: PostHogErrorBoundaryFallbackProps) {
const message = error instanceof Error ? error.message : String(error)

return (
<div className="flex h-full w-full items-center justify-center">
<Frame>
<ErrorIndicator
description="Sorry, something went wrong."
message={message}
className="border-none"
/>
</Frame>
</div>
)
}

export function DashboardPostHogErrorBoundary({
children,
}: {
children: React.ReactNode
}) {
return (
<PostHogErrorBoundary fallback={Fallback}>{children}</PostHogErrorBoundary>
)
}
1 change: 1 addition & 0 deletions src/features/dashboard/shared/route-error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export function DashboardRouteError({
return (
<ErrorBoundary
description="Sorry, something went wrong with this page."
className="min-h-svh"
error={error}
onRetry={() => {
router.refresh()
Expand Down
22 changes: 20 additions & 2 deletions src/features/posthog-provider.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { usePathname } from 'next/navigation'
import posthog, { type Survey } from 'posthog-js'
import { PostHogProvider as PHProvider } from 'posthog-js/react'
import { createContext, useContext, useEffect, useState } from 'react'
Expand Down Expand Up @@ -31,12 +32,20 @@ export function PostHogProvider({
children: React.ReactNode
enabled: boolean
}) {
const pathname = usePathname()
const [dashboardFeedbackSurvey, setDashboardFeedbackSurvey] =
useState<Survey | null>(null)
const [isInitialized, setIsInitialized] = useState(false)

// Only track the dashboard app — not auth, marketing, or proxied (docs/blog)
// paths. PostHog initializes lazily once the user reaches a /dashboard route.
const shouldInit = enabled && !!pathname?.startsWith('/dashboard')

useEffect(() => {
if (!enabled || !process.env.NEXT_PUBLIC_POSTHOG_KEY) {
if (!shouldInit || !process.env.NEXT_PUBLIC_POSTHOG_KEY) {
return
}
if (posthog.__loaded) {
return
}

Expand All @@ -48,6 +57,11 @@ export function PostHogProvider({
// https://posthog.com/docs/libraries/next-js#configuring-a-reverse-proxy-to-posthog
api_host: '/ph-proxy',
ui_host: 'https://us.posthog.com',
capture_exceptions: {
capture_unhandled_errors: true,
capture_unhandled_rejections: true,
capture_console_errors: false,
},
advanced_enable_surveys: true,
disable_session_recording: process.env.NODE_ENV !== 'production',
advanced_disable_toolbar_metrics: true,
Expand All @@ -57,6 +71,10 @@ export function PostHogProvider({
},
})

posthog.register({
environment: process.env.NEXT_PUBLIC_VERCEL_ENV ?? 'development',
})

posthog.getSurveys((surveys) => {
for (const survey of surveys) {
switch (survey.id) {
Expand All @@ -67,7 +85,7 @@ export function PostHogProvider({
}
setIsInitialized(true)
})
}, [enabled])
}, [shouldInit])

return (
<AppPostHogContext.Provider
Expand Down
6 changes: 6 additions & 0 deletions src/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ export const serverSchema = z.object({

TURNSTILE_SECRET_KEY: z.string().optional(),

POSTHOG_API_KEY: z.string().min(1).optional(),
POSTHOG_PROJECT_ID: z.string().min(1).optional(),

AUTH_PROVIDER: z.enum(['supabase', 'ory']),
AUTH_SECRET: z.string().min(1).optional(),
AUTH_TRUST_HOST: z.string().optional(),
Expand Down Expand Up @@ -64,6 +67,9 @@ export const clientSchema = z.object({
.string()
.min(1)
.optional(),
NEXT_PUBLIC_VERCEL_ENV: z
.enum(['production', 'preview', 'development'])
.optional(),

NEXT_PUBLIC_INCLUDE_BILLING: z.string().optional(),
NEXT_PUBLIC_INCLUDE_ARGUS: z.string().optional(),
Expand Down
18 changes: 16 additions & 2 deletions src/ui/error.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,30 @@
'use client'

import posthog from 'posthog-js'
import { useEffect } from 'react'
import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary'
import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'
import { cn } from '@/lib/utils'
import { ErrorIndicator } from './error-indicator'
import Frame from './frame'

const GENERIC_ERROR_MESSAGE =
"We're aware of the issue and are working to fix it as soon as possible."

export default function ErrorBoundary({
error,
description,
className,
hideFrame = false,
onRetry,
report = true,
}: {
error: Error & { digest?: string }
description?: string
className?: string
hideFrame?: boolean
onRetry?: () => void
report?: boolean
}) {
useEffect(() => {
l.error(
Expand All @@ -28,7 +34,15 @@ export default function ErrorBoundary({
},
`${error.message}`
)
}, [error])

// Only report when a PostHog client is actually initialized. We init only on
// dashboard routes, and global-error renders outside the provider tree — in
// both cases captureException on an uninitialized singleton silently drops.
// global-error handles its own reporting (report={false}).
if (report && posthog.__loaded) {
posthog.captureException(error)
Comment thread
cursor[bot] marked this conversation as resolved.
}
}, [error, report])

return (
<div
Expand All @@ -40,7 +54,7 @@ export default function ErrorBoundary({
{hideFrame ? (
<ErrorIndicator
description={description}
message={error.message}
message={GENERIC_ERROR_MESSAGE}
className="border-none"
onRetry={onRetry}
/>
Expand Down
23 changes: 19 additions & 4 deletions tests/integration/e2b-browser-stubs.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { NextConfig } from 'next'
import { PHASE_PRODUCTION_BUILD } from 'next/constants'
import { describe, expect, it } from 'vitest'
import nextConfig from '../../next.config'

Expand All @@ -14,14 +16,26 @@ class MockNormalModuleReplacementPlugin {
}
}

// withPostHogConfig wraps the config as a function; resolve it to the object form.
type NextConfigFn = (
phase: string,
ctx: { defaultConfig: NextConfig }
) => Promise<NextConfig>

const resolveNextConfig = () =>
(nextConfig as unknown as NextConfigFn)(PHASE_PRODUCTION_BUILD, {
defaultConfig: {},
})

describe('E2B browser module stubs', () => {
it('aliases node built-ins after webpack strips node: scheme requests', () => {
it('aliases node built-ins after webpack strips node: scheme requests', async () => {
const resolved = await resolveNextConfig()
const webpackConfig = {
resolve: { alias: {} as Record<string, string> },
plugins: [] as MockNormalModuleReplacementPlugin[],
}

nextConfig.webpack?.(webpackConfig, {
resolved.webpack?.(webpackConfig, {
isServer: false,
webpack: {
NormalModuleReplacementPlugin: MockNormalModuleReplacementPlugin,
Expand Down Expand Up @@ -53,13 +67,14 @@ describe('E2B browser module stubs', () => {
)
})

it('does not rewrite server webpack builds', () => {
it('does not rewrite server webpack builds', async () => {
const resolved = await resolveNextConfig()
const webpackConfig = {
resolve: { alias: {} as Record<string, string> },
plugins: [] as MockNormalModuleReplacementPlugin[],
}

nextConfig.webpack?.(webpackConfig, {
resolved.webpack?.(webpackConfig, {
isServer: true,
webpack: {
NormalModuleReplacementPlugin: MockNormalModuleReplacementPlugin,
Expand Down
Loading