Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,8 @@ AUTH_SECRET=***

# Stripe Stub Mode (optional)
# Set to 'stub' to use mock Stripe implementation without real API keys
# STRIPE_MODE=stub
# STRIPE_MODE=stub

# PostHog Configuration
NEXT_PUBLIC_POSTHOG_KEY=your_posthog_project_token_here
NEXT_PUBLIC_POSTHOG_HOST=https://your-posthog-host
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.env.local
.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
import posthog from 'posthog-js';

type ErrorBoundaryProps = {
children: ReactNode;
};

type ErrorBoundaryState = {
hasError: boolean;
};

export default class ErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
state: ErrorBoundaryState = { hasError: false };

static getDerivedStateFromError(): ErrorBoundaryState {
return { hasError: true };
}

componentDidCatch(error: Error, errorInfo: ErrorInfo) {
if (
process.env.NEXT_PUBLIC_POSTHOG_KEY &&
process.env.NEXT_PUBLIC_POSTHOG_HOST
) {
posthog.captureException(error, {
component_stack: errorInfo.componentStack
});
}
}

render() {
if (this.state.hasError) {
return <p>Something went wrong. Please refresh the page and try again.</p>;
}

return this.props.children;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,13 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useRouter } from 'next/router';
import { User } from '@/lib/db/schema';
import useSWR, { mutate } from 'swr';
import posthog from 'posthog-js';

const fetcher = (url: string) => fetch(url).then((res) => res.json());
const posthogConfigured = Boolean(
process.env.NEXT_PUBLIC_POSTHOG_KEY &&
process.env.NEXT_PUBLIC_POSTHOG_HOST
);

function UserMenu() {
const [isMenuOpen, setIsMenuOpen] = useState(false);
Expand All @@ -25,10 +30,18 @@ function UserMenu() {
async function handleSignOut() {
try {
// Call sign-out API to delete HttpOnly session cookie
await fetch('/api/auth/sign-out', {
const response = await fetch('/api/auth/sign-out', {
method: 'POST'
});

if (!response.ok) {
throw new Error('Failed to sign out');
}

if (posthogConfigured) {
posthog.capture('user_signed_out');
}

// Clear SWR cache
mutate('/api/user', null, false);
mutate('/api/team', null, false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import { useRouter } from 'next/router';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import type { User } from '@/lib/db/schema';
import { CircleIcon, Loader2 } from 'lucide-react';
import { mutate } from 'swr';
import posthog from 'posthog-js';

export function Login({
mode = 'signin',
Expand Down Expand Up @@ -57,6 +60,28 @@ export function Login({
return;
}

const user = await mutate<User | null>('/api/user');

if (
user &&
process.env.NEXT_PUBLIC_POSTHOG_KEY &&
process.env.NEXT_PUBLIC_POSTHOG_HOST
) {
const userId = String(user.id);
const identifiedUserId = posthog.get_property('$user_id');

if (identifiedUserId && identifiedUserId !== userId) {
posthog.reset();
}

posthog.identify(userId, {
email: user.email,
role: user.role,
...(user.name ? { name: user.name } : {})
});
posthog.capture(mode === 'signin' ? 'user_signed_in' : 'user_signed_up');
}

if (result.success && result.redirectTo) {
router.push(result.redirectTo);
} else if (result.url) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import posthog from 'posthog-js';

const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
const posthogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST;

if (!posthogKey) {
if (process.env.NODE_ENV === 'development') {
throw new Error(
'NEXT_PUBLIC_POSTHOG_KEY variable required by PostHog is missing or un-configured, this causes events to be silently missed. This error stops appearing once NEXT_PUBLIC_POSTHOG_KEY is configured'
);
}
} else if (!posthogHost) {
if (process.env.NODE_ENV === 'development') {
throw new Error(
'NEXT_PUBLIC_POSTHOG_HOST variable required by PostHog is missing or un-configured, this causes events to be silently missed. This error stops appearing once NEXT_PUBLIC_POSTHOG_HOST is configured'
);
}
} else {
posthog.init(posthogKey, {
api_host: posthogHost,
defaults: '2025-05-24',
capture_exceptions: true,
debug: process.env.NODE_ENV === 'development',
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { SeverityNumber } from '@opentelemetry/api-logs';
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { BatchLogRecordProcessor, LoggerProvider } from '@opentelemetry/sdk-logs';

const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
const posthogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST;

let loggerProvider: LoggerProvider | undefined;

if (!posthogKey) {
if (process.env.NODE_ENV === 'development') {
throw new Error(
'NEXT_PUBLIC_POSTHOG_KEY variable required by PostHog is missing or un-configured, this causes events to be silently missed. This error stops appearing once NEXT_PUBLIC_POSTHOG_KEY is configured'
);
}
} else if (!posthogHost) {
if (process.env.NODE_ENV === 'development') {
throw new Error(
'NEXT_PUBLIC_POSTHOG_HOST variable required by PostHog is missing or un-configured, this causes events to be silently missed. This error stops appearing once NEXT_PUBLIC_POSTHOG_HOST is configured'
);
}
} else {
loggerProvider = new LoggerProvider({
resource: resourceFromAttributes({ 'service.name': 'nextjs-saas' }),
processors: [
new BatchLogRecordProcessor({
exporter: new OTLPLogExporter({
url: `${posthogHost.replace(/\/$/, '')}/i/v1/logs`,
headers: {
Authorization: `Bearer ${posthogKey}`,
'Content-Type': 'application/json'
}
})
})
]
});
}

export async function emitPostHogLog(
body: string,
attributes: Record<string, string>
) {
if (!loggerProvider) {
return;
}

loggerProvider.getLogger('posthog-integration').emit({
body,
severityNumber: SeverityNumber.INFO,
attributes
});

try {
await loggerProvider.forceFlush();
} catch {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@opentelemetry/api-logs": "^0.223.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.300.0",
"@opentelemetry/resources": "^3.0.0",
"@opentelemetry/sdk-logs": "^3.0.0",
"@tailwindcss/postcss": "4.1.7",
"@types/node": "^22.15.18",
"@types/react": "19.1.4",
Expand All @@ -28,6 +32,7 @@
"next": "15.5.7",
"postcss": "^8.5.3",
"postgres": "^3.4.5",
"posthog-js": "^1.441.2",
"radix-ui": "^1.4.2",
"react": "19.1.2",
"react-dom": "19.1.2",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,73 @@
import ErrorBoundary from '@/components/error-boundary';
import '@/styles/globals.css';
import type { User } from '@/lib/db/schema';
import type { AppProps } from 'next/app';
import { Manrope } from 'next/font/google';
import { SWRConfig } from 'swr';
import posthog from 'posthog-js';
import { useEffect, useRef } from 'react';
import useSWR, { SWRConfig } from 'swr';

const manrope = Manrope({ subsets: ['latin'] });
const fetcher = (url: string) => fetch(url).then((res) => res.json());

function PostHogUserIdentification() {
const { data: user } = useSWR<User | null>('/api/user', fetcher);
const identifiedUserId = useRef<number | null>(null);

useEffect(() => {
if (
!process.env.NEXT_PUBLIC_POSTHOG_KEY ||
!process.env.NEXT_PUBLIC_POSTHOG_HOST
) {
return;
}

if (!user) {
if (identifiedUserId.current !== null) {
posthog.reset();
identifiedUserId.current = null;
}
return;
}

const userId = String(user.id);

if (
identifiedUserId.current === user.id ||
posthog.get_distinct_id() === userId
) {
identifiedUserId.current = user.id;
return;
}

if (identifiedUserId.current !== null) {
posthog.reset();
}

posthog.identify(userId, {
email: user.email,
role: user.role,
...(user.name ? { name: user.name } : {})
});
identifiedUserId.current = user.id;
}, [user]);

return null;
}

export default function App({ Component, pageProps }: AppProps) {
return (
<div className={manrope.className}>
<SWRConfig
value={{
fallback: pageProps.fallback || {}
}}
>
<Component {...pageProps} />
</SWRConfig>
<ErrorBoundary>
<SWRConfig
value={{
fallback: pageProps.fallback || {}
}}
>
<PostHogUserIdentification />
<Component {...pageProps} />
</SWRConfig>
</ErrorBoundary>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { createCheckoutSession } from '@/lib/payments/stripe';
import { getUser, getTeamForUser } from '@/lib/db/queries';
import { emitPostHogLog } from '@/instrumentation';

export default async function handler(
req: NextApiRequest,
Expand All @@ -25,6 +26,9 @@ export default async function handler(
}

const result = await createCheckoutSession({ team, priceId, userId: user.id });
await emitPostHogLog('checkout_session_created', {
endpoint: '/api/stripe/create-checkout'
});
return res.status(200).json(result);
} catch (error) {
console.error('Checkout error:', error);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { createCustomerPortalSession } from '@/lib/payments/stripe';
import { getUser, getTeamForUser } from '@/lib/db/queries';
import { emitPostHogLog } from '@/instrumentation';

export default async function handler(
req: NextApiRequest,
Expand All @@ -25,6 +26,9 @@ export default async function handler(
}

const portalSession = await createCustomerPortalSession(team);
await emitPostHogLog('customer_portal_session_created', {
endpoint: '/api/stripe/customer-portal'
});
return res.status(200).json({ url: portalSession.url });
} catch (error) {
console.error('Customer portal error:', error);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
ActivityType
} from '@/lib/db/schema';
import { getUser, getUserWithTeam } from '@/lib/db/queries';
import { emitPostHogLog } from '@/instrumentation';

async function logActivity(
teamId: number | null | undefined,
Expand Down Expand Up @@ -108,6 +109,10 @@ export default async function handler(
ActivityType.INVITE_TEAM_MEMBER
);

await emitPostHogLog('team_invitation_created', {
endpoint: '/api/team/invite',
invited_role: role
});
return res.status(200).json({ success: 'Invitation sent successfully' });
} catch (error) {
console.error('Invite team member error:', error);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,13 @@ import { Loader2 } from 'lucide-react';
import useSWR from 'swr';
import { User } from '@/lib/db/schema';
import { useState, useTransition } from 'react';
import posthog from 'posthog-js';

const fetcher = (url: string) => fetch(url).then((res) => res.json());
const posthogConfigured = Boolean(
process.env.NEXT_PUBLIC_POSTHOG_KEY &&
process.env.NEXT_PUBLIC_POSTHOG_HOST
);

export default function GeneralPage() {
const { data: user } = useSWR<User>('/api/user', fetcher);
Expand Down Expand Up @@ -52,6 +57,9 @@ export default function GeneralPage() {
return;
}

if (posthogConfigured) {
posthog.capture('account_updated');
}
setSuccess(result.success);
setName(result.name);
} catch (err) {
Expand Down
Loading
Loading