Skip to content

Commit 6c9632d

Browse files
authored
Merge pull request #228 from PostHog/fix/serverless-capture-flush
Make server-side posthog-node capture serverless-safe
2 parents bb59cb5 + 30cf713 commit 6c9632d

13 files changed

Lines changed: 41 additions & 2 deletions

File tree

context/commandments.yaml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ commandments:
3434
# Server-side feature flags
3535
- Server Components and Route Handlers cannot use React hooks - use posthog-node SDK instead
3636
- Create a server-side PostHog client with posthog-node, call getAllFlags() or getFeatureFlag(), then await posthog.shutdown()
37+
- 'Route Handlers and Server Actions are short-lived per request. Create the posthog-node client with flushAt 1 and flushInterval 0, and `await posthog.flush()` after capturing and before returning, or the function freezes before the event is sent and it is silently dropped'
3738
- Pass flag values from server to client components as props to avoid hydration mismatches
3839
# Avoiding flicker
3940
- For flags that affect initial render, evaluate server-side and pass as props to prevent UI flicker
@@ -56,8 +57,8 @@ commandments:
5657
- 'Include enableExceptionAutocapture: true in the PostHog constructor options'
5758
- Add posthog.capture() calls in route handlers for meaningful user actions – every route that creates, updates, or deletes data should track an event with contextual properties
5859
- Add posthog.captureException(err, distinctId) in the application's error handler (e.g., Express error middleware, Fastify setErrorHandler, Koa app.on('error'))
59-
- In long-running servers, the SDK batches events automatically – do NOT set flushAt or flushInterval unless you have a specific reason to
60-
- For short-lived processes (scripts, CLIs, serverless), set flushAt to 1 and flushInterval to 0 to send events immediately
60+
- The SDK batches events and flushes asynchronously. await flush() or await shutdown() before letting that process exit. If unsure, set flushAt 1 and flushInterval 0.
61+
- '`posthog.capture()` enqueues synchronously and returns; the batched HTTP send happens afterwards. Treat every per-request handler as short-lived even when the framework feels like a server: Next.js / Nuxt / SvelteKit / Remix route handlers, serverless and edge functions, and Lambda are torn down per invocation before the send runs. Create the client with flushAt 1 and flushInterval 0, then await the send before returning. Always use `await posthog.flush()` for a shared/singleton client, `await posthog.shutdown()` for a per-request client. Never skip the awaited flush or risk the enqueued event being silently dropped.'
6162
- Reverse proxy is NOT needed for server-side Node.js – only client-side JavaScript needs a proxy to avoid ad blockers
6263

6364
python:
@@ -195,6 +196,7 @@ commandments:
195196
- Test queries in the PostHog SQL editor before using them in insights or the API
196197

197198
sveltekit:
199+
- 'For server-side capture (+server.ts endpoints, actions, hooks like handleError), the handler is short-lived per request. Configure the posthog-node singleton with flushAt 1 and flushInterval 0, and `await posthog.flush()` after capturing and before returning, or the batched event is silently dropped when the request ends'
198200
- Set paths.relative to false in svelte.config.js — this is required for PostHog session replay to work correctly with SSR and is easy to miss
199201
- Use the Svelte MCP server tools to check Svelte documentation (list-sections, get-documentation) and validate components (svelte-autofixer) — always run svelte-autofixer on new or modified .svelte files before finishing
200202

@@ -254,11 +256,13 @@ commandments:
254256
astro-ssr:
255257
- Use posthog-node in API routes under src/pages/api/ for server-side event tracking
256258
- Store the posthog-node client instance in a singleton pattern (src/lib/posthog-server.ts) to avoid creating multiple clients
259+
- 'Configure the singleton with flushAt 1 and flushInterval 0, and `await posthog.flush()` in the API route after capturing and before returning the Response. An SSR endpoint is short-lived per request, so an unflushed batched event is silently dropped'
257260
- Pass the client session ID to server via X-PostHog-Session-Id header for unified session tracking
258261

259262
astro-hybrid:
260263
- Use posthog-node in API routes under src/pages/api/ for server-side event tracking
261264
- Store the posthog-node client instance in a singleton pattern (src/lib/posthog-server.ts) to avoid creating multiple clients
265+
- 'Configure the singleton with flushAt 1 and flushInterval 0, and `await posthog.flush()` in the API route after capturing and before returning the Response. An SSR endpoint is short-lived per request, so an unflushed batched event is silently dropped'
262266
- In Astro 5, use output static (the default) with an adapter - pages are prerendered by default
263267
- Use export const prerender = false to opt specific pages into SSR when they need server-side rendering
264268
- Only pages that need server-side PostHog tracking (like API-backed forms) should opt out of prerendering

example-apps/astro-hybrid/src/pages/api/auth/login.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ export const POST: APIRoute = async ({ request }) => {
5555
},
5656
});
5757

58+
// This endpoint is short-lived; flush so the enqueued events send before it returns
59+
await posthog.flush();
60+
5861
return new Response(
5962
JSON.stringify({
6063
success: true,

example-apps/astro-hybrid/src/pages/api/events/burrito.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ export const POST: APIRoute = async ({ request }) => {
3333
},
3434
});
3535

36+
// This endpoint is short-lived; flush so the enqueued event sends before it returns
37+
await posthog.flush();
38+
3639
return new Response(
3740
JSON.stringify({
3841
success: true,

example-apps/astro-ssr/src/pages/api/auth/login.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ export const POST: APIRoute = async ({ request }) => {
5353
},
5454
});
5555

56+
// This endpoint is short-lived; flush so the enqueued events send before it returns
57+
await posthog.flush();
58+
5659
return new Response(
5760
JSON.stringify({
5861
success: true,

example-apps/astro-ssr/src/pages/api/events/burrito.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ export const POST: APIRoute = async ({ request }) => {
3131
},
3232
});
3333

34+
// This endpoint is short-lived; flush so the enqueued event sends before it returns
35+
await posthog.flush();
36+
3437
return new Response(
3538
JSON.stringify({
3639
success: true,

example-apps/next-app-router/src/app/api/auth/login/route.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,8 @@ export async function POST(request: Request) {
3838
}
3939
});
4040

41+
// This handler is short-lived; flush so the enqueued events send before it returns
42+
await posthog.flush();
43+
4144
return NextResponse.json({ success: true, user });
4245
}

example-apps/next-pages-router/src/pages/api/auth/login.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,8 @@ export default async function handler(
4545
}
4646
});
4747

48+
// This handler is short-lived; flush so the enqueued events send before it returns
49+
await posthog.flush();
50+
4851
return res.status(200).json({ success: true, user });
4952
}

example-apps/nuxt-4/server/api/auth/login.post.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ export default defineEventHandler(async (event) => {
3232
},
3333
})
3434

35+
// This handler is short-lived; flush so the enqueued event sends before it returns
36+
await posthog.flush()
37+
3538
return {
3639
success: true,
3740
user,

example-apps/nuxt-4/server/api/burrito/consider.post.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ export default defineEventHandler(async (event) => {
4040
},
4141
})
4242

43+
// This handler is short-lived; flush so the enqueued event sends before it returns
44+
await posthog.flush()
45+
4346
return {
4447
success: true,
4548
user: { ...user },

example-apps/nuxt-4/server/utils/posthog.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export function useServerPostHog(): PostHog {
99
const posthogConfig = config.public.posthog
1010
client = new PostHog(posthogConfig.publicKey, {
1111
host: posthogConfig.host,
12+
flushAt: 1,
13+
flushInterval: 0,
1214
})
1315
}
1416
return client

0 commit comments

Comments
 (0)