instrument-product-analytics
Add PostHog product analytics events to track user behavior. Use after implementing new features or reviewing PRs to ensure meaningful user actions are captured. Also handles initial PostHog SDK setup if not yet installed.
Add PostHog product analytics events
Use this skill to add product analytics events (capture calls) that track meaningful user actions in new or changed code. Use it after implementing features or reviewing PRs to ensure key user behaviors are captured. If PostHog is not yet installed, this skill also covers initial SDK setup. Supports any framework or language.
Supported frameworks and languages: Next.js, React Router, Nuxt, Vue, TanStack Start, SvelteKit, Astro, Angular, Django, Flask, FastAPI, Laravel, PHP, Ruby on Rails, Go, Elixir, Android, iOS, Flutter, React Native, Expo, and more.
Instructions
Follow these steps IN ORDER:
STEP 1: Analyze the codebase and detect the platform.
Look for dependency files (package.json, pubspec.yaml, Podfile, Package.swift, requirements.txt, Gemfile, composer.json, go.mod, mix.exs, etc.) to determine the framework and language.
Look for lockfiles (pnpm-lock.yaml, package-lock.json, yarn.lock, bun.lockb, go.sum, pubspec.lock, Podfile.lock, Package.resolved, mix.lock) to determine the package manager.
- Check for existing PostHog setup. If PostHog is already installed and initialized, skip to STEP 5.
STEP 2: Research integration. (Skip if PostHog is already set up.) 2.1. Find the reference file below that matches the detected framework — it is the source of truth for SDK initialization, provider setup, and event capture patterns. Read it now. 2.2. If no reference matches, fall back to your general knowledge and web search. Use posthog.com/docs as the primary search source.
STEP 3: Install the PostHog SDK. (Skip if PostHog is already set up.)
- Add the PostHog SDK package for the detected platform. Do not manually edit package.json — use the package manager's install command.
STEP 4: Initialize PostHog. (Skip if PostHog is already set up.)
- Follow the framework reference for where and how to initialize. This varies significantly by framework (e.g., instrumentation-client.ts for Next.js 15.3+, AppConfig.ready() for Django, create_app() for Flask).
STEP 5: Plan event tracking.
- From the project's file list, select between 10 and 15 files that might have interesting business value for event tracking, especially conversion and churn events.
- Also look for files related to login that could be used for identifying users, along with error handling.
- Find any existing
posthog.capture()code. Make note of event name formatting. Don't duplicate existing events; supplement them. - Track actions only, not pageviews (those can be captured automatically). Exceptions can be made for "viewed"-type events at the top of a conversion funnel.
- Server-side events are REQUIRED if the project includes any instrumentable server-side code (API routes, server actions, webhook handlers, payment/checkout completion, authentication endpoints).
STEP 6: Implement event capture.
- For each planned event, add
posthog.capture()calls with useful properties. - If a file already has existing integration code for other tools or services, don't overwrite or remove that code. Place PostHog code below it.
- Do not alter the fundamental architecture of existing files. Make additions minimal and targeted.
- You must read a file immediately before attempting to write it.
STEP 7: Identify users.
- Add PostHog
identify()calls on the client side during login and signup events. Use the contents of login and signup forms to identify users on submit. - If both frontend and backend exist, pass the client-side session and distinct ID using
X-POSTHOG-DISTINCT-IDandX-POSTHOG-SESSION-IDheaders to the server-side code. On the server side, make sure events have a matching distinct ID.
STEP 8: Add error tracking.
- Add PostHog exception capture error tracking to relevant files, particularly around critical user flows and API boundaries.
STEP 9: Set up environment variables.
- Check if the project already has PostHog environment variables configured (e.g. in
.env,.env.local, or framework-specific env files). If valid values already exist, skip this step. - If the PostHog project token is missing, use the PostHog MCP server's
projects-gettool to retrieve the project'sapi_token. If multiple projects are returned, ask the user which project to use. If the MCP server is not connected or not authenticated, ask the user for their PostHog project token instead. - For the PostHog host URL: check the
projects-getMCP response for aregionfield —USmaps tohttps://us.i.posthog.com,EUmaps tohttps://eu.i.posthog.com. If the region is not available from the MCP response or from existing project configuration, ask the user: "Are you on PostHog US Cloud or EU Cloud?" Do not assume US Cloud. - Write these values to the appropriate env file using the framework's naming convention.
- Reference these environment variables in code instead of hardcoding them.
STEP 10: Verify and clean up.
- Check the project for errors. Look for type checking or build scripts in package.json.
- Ensure any components created were actually used.
- Run any linter or prettier-like scripts found in the package.json, but ONLY on the files you have edited or created during this session. Never run formatting or linting across the entire project's codebase.
Reference files
references/EXAMPLE-next-app-router.md- next-app-router example project codereferences/EXAMPLE-next-pages-router.md- next-pages-router example project codereferences/EXAMPLE-react-react-router-6.md- react-react-router-6 example project codereferences/EXAMPLE-react-react-router-7-framework.md- react-react-router-7-framework example project codereferences/EXAMPLE-react-react-router-7-data.md- react-react-router-7-data example project codereferences/EXAMPLE-react-react-router-7-declarative.md- react-react-router-7-declarative example project codereferences/EXAMPLE-nuxt-3-6.md- nuxt-3-6 example project codereferences/EXAMPLE-nuxt-4.md- nuxt-4 example project codereferences/EXAMPLE-vue-3.md- vue-3 example project codereferences/EXAMPLE-react-tanstack-router-file-based.md- react-tanstack-router-file-based example project codereferences/EXAMPLE-react-tanstack-router-code-based.md- react-tanstack-router-code-based example project codereferences/EXAMPLE-tanstack-start.md- tanstack-start example project codereferences/EXAMPLE-sveltekit.md- sveltekit example project codereferences/EXAMPLE-astro-static.md- astro-static example project codereferences/EXAMPLE-astro-view-transitions.md- astro-view-transitions example project codereferences/EXAMPLE-astro-ssr.md- astro-ssr example project codereferences/EXAMPLE-astro-hybrid.md- astro-hybrid example project codereferences/EXAMPLE-angular.md- angular example project codereferences/EXAMPLE-django.md- django example project codereferences/EXAMPLE-flask.md- flask example project codereferences/EXAMPLE-fastapi.md- fastapi example project codereferences/EXAMPLE-python.md- python example project codereferences/EXAMPLE-laravel.md- laravel example project codereferences/EXAMPLE-php.md- php example project codereferences/EXAMPLE-ruby-on-rails.md- ruby-on-rails example project codereferences/EXAMPLE-ruby.md- ruby example project codereferences/EXAMPLE-android.md- android example project codereferences/EXAMPLE-swift.md- swift example project codereferences/EXAMPLE-react-native.md- react-native example project codereferences/EXAMPLE-expo.md- expo example project codereferences/next-js.md- Next.jsreferences/react-router-v6.md- React router v6references/react-router-v7-framework-mode.md- React router v7 framework mode (remix v3)references/react-router-v7-data-mode.md- React router v7 data modereferences/react-router-v7-declarative-mode.md- React router v7 declarative modereferences/nuxt-js-3-6.md- Nuxt.js (v3.0 to v3.6)references/nuxt-js.md- Nuxt.jsreferences/vue-js.md- Vue.jsreferences/tanstack-start.md- Tanstack startreferences/svelte.md- Sveltereferences/astro.md- Astroreferences/angular.md- Angularreferences/django.md- Djangoreferences/flask.md- Flaskreferences/python.md- Pythonreferences/posthog-python.md- PostHog python SDKreferences/dotnet.md- .netreferences/elixir.md- Elixirreferences/go.md- Goreferences/laravel.md- Laravelreferences/php.md- Phpreferences/ruby-on-rails.md- Ruby on railsreferences/ruby.md- Rubyreferences/android.md- Androidreferences/ios.md- Iosreferences/usage.md- Ios SDK usagereferences/configuration.md- Ios SDK configurationreferences/flutter.md- Flutterreferences/react-native.md- React nativereferences/identify-users.md- Identify usersreferences/COMMANDMENTS.md- Framework-specific rules the integration must follow
Each framework reference contains SDK-specific installation, initialization, and usage patterns. Find the one matching the user's stack.
Key principles
- Environment variables: Always use environment variables for PostHog keys. Never hardcode them.
- Minimal changes: Add PostHog code alongside existing integrations. Don't replace or restructure existing code.
- Match the docs: Follow the framework reference's initialization and capture patterns exactly.
- SKILL.md
- references/COMMANDMENTS.md
- references/EXAMPLE-android.md
- references/EXAMPLE-angular.md
- references/EXAMPLE-astro-hybrid.md
- references/EXAMPLE-astro-ssr.md
- references/EXAMPLE-astro-static.md
- references/EXAMPLE-astro-view-transitions.md
- references/EXAMPLE-django.md
- references/EXAMPLE-expo.md
- references/EXAMPLE-fastapi.md
- references/EXAMPLE-flask.md
- references/EXAMPLE-laravel.md
- references/EXAMPLE-next-app-router.md
- references/EXAMPLE-next-pages-router.md
- references/EXAMPLE-nuxt-3-6.md
- references/EXAMPLE-nuxt-4.md
- references/EXAMPLE-php.md
- references/EXAMPLE-python.md
- references/EXAMPLE-react-native.md
- references/EXAMPLE-react-react-router-6.md
- references/EXAMPLE-react-react-router-7-data.md
- references/EXAMPLE-react-react-router-7-declarative.md
- references/EXAMPLE-react-react-router-7-framework.md
- references/EXAMPLE-react-tanstack-router-code-based.md
- references/EXAMPLE-react-tanstack-router-file-based.md
- references/EXAMPLE-ruby-on-rails.md
- references/EXAMPLE-ruby.md
- references/EXAMPLE-sveltekit.md
- references/EXAMPLE-swift.md
- references/EXAMPLE-tanstack-start.md
- references/EXAMPLE-vue-3.md
- references/android.md
- references/angular.md
- references/astro.md
- references/configuration.md
- references/django.md
- references/dotnet.md
- references/elixir.md
- references/flask.md
- references/flutter.md
- references/go.md
- references/identify-users.md
- references/ios.md
- references/laravel.md
- references/next-js.md
- references/nuxt-js-3-6.md
- references/nuxt-js.md
- references/php.md
- references/posthog-python.md
- references/python.md
- references/react-native.md
- references/react-router-v6.md
- references/react-router-v7-data-mode.md
- references/react-router-v7-declarative-mode.md
- references/react-router-v7-framework-mode.md
- references/ruby-on-rails.md
- references/ruby.md
- references/svelte.md
- references/tanstack-start.md
- references/usage.md
- references/vue-js.md
SKILL.md
SKILL.md holds the skill's instructions; it is edited on the Instructions tab.
references/COMMANDMENTS.md
Framework rules
Follow these when integrating PostHog into this framework.
- A missing PostHog configuration must never break the app — read keys optionally (never a required setting), guard init and capture behind their presence, and keep build and boot working with no PostHog environment set — but never silently: in development or debug builds fail loudly, using the language's idiomatic error, with the message "<VAR> variable required by PostHog is missing or un-configured, this causes events to be silently missed. This error stops appearing once <VAR> is configured" (substituting the actual variable name); production stays a no-op
references/EXAMPLE-android.md
PostHog android Example Project
Repository: https://github.com/PostHog/context-mill Path: example-apps/android
README.md
PostHog Android example
This is an Android example demonstrating PostHog integration with product analytics, session replay, and error tracking using Kotlin and Jetpack Compose.
This example uses the PostHog Android SDK (posthog-android) to provide automatic PostHog integration with built-in error tracking, session replay, and simplified configuration.
Features
- Product Analytics: Track user events and behaviors
- Session Replay: Record and replay user sessions
- Error Tracking: Automatic error capture and crash reporting
- User Authentication: Demo login system with PostHog user identification
- Event Tracking: Examples of custom event tracking throughout the app
Getting Started
1. Prerequisites
- Android Studio (latest stable version)
- Android SDK (API level 24 or higher)
- JDK 11 or higher
- Gradle 8.0 or higher
- A PostHog account
2. Configure Environment Variables
The PostHog configuration is stored in local.properties (this file is gitignored):
# PostHog configuration
posthog.apiKey=your_posthog_project_token
posthog.host=https://us.i.posthog.comAlternatively, you can configure PostHog in your build.gradle file:
android {
defaultConfig {
buildConfigField "String", "POSTHOG_PROJECT_TOKEN", "\"your_posthog_project_token\""
buildConfigField "String", "POSTHOG_HOST", "\"https://us.i.posthog.com\""
}
}Get your PostHog project token from your PostHog project settings.
3. Build and Run
- Open the project in Android Studio
- Sync Gradle files
- Run the app on an emulator or physical device
Project Structure
├── app/
│ ├── src/
│ │ ├── main/
│ │ │ ├── java/com/example/posthog/
│ │ │ │ ├── BurritoApplication.kt # Application class with PostHog initialization
│ │ │ │ ├── MainActivity.kt # Main activity
│ │ │ │ ├── ui/
│ │ │ │ │ ├── screens/
│ │ │ │ │ │ ├── LoginScreen.kt # Login screen with user identification
│ │ │ │ │ │ ├── BurritoScreen.kt # Demo feature screen with event tracking
│ │ │ │ │ │ └── ProfileScreen.kt # User profile with error tracking demo
│ │ │ │ │ └── components/ # Reusable UI components
│ │ │ │ └── utils/
│ │ │ │ └── PostHogHelper.kt # PostHog utility functions
│ │ │ ├── res/ # Resources (layouts, strings, etc.)
│ │ │ └── AndroidManifest.xml # App manifest
│ │ └── test/ # Unit tests
│ └── build.gradle # App-level Gradle configuration
├── build.gradle # Project-level Gradle configuration
├── settings.gradle # Gradle settings
└── local.properties # Local configuration (gitignored)Key Integration Points
Application Initialization (BurritoApplication.kt)
PostHog is initialized in the Application class to ensure it's available throughout the app lifecycle:
class BurritoApplication : Application() {
override fun onCreate() {
super.onCreate()
val posthogConfig = PostHogConfig(
apiKey = BuildConfig.POSTHOG_PROJECT_TOKEN,
host = BuildConfig.POSTHOG_HOST
).apply {
// Enable session replay
sessionReplay = true
// Enable automatic exception capture
captureApplicationLifecycleEvents = true
captureDeepLinks = true
captureScreenViews = true
}
PostHog.setup(this, posthogConfig)
}
}Key Points:
- PostHog is initialized in
onCreate()to ensure it's initialized as early as possible - Configuration is loaded from
BuildConfig(set inbuild.gradle) - Session replay, lifecycle events, and screen views are enabled
- The Application class must be registered in
AndroidManifest.xml
User Identification (LoginScreen.kt)
Users are identified when they log in:
val posthog = PostHog.getInstance()
fun handleLogin(username: String, password: String) {
// Authenticate user
val success = authenticateUser(username, password)
if (success) {
// Identify the user once on login/sign up
posthog.identify(
distinctId = username,
properties = mapOf(
"username" to username,
"login_method" to "password"
)
)
// Capture login event
posthog.capture("user_logged_in", mapOf(
"username" to username
))
}
}Key Points:
identify()is called once when the user logs in or signs up- User properties can be set during identification
- Events are captured using
capture()with event names and properties - The
distinctIdshould be a unique identifier for the user
Event Tracking (BurritoScreen.kt)
Custom events are tracked throughout the app:
val posthog = PostHog.getInstance()
fun handleBurritoConsideration() {
// Track custom event
posthog.capture("burrito_considered", mapOf(
"total_considerations" to considerationCount,
"username" to currentUser.username,
"timestamp" to System.currentTimeMillis()
))
// Update user properties
posthog.setUserProperties(mapOf(
"last_burrito_consideration" to System.currentTimeMillis(),
"total_burrito_considerations" to considerationCount
))
}Key Points:
- Events are captured with
capture()method - Event properties provide context about the event
- User properties can be updated with
setUserProperties() - Properties can be strings, numbers, booleans, or dates
Error Tracking
Errors are captured automatically and can also be tracked manually:
Automatic Error Capture: PostHog automatically captures uncaught exceptions when configured:
val posthogConfig = PostHogConfig(
apiKey = BuildConfig.POSTHOG_PROJECT_TOKEN,
host = BuildConfig.POSTHOG_HOST
).apply {
// Automatic exception capture is enabled by default
captureApplicationLifecycleEvents = true
}Manual Error Capture:
val posthog = PostHog.getInstance()
try {
// Risky operation
performRiskyOperation()
} catch (e: Exception) {
// Capture exception manually
posthog.captureException(e, mapOf(
"context" to "burrito_consideration",
"user_id" to currentUser.id
))
}Screen View Tracking
Screen views are automatically tracked when captureScreenViews is enabled. You can also manually track screen views:
val posthog = PostHog.getInstance()
// Manual screen view tracking
posthog.screen("BurritoScreen", mapOf(
"screen_category" to "features",
"user_type" to "premium"
))Session Replay
Session replay is enabled in the PostHog configuration:
val posthogConfig = PostHogConfig(
apiKey = BuildConfig.POSTHOG_PROJECT_TOKEN,
host = BuildConfig.POSTHOG_HOST
).apply {
sessionReplay = true
sessionReplayConfig = SessionReplayConfig(
maskAllInputs = false, // Set to true to mask all input fields
maskAllText = false // Set to true to mask all text
)
}Accessing PostHog in Components
PostHog is accessed via the singleton instance:
val posthog = PostHog.getInstance()
posthog.capture("event_name", mapOf("property" to "value"))The instance is available throughout your application after initialization.
Gradle Configuration
App-level build.gradle
android {
defaultConfig {
// PostHog configuration
buildConfigField "String", "POSTHOG_PROJECT_TOKEN", "\"${project.findProperty("posthog.apiKey") ?: ""}\""
buildConfigField "String", "POSTHOG_HOST", "\"${project.findProperty("posthog.host") ?: "https://us.i.posthog.com"}\""
}
}
dependencies {
// PostHog Android SDK
implementation 'com.posthog:posthog-android:3.+'
// Other dependencies...
}Reading from local.properties
The local.properties file is automatically read by Gradle:
def localProperties = new Properties()
localProperties.load(new FileInputStream(rootProject.file("local.properties")))
android {
defaultConfig {
buildConfigField "String", "POSTHOG_PROJECT_TOKEN", "\"${localProperties.getProperty("posthog.apiKey", "")}\""
buildConfigField "String", "POSTHOG_HOST", "\"${localProperties.getProperty("posthog.host", "https://us.i.posthog.com")}\""
}
}Best Practices
- Initialize Early: Initialize PostHog in your
Application.onCreate()method - Identify Once: Call
identify()once when the user logs in or signs up - Use Meaningful Event Names: Use clear, descriptive event names (e.g.,
user_logged_ininstead oflogin) - Include Context: Add relevant properties to events for better analysis
- Handle Errors Gracefully: Don't let PostHog errors break your app
- Test in Development: Use a separate PostHog project for development/testing
- Respect Privacy: Be mindful of PII (Personally Identifiable Information) in events and properties
Learn More
app/src/main/java/com/example/posthog/BurritoApp.kt
package com.example.posthog
import android.app.Application
import com.posthog.android.PostHogAndroid
import com.posthog.android.PostHogAndroidConfig
class BurritoApplication : Application() {
override fun onCreate() {
super.onCreate()
// Initialize PostHog early in Application lifecycle
val config = PostHogAndroidConfig(
apiKey = BuildConfig.POSTHOG_PROJECT_TOKEN,
host = BuildConfig.POSTHOG_HOST,
).apply {
debug = true
errorTrackingConfig.autoCapture = true
}
PostHogAndroid.setup(this, config)
}
}
app/src/main/java/com/example/posthog/data/User.kt
package com.example.posthog.data
data class User(
val username: String,
val burritoConsiderations: Int = 0
)
app/src/main/java/com/example/posthog/data/UserRepository.kt
package com.example.posthog.data
import android.content.Context
import android.content.SharedPreferences
import org.json.JSONObject
class UserRepository(context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences(
PREFS_NAME, Context.MODE_PRIVATE
)
companion object {
private const val PREFS_NAME = "burrito_app_prefs"
private const val KEY_CURRENT_USERNAME = "current_username"
private const val KEY_USER_DATA_PREFIX = "user_data_"
}
fun getCurrentUsername(): String? {
return prefs.getString(KEY_CURRENT_USERNAME, null)
}
fun getUser(username: String): User? {
val json = prefs.getString("$KEY_USER_DATA_PREFIX$username", null) ?: return null
return try {
val obj = JSONObject(json)
User(
username = obj.getString("username"),
burritoConsiderations = obj.getInt("burritoConsiderations")
)
} catch (e: Exception) {
null
}
}
fun saveUser(user: User) {
val json = JSONObject().apply {
put("username", user.username)
put("burritoConsiderations", user.burritoConsiderations)
}.toString()
prefs.edit()
.putString("$KEY_USER_DATA_PREFIX${user.username}", json)
.putString(KEY_CURRENT_USERNAME, user.username)
.apply()
}
fun clearCurrentUser() {
prefs.edit()
.remove(KEY_CURRENT_USERNAME)
.apply()
}
fun getCurrentUser(): User? {
val username = getCurrentUsername() ?: return null
return getUser(username)
}
}
app/src/main/java/com/example/posthog/MainActivity.kt
package com.example.posthog
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.example.posthog.navigation.NavGraph
import com.example.posthog.navigation.Screen
import com.example.posthog.ui.components.AppHeader
import com.example.posthog.ui.components.BottomNavBar
import com.example.posthog.ui.theme.BackgroundGray
import com.example.posthog.ui.theme.PostHogTheme
import com.example.posthog.viewmodel.AuthViewModel
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
PostHogTheme {
BurritoApp()
}
}
}
}
@Composable
fun BurritoApp() {
val navController = rememberNavController()
val viewModel: AuthViewModel = viewModel()
val isAuthenticated by viewModel.isAuthenticated.collectAsState()
val currentUser by viewModel.currentUser.collectAsState()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
AppHeader(
isAuthenticated = isAuthenticated,
username = currentUser?.username,
currentRoute = currentRoute,
onNavigate = { route ->
navController.navigate(route) {
popUpTo(Screen.Home.route)
launchSingleTop = true
}
},
onLogout = {
viewModel.logout()
navController.navigate(Screen.Home.route) {
popUpTo(Screen.Home.route) { inclusive = true }
}
}
)
},
bottomBar = {
BottomNavBar(
isAuthenticated = isAuthenticated,
currentRoute = currentRoute,
onNavigate = { route ->
navController.navigate(route) {
popUpTo(Screen.Home.route)
launchSingleTop = true
}
}
)
},
containerColor = BackgroundGray
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.background(BackgroundGray)
.padding(innerPadding)
) {
NavGraph(
navController = navController,
viewModel = viewModel
)
}
}
}
app/src/main/java/com/example/posthog/navigation/NavGraph.kt
package com.example.posthog.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import com.example.posthog.ui.screens.BurritoScreen
import com.example.posthog.ui.screens.HomeScreen
import com.example.posthog.ui.screens.ProfileScreen
import com.example.posthog.viewmodel.AuthViewModel
sealed class Screen(val route: String) {
object Home : Screen("home")
object Burrito : Screen("burrito")
object Profile : Screen("profile")
}
@Composable
fun NavGraph(
navController: NavHostController,
viewModel: AuthViewModel
) {
val isAuthenticated by viewModel.isAuthenticated.collectAsState()
val currentUser by viewModel.currentUser.collectAsState()
NavHost(
navController = navController,
startDestination = Screen.Home.route
) {
composable(Screen.Home.route) {
HomeScreen(
isAuthenticated = isAuthenticated,
username = currentUser?.username,
onLogin = { username -> viewModel.login(username) }
)
}
composable(Screen.Burrito.route) {
if (!isAuthenticated) {
LaunchedEffect(Unit) {
navController.navigate(Screen.Home.route) {
popUpTo(Screen.Home.route) { inclusive = true }
}
}
} else {
BurritoScreen(
burritoCount = currentUser?.burritoConsiderations ?: 0,
onConsiderBurrito = { viewModel.incrementBurritoCount() }
)
}
}
composable(Screen.Profile.route) {
if (!isAuthenticated) {
LaunchedEffect(Unit) {
navController.navigate(Screen.Home.route) {
popUpTo(Screen.Home.route) { inclusive = true }
}
}
} else {
ProfileScreen(
username = currentUser?.username ?: "",
burritoCount = currentUser?.burritoConsiderations ?: 0
)
}
}
}
}
app/src/main/java/com/example/posthog/ui/components/AppHeader.kt
package com.example.posthog.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.posthog.ui.theme.DarkHeader
import com.example.posthog.ui.theme.ErrorRed
import com.example.posthog.ui.theme.White
@Composable
fun AppHeader(
isAuthenticated: Boolean,
username: String?,
currentRoute: String?,
onNavigate: (String) -> Unit,
onLogout: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(DarkHeader)
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// App title
Text(
text = "Burrito App",
color = White,
fontSize = 18.sp
)
// User section (right side)
if (isAuthenticated && username != null) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = username,
color = White,
fontSize = 14.sp
)
Button(
onClick = onLogout,
colors = ButtonDefaults.buttonColors(
containerColor = ErrorRed
),
shape = RoundedCornerShape(4.dp),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp)
) {
Text(
text = "Logout",
color = White,
fontSize = 14.sp
)
}
}
}
}
}
}
app/src/main/java/com/example/posthog/ui/components/BottomNavBar.kt
package com.example.posthog.ui.components
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.posthog.navigation.Screen
import com.example.posthog.ui.theme.PrimaryBlue
import com.example.posthog.ui.theme.TextGray
import com.example.posthog.ui.theme.White
sealed class BottomNavItem(
val route: String,
val label: String,
val selectedIcon: ImageVector?,
val unselectedIcon: ImageVector?
) {
object Home : BottomNavItem(
route = Screen.Home.route,
label = "Home",
selectedIcon = Icons.Filled.Home,
unselectedIcon = Icons.Outlined.Home
)
object Burrito : BottomNavItem(
route = Screen.Burrito.route,
label = "Burrito",
selectedIcon = null, // We'll use a custom icon or emoji
unselectedIcon = null
)
object Profile : BottomNavItem(
route = Screen.Profile.route,
label = "Profile",
selectedIcon = Icons.Filled.Person,
unselectedIcon = Icons.Outlined.Person
)
}
@Composable
fun BottomNavBar(
isAuthenticated: Boolean,
currentRoute: String?,
onNavigate: (String) -> Unit
) {
val items = if (isAuthenticated) {
listOf(BottomNavItem.Home, BottomNavItem.Burrito, BottomNavItem.Profile)
} else {
listOf(BottomNavItem.Home)
}
NavigationBar(
containerColor = White
) {
items.forEach { item ->
val selected = currentRoute == item.route
NavigationBarItem(
selected = selected,
onClick = { onNavigate(item.route) },
icon = {
if (item.selectedIcon != null && item.unselectedIcon != null) {
Icon(
imageVector = if (selected) item.selectedIcon else item.unselectedIcon,
contentDescription = item.label,
modifier = Modifier.size(24.dp)
)
} else {
// For Burrito, use text emoji as icon
Text(
text = "🌯",
fontSize = 24.sp
)
}
},
label = {
Text(
text = item.label,
fontSize = 12.sp
)
},
colors = NavigationBarItemDefaults.colors(
selectedIconColor = PrimaryBlue,
selectedTextColor = PrimaryBlue,
unselectedIconColor = TextGray,
unselectedTextColor = TextGray,
indicatorColor = PrimaryBlue.copy(alpha = 0.1f)
)
)
}
}
}
app/src/main/java/com/example/posthog/ui/components/StatsCard.kt
package com.example.posthog.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.posthog.ui.theme.LightGray
import com.example.posthog.ui.theme.TextDark
import com.example.posthog.ui.theme.TextGray
@Composable
fun StatsCard(
title: String,
value: String,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxWidth()
.background(
color = LightGray,
shape = RoundedCornerShape(4.dp)
)
.padding(16.dp)
) {
Text(
text = title,
color = TextGray,
fontSize = 14.sp
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = value,
color = TextDark,
fontSize = 24.sp,
fontWeight = FontWeight.Bold
)
}
}
app/src/main/java/com/example/posthog/ui/screens/BurritoScreen.kt
package com.example.posthog.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.posthog.ui.components.StatsCard
import com.example.posthog.ui.theme.BackgroundGray
import com.example.posthog.ui.theme.SuccessGreen
import com.example.posthog.ui.theme.TextDark
import com.example.posthog.ui.theme.TextGray
import com.example.posthog.ui.theme.White
import kotlinx.coroutines.delay
@Composable
fun BurritoScreen(
burritoCount: Int,
onConsiderBurrito: () -> Unit
) {
var showSuccess by remember { mutableStateOf(false) }
LaunchedEffect(showSuccess) {
if (showSuccess) {
delay(2000)
showSuccess = false
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(BackgroundGray)
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
contentAlignment = Alignment.TopCenter
) {
Column(
modifier = Modifier
.widthIn(max = 600.dp)
.padding(vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Main content card
Column(
modifier = Modifier
.fillMaxWidth()
.shadow(
elevation = 4.dp,
shape = RoundedCornerShape(8.dp),
ambientColor = TextDark.copy(alpha = 0.1f),
spotColor = TextDark.copy(alpha = 0.1f)
)
.background(
color = White,
shape = RoundedCornerShape(8.dp)
)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Burrito consideration zone",
fontSize = 24.sp,
fontWeight = FontWeight.SemiBold,
color = TextDark,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Take a moment to truly consider the burrito.",
fontSize = 16.sp,
color = TextGray,
textAlign = TextAlign.Center,
lineHeight = 26.sp
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = {
onConsiderBurrito()
showSuccess = true
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
colors = ButtonDefaults.buttonColors(
containerColor = SuccessGreen
),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = "Consider the Burrito",
fontSize = 18.sp,
color = White
)
}
if (showSuccess) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "You have considered the burrito. Well done!",
color = SuccessGreen,
fontSize = 16.sp,
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Consideration stats",
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
color = TextDark,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
StatsCard(
title = "Total Burrito Considerations",
value = burritoCount.toString()
)
}
}
}
}
app/src/main/java/com/example/posthog/ui/screens/HomeScreen.kt
package com.example.posthog.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.posthog.ui.theme.BackgroundGray
import com.example.posthog.ui.theme.BorderGray
import com.example.posthog.ui.theme.PrimaryBlue
import com.example.posthog.ui.theme.TextDark
import com.example.posthog.ui.theme.TextGray
import com.example.posthog.ui.theme.White
@Composable
fun HomeScreen(
isAuthenticated: Boolean,
username: String?,
onLogin: (String) -> Unit
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(BackgroundGray)
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
contentAlignment = if (isAuthenticated) Alignment.TopCenter else Alignment.Center
) {
Column(
modifier = Modifier
.widthIn(max = 600.dp)
.padding(vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (isAuthenticated && username != null) {
LoggedInContent(username = username)
} else {
LoginForm(onLogin = onLogin)
}
}
}
}
@Composable
private fun LoggedInContent(username: String) {
ContentCard {
Text(
text = "Welcome back, $username!",
fontSize = 24.sp,
fontWeight = FontWeight.SemiBold,
color = TextDark
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Ready to consider some burritos?",
fontSize = 16.sp,
color = TextGray,
lineHeight = 26.sp
)
}
}
@Composable
private fun LoginForm(onLogin: (String) -> Unit) {
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
ContentCard {
Text(
text = "Welcome to Burrito Consideration App",
fontSize = 24.sp,
fontWeight = FontWeight.SemiBold,
color = TextDark,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(24.dp))
// Username field
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = "Username",
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = TextDark,
modifier = Modifier.padding(bottom = 8.dp)
)
OutlinedTextField(
value = username,
onValueChange = { username = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(4.dp),
colors = OutlinedTextFieldDefaults.colors(
unfocusedBorderColor = BorderGray,
focusedBorderColor = PrimaryBlue,
unfocusedContainerColor = White,
focusedContainerColor = White
)
)
}
Spacer(modifier = Modifier.height(16.dp))
// Password field
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = "Password",
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = TextDark,
modifier = Modifier.padding(bottom = 8.dp)
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
shape = RoundedCornerShape(4.dp),
colors = OutlinedTextFieldDefaults.colors(
unfocusedBorderColor = BorderGray,
focusedBorderColor = PrimaryBlue,
unfocusedContainerColor = White,
focusedContainerColor = White
)
)
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
if (username.isNotBlank()) {
onLogin(username)
}
},
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
colors = ButtonDefaults.buttonColors(
containerColor = PrimaryBlue
),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = "Sign In",
fontSize = 16.sp,
color = White
)
}
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Note: This is a demo app. Enter any username to sign in.",
fontSize = 14.sp,
color = TextGray,
textAlign = TextAlign.Center,
lineHeight = 21.sp
)
}
}
@Composable
private fun ContentCard(
content: @Composable () -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.shadow(
elevation = 4.dp,
shape = RoundedCornerShape(8.dp),
ambientColor = TextDark.copy(alpha = 0.1f),
spotColor = TextDark.copy(alpha = 0.1f)
)
.background(
color = White,
shape = RoundedCornerShape(8.dp)
)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
content()
}
}
app/src/main/java/com/example/posthog/ui/screens/ProfileScreen.kt
package com.example.posthog.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.posthog.ui.components.StatsCard
import com.example.posthog.ui.theme.BackgroundGray
import com.example.posthog.ui.theme.TextDark
import com.example.posthog.ui.theme.TextGray
import com.example.posthog.ui.theme.White
@Composable
fun ProfileScreen(
username: String,
burritoCount: Int
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(BackgroundGray)
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
contentAlignment = Alignment.TopCenter
) {
Column(
modifier = Modifier
.widthIn(max = 600.dp)
.padding(vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Main content card
Column(
modifier = Modifier
.fillMaxWidth()
.shadow(
elevation = 4.dp,
shape = RoundedCornerShape(8.dp),
ambientColor = TextDark.copy(alpha = 0.1f),
spotColor = TextDark.copy(alpha = 0.1f)
)
.background(
color = White,
shape = RoundedCornerShape(8.dp)
)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "User Profile",
fontSize = 24.sp,
fontWeight = FontWeight.SemiBold,
color = TextDark
)
Spacer(modifier = Modifier.height(24.dp))
// Your Information section
Text(
text = "Your Information",
fontSize = 24.sp,
fontWeight = FontWeight.SemiBold,
color = TextDark,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
// Username display
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = "Username",
fontSize = 14.sp,
color = TextGray
)
Text(
text = username,
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
color = TextDark
)
}
Spacer(modifier = Modifier.height(24.dp))
// Stats card
StatsCard(
title = "Total Burrito Considerations",
value = burritoCount.toString()
)
Spacer(modifier = Modifier.height(24.dp))
// Your Burrito Journey section
Text(
text = "Your Burrito Journey",
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
color = TextDark,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = getJourneyMessage(burritoCount),
fontSize = 16.sp,
color = TextGray,
textAlign = TextAlign.Start,
lineHeight = 26.sp,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
private fun getJourneyMessage(count: Int): String = when {
count == 0 -> "You haven't considered any burritos yet. Start your journey!"
count == 1 -> "You've considered the burrito potential once. The journey begins!"
count in 2..4 -> "You're getting the hang of burrito consideration!"
count in 5..9 -> "You're becoming a burrito consideration expert!"
else -> "You are a true burrito consideration master!"
}
app/src/main/java/com/example/posthog/ui/theme/Color.kt
package com.example.posthog.ui.theme
import androidx.compose.ui.graphics.Color
val PrimaryBlue = Color(0xFF0070F3)
val PrimaryBlueHover = Color(0xFF0051CC)
val SuccessGreen = Color(0xFF28A745)
val SuccessGreenHover = Color(0xFF218838)
val ErrorRed = Color(0xFFDC3545)
val ErrorRedHover = Color(0xFFC82333)
val DarkHeader = Color(0xFF333333)
val DarkHeaderHover = Color(0xFF555555)
val LightGray = Color(0xFFF8F9FA)
val BorderGray = Color(0xFFDDDDDD)
val TextGray = Color(0xFF666666)
val BackgroundGray = Color(0xFFF5F5F5)
val TextDark = Color(0xFF333333)
val White = Color(0xFFFFFFFF)
app/src/main/java/com/example/posthog/ui/theme/Theme.kt
package com.example.posthog.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
private val LightColorScheme = lightColorScheme(
primary = PrimaryBlue,
secondary = SuccessGreen,
tertiary = DarkHeader,
background = BackgroundGray,
surface = White,
onPrimary = White,
onSecondary = White,
onTertiary = White,
onBackground = TextDark,
onSurface = TextDark
)
@Composable
fun PostHogTheme(
content: @Composable () -> Unit
) {
MaterialTheme(
colorScheme = LightColorScheme,
typography = Typography,
content = content
)
}app/src/main/java/com/example/posthog/ui/theme/Type.kt
package com.example.posthog.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Typography based on design specification
// Uses system font stack (FontFamily.Default maps to Roboto on Android)
val Typography = Typography(
// H1 - Page titles (32sp)
displayLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 32.sp,
lineHeight = 40.sp,
letterSpacing = 0.sp
),
// H2 - Section titles (24sp)
displayMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp,
lineHeight = 32.sp,
letterSpacing = 0.sp
),
// H3 - Subsection titles (20sp)
displaySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 20.sp,
lineHeight = 26.sp,
letterSpacing = 0.sp
),
// Body text (16sp with 1.6 line height = 25.6sp)
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 26.sp,
letterSpacing = 0.sp
),
// Small/Note text (14sp)
bodySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 21.sp,
letterSpacing = 0.sp
),
// Labels (16sp, medium weight)
labelLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.sp
),
// Button text - Burrito button (18sp)
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 18.sp,
lineHeight = 24.sp,
letterSpacing = 0.sp
),
// Button text - Primary/Logout (16sp/14sp)
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.sp
),
titleSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.sp
)
)app/src/main/java/com/example/posthog/viewmodel/AuthViewModel.kt
package com.example.posthog.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.example.posthog.data.User
import com.example.posthog.data.UserRepository
import com.posthog.PostHog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class AuthViewModel(application: Application) : AndroidViewModel(application) {
private val repository = UserRepository(application)
private val _currentUser = MutableStateFlow<User?>(null)
val currentUser: StateFlow<User?> = _currentUser.asStateFlow()
private val _isAuthenticated = MutableStateFlow(false)
val isAuthenticated: StateFlow<Boolean> = _isAuthenticated.asStateFlow()
init {
loadCurrentUser()
}
private fun loadCurrentUser() {
viewModelScope.launch {
val user = repository.getCurrentUser()
_currentUser.value = user
_isAuthenticated.value = user != null
}
}
fun login(username: String) {
viewModelScope.launch {
val existingUser = repository.getUser(username)
val user = existingUser ?: User(username = username, burritoConsiderations = 0)
repository.saveUser(user)
_currentUser.value = user
_isAuthenticated.value = true
PostHog.identify(username)
PostHog.capture(event = "user_logged_in")
}
}
fun logout() {
viewModelScope.launch {
PostHog.capture("user_logged_out")
PostHog.reset()
repository.clearCurrentUser()
_currentUser.value = null
_isAuthenticated.value = false
}
}
fun incrementBurritoCount() {
viewModelScope.launch {
val user = _currentUser.value ?: return@launch
val updatedUser = user.copy(burritoConsiderations = user.burritoConsiderations + 1)
repository.saveUser(updatedUser)
_currentUser.value = updatedUser
PostHog.capture(
event = "burrito_considered",
properties = mapOf(
"total_considerations" to updatedUser.burritoConsiderations,
"username" to updatedUser.username
)
)
}
}
}
references/EXAMPLE-angular.md
PostHog angular Example Project
Repository: https://github.com/PostHog/context-mill Path: example-apps/angular
README.md
PostHog Angular Example
This is an Angular example demonstrating PostHog integration with product analytics, session replay, and error tracking.
Features
- Product analytics: Track user events and behaviors
- Session replay: Record and replay user sessions
- Error tracking: Capture and track errors
- User authentication: Demo login system with PostHog user identification
- SSR-safe: Uses platform checks for browser-only PostHog calls
- Reverse proxy: PostHog ingestion through Angular proxy
Getting started
1. Install dependencies
pnpm install2. Configure environment variables
Create a .env file in the root directory:
VITE_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
VITE_POSTHOG_HOST=https://us.posthog.comGet your PostHog project token from your PostHog project settings.
3. Run the development server
pnpm startOpen http://localhost:3000 with your browser to see the app.
Project structure
src/
├── app/
│ ├── components/
│ │ └── header/ # Navigation header with auth state
│ ├── pages/
│ │ ├── home/ # Home/Login page
│ │ ├── burrito/ # Demo feature page with event tracking
│ │ └── profile/ # User profile with error tracking demo
│ ├── services/
│ │ ├── posthog.service.ts # PostHog service wrapper (SSR-safe)
│ │ └── auth.service.ts # Auth service with PostHog integration
│ ├── guards/
│ │ └── auth.guard.ts # Route guard for protected pages
│ ├── app.component.ts # Root component with PostHog init
│ ├── app.routes.ts # Route definitions
│ └── app.config.ts # App configuration
├── environments/
│ ├── environment.ts # Dev environment config
│ └── environment.production.ts
└── main.ts # App entry pointKey integration points
PostHog service (services/posthog.service.ts)
A wrapper service that handles SSR safety and provides access to the PostHog instance:
import { Injectable, inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import posthog from 'posthog-js';
@Injectable({ providedIn: 'root' })
export class PostHogService {
private readonly platformId = inject(PLATFORM_ID);
get posthog(): typeof posthog {
if (isPlatformBrowser(this.platformId)) {
return posthog;
}
// Return a no-op proxy for SSR safety
return new Proxy({} as typeof posthog, {
get: () => () => undefined,
});
}
init(apiKey: string, options: Partial<PostHogConfig>): void {
if (isPlatformBrowser(this.platformId)) {
posthog.init(apiKey, options);
}
}
}PostHog initialization (app.component.ts)
PostHog is initialized in the root component's ngOnInit:
import { PostHogService } from './services/posthog.service';
import { environment } from '../environments/environment';
export class AppComponent implements OnInit {
private readonly posthogService = inject(PostHogService);
ngOnInit(): void {
this.posthogService.init(environment.posthogKey, {
api_host: '/ingest',
ui_host: environment.posthogHost || 'https://us.posthog.com',
capture_exceptions: true,
});
}
}User identification (services/auth.service.ts)
import { PostHogService } from './posthog.service';
const posthogService = inject(PostHogService);
posthogService.posthog.identify(username, {
username,
isNewUser,
});Event tracking (pages/burrito/burrito.component.ts)
import { PostHogService } from '../../services/posthog.service';
const posthogService = inject(PostHogService);
posthogService.posthog.capture('burrito_considered', {
total_considerations: count,
username: username,
});Error tracking (pages/profile/profile.component.ts)
posthogService.posthog.captureException(error);Angular-specific details
This example uses Angular 21 with modern features:
- Standalone components: No NgModules, all components use
standalone: true - Signals: Reactive state management with Angular signals
- SSR support: Uses
isPlatformBrowser()checks for SSR safety - Dependency injection: PostHog wrapped in an injectable service
- Proxy configuration: Uses
proxy.conf.jsonfor PostHog API calls - Environment files: Generated from
.envat build time via prebuild script
Environment variable handling
Angular CLI doesn't natively support .env files. This project uses a prebuild script:
scripts/generate-env.jsreads.envand generatesenvironment.generated.ts- The script runs automatically before
pnpm startandpnpm build - Environment files import from the generated file
Learn more
.env.example
NG_APP_POSTHOG_PROJECT_TOKEN=<ph_project_token>
NG_APP_POSTHOG_HOST=https://us.posthog.com
src/app/app.component.ts
import {
Component,
inject,
OnInit,
PLATFORM_ID,
ChangeDetectionStrategy,
} from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { RouterOutlet } from '@angular/router';
import { HeaderComponent } from './components/header/header.component';
import { PostHogService } from './services/posthog.service';
import { environment } from '../environments/environment';
@Component({
selector: 'app-root',
imports: [RouterOutlet, HeaderComponent],
template: `
<app-header />
<router-outlet />
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent implements OnInit {
private readonly platformId = inject(PLATFORM_ID);
private readonly posthogService = inject(PostHogService);
ngOnInit(): void {
if (isPlatformBrowser(this.platformId)) {
this.posthogService.init(environment.posthogKey, {
api_host: '/ingest',
ui_host: environment.posthogHost || 'https://us.posthog.com',
capture_exceptions: true,
});
}
}
}
src/app/app.config.server.ts
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering, withRoutes } from '@angular/ssr';
import { appConfig } from './app.config';
import { serverRoutes } from './app.routes.server';
const serverConfig: ApplicationConfig = {
providers: [provideServerRendering(withRoutes(serverRoutes))],
};
export const config = mergeApplicationConfig(appConfig, serverConfig);
src/app/app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import {
provideClientHydration,
withEventReplay,
} from '@angular/platform-browser';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes, withComponentInputBinding()),
provideHttpClient(withFetch()),
provideClientHydration(withEventReplay()),
],
};
src/app/app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{
path: '',
renderMode: RenderMode.Server,
},
{
path: 'burrito',
renderMode: RenderMode.Client, // Protected route, render client-side
},
{
path: 'profile',
renderMode: RenderMode.Client, // Protected route, render client-side
},
{
path: '**',
renderMode: RenderMode.Server,
},
];
src/app/app.routes.ts
import { Routes } from '@angular/router';
import { authGuard } from './guards/auth.guard';
export const routes: Routes = [
{
path: '',
title: 'Burrito Consideration App',
loadComponent: () =>
import('./pages/home/home.component').then((m) => m.HomeComponent),
},
{
path: 'burrito',
title: 'Burrito Consideration - Burrito Consideration App',
loadComponent: () =>
import('./pages/burrito/burrito.component').then(
(m) => m.BurritoComponent
),
canActivate: [authGuard],
},
{
path: 'profile',
title: 'Profile - Burrito Consideration App',
loadComponent: () =>
import('./pages/profile/profile.component').then(
(m) => m.ProfileComponent
),
canActivate: [authGuard],
},
{
path: '**',
redirectTo: '',
},
];
src/app/components/header/header.component.ts
import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
import { RouterLink } from '@angular/router';
import { AuthService } from '../../services/auth.service';
@Component({
selector: 'app-header',
imports: [RouterLink],
template: `
<a class="skip-link" href="#main-content">Skip to main content</a>
<header class="header" role="banner">
<div class="header-container">
<nav aria-label="Main navigation">
<a routerLink="/">Home</a>
@if (auth.isAuthenticated()) {
<a routerLink="/burrito">Burrito Consideration</a>
<a routerLink="/profile">Profile</a>
}
</nav>
<div class="user-section">
@if (auth.user(); as user) {
<span>Welcome, {{ user.username }}!</span>
<button (click)="auth.logout()" class="btn-logout">Logout</button>
} @else {
<span>Not logged in</span>
}
</div>
</div>
</header>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HeaderComponent {
readonly auth = inject(AuthService);
}
src/app/guards/auth.guard.ts
import { inject } from '@angular/core';
import { Router, CanActivateFn } from '@angular/router';
import { AuthService } from '../services/auth.service';
export const authGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isAuthenticated()) {
return true;
}
return router.createUrlTree(['/']);
};
src/app/pages/burrito/burrito.component.ts
import {
Component,
inject,
signal,
ChangeDetectionStrategy,
} from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from '../../services/auth.service';
import { PostHogService } from '../../services/posthog.service';
@Component({
selector: 'app-burrito',
template: `
<main id="main-content" tabindex="-1">
<div class="container">
<h1>Burrito consideration zone</h1>
<p>Take a moment to truly consider the potential of burritos.</p>
<div style="text-align: center">
<button (click)="handleConsideration()" class="btn-burrito">
I have considered the burrito potential
</button>
@if (hasConsidered()) {
<p class="success" role="status" aria-live="polite">
Thank you for your consideration! Count:
{{ auth.user()?.burritoConsiderations }}
</p>
}
</div>
<div class="stats">
<h3>Consideration stats</h3>
<p>Total considerations: {{ auth.user()?.burritoConsiderations }}</p>
</div>
</div>
</main>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BurritoComponent {
readonly auth = inject(AuthService);
private readonly posthogService = inject(PostHogService);
private readonly router = inject(Router);
hasConsidered = signal(false);
constructor() {
// Redirect if not authenticated
if (!this.auth.isAuthenticated()) {
this.router.navigate(['/']);
}
}
handleConsideration(): void {
const user = this.auth.user();
if (!user) return;
this.auth.incrementBurritoConsiderations();
this.hasConsidered.set(true);
setTimeout(() => this.hasConsidered.set(false), 2000);
this.posthogService.posthog.capture('burrito_considered', {
total_considerations: user.burritoConsiderations + 1,
username: user.username,
});
}
}
src/app/pages/home/home.component.ts
import {
Component,
inject,
signal,
ChangeDetectionStrategy,
} from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { AuthService } from '../../services/auth.service';
@Component({
selector: 'app-home',
imports: [ReactiveFormsModule],
template: `
<main id="main-content" tabindex="-1">
@if (auth.user(); as user) {
<div class="container">
<h1>Welcome back, {{ user.username }}!</h1>
<p>You are logged in. Feel free to explore:</p>
<ul>
<li>Consider the potential of burritos</li>
<li>View your profile and statistics</li>
</ul>
</div>
} @else {
<div class="container">
<h1>Welcome to Burrito Consideration App</h1>
<p>Please sign in to begin your burrito journey</p>
<form [formGroup]="loginForm" (ngSubmit)="handleSubmit()" class="form">
<div class="form-group">
<label for="username">Username:</label>
<input
type="text"
id="username"
formControlName="username"
placeholder="Enter any username"
autocomplete="username"
/>
</div>
<div class="form-group">
<label for="password">Password:</label>
<input
type="password"
id="password"
formControlName="password"
placeholder="Enter any password"
autocomplete="current-password"
/>
</div>
@if (error()) {
<p class="error" role="alert">{{ error() }}</p>
}
<button type="submit" class="btn-primary">Sign In</button>
</form>
<p class="note">
Note: This is a demo app. Use any username and password to sign in.
</p>
</div>
}
</main>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HomeComponent {
private readonly fb = inject(FormBuilder);
readonly auth = inject(AuthService);
loginForm = this.fb.nonNullable.group({
username: ['', Validators.required],
password: ['', Validators.required],
});
error = signal('');
handleSubmit(): void {
this.error.set('');
if (this.loginForm.invalid) {
this.error.set('Please provide both username and password');
return;
}
const { username, password } = this.loginForm.getRawValue();
const success = this.auth.login(username, password);
if (success) {
this.loginForm.reset();
} else {
this.error.set('Please provide both username and password');
}
}
}
src/app/pages/profile/profile.component.ts
import {
Component,
inject,
computed,
ChangeDetectionStrategy,
} from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from '../../services/auth.service';
import { PostHogService } from '../../services/posthog.service';
@Component({
selector: 'app-profile',
template: `
<main id="main-content" tabindex="-1">
<div class="container">
<h1>User Profile</h1>
<div class="stats">
<h2>Your Information</h2>
<p><strong>Username:</strong> {{ auth.user()?.username }}</p>
<p>
<strong>Burrito Considerations:</strong>
{{ auth.user()?.burritoConsiderations }}
</p>
</div>
<div style="margin-top: 2rem">
<button
(click)="triggerTestError()"
class="btn-primary"
style="background-color: #dc3545"
>
Trigger Test Error (for PostHog)
</button>
</div>
<div style="margin-top: 2rem">
<h3>Your Burrito Journey</h3>
<p>{{ journeyMessage() }}</p>
</div>
</div>
</main>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProfileComponent {
readonly auth = inject(AuthService);
private readonly posthogService = inject(PostHogService);
private readonly router = inject(Router);
journeyMessage = computed(() => {
const count = this.auth.user()?.burritoConsiderations ?? 0;
if (count === 0) {
return "You haven't considered any burritos yet. Visit the Burrito Consideration page to start!";
} else if (count === 1) {
return "You've considered the burrito potential once. Keep going!";
} else if (count < 5) {
return "You're getting the hang of burrito consideration!";
} else if (count < 10) {
return "You're becoming a burrito consideration expert!";
} else {
return 'You are a true burrito consideration master!';
}
});
constructor() {
if (!this.auth.isAuthenticated()) {
this.router.navigate(['/']);
}
}
triggerTestError(): void {
try {
throw new Error('Test error for PostHog error tracking');
} catch (err) {
const error = err as Error;
this.posthogService.posthog.captureException(error);
console.error('Captured error:', err);
alert('Error captured and sent to PostHog!');
}
}
}
src/app/services/auth.service.ts
import {
Injectable,
signal,
computed,
inject,
PLATFORM_ID,
} from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { PostHogService } from './posthog.service';
export interface User {
username: string;
burritoConsiderations: number;
}
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly platformId = inject(PLATFORM_ID);
private readonly posthogService = inject(PostHogService);
// In-memory user store (matches TanStack behavior)
private readonly users = new Map<string, User>();
// Signals for reactive state
private readonly _user = signal<User | null>(null);
// Public computed signals
readonly user = this._user.asReadonly();
readonly isAuthenticated = computed(() => this._user() !== null);
constructor() {
// Initialize from localStorage on browser
if (isPlatformBrowser(this.platformId)) {
const storedUsername = localStorage.getItem('currentUser');
if (storedUsername) {
const existingUser = this.users.get(storedUsername);
if (existingUser) {
this._user.set(existingUser);
}
}
}
}
login(username: string, password: string): boolean {
if (!username || !password) {
return false;
}
// Get or create user in local map (no API call)
let user = this.users.get(username);
const isNewUser = !user;
if (!user) {
user = { username, burritoConsiderations: 0 };
this.users.set(username, user);
}
this._user.set(user);
if (isPlatformBrowser(this.platformId)) {
localStorage.setItem('currentUser', username);
}
// PostHog identification (client-side only)
this.posthogService.posthog.identify(username, {
username,
isNewUser,
});
this.posthogService.posthog.capture('user_logged_in', {
username,
isNewUser,
});
return true;
}
logout(): void {
this.posthogService.posthog.capture('user_logged_out');
this.posthogService.posthog.reset();
this._user.set(null);
if (isPlatformBrowser(this.platformId)) {
localStorage.removeItem('currentUser');
}
}
incrementBurritoConsiderations(): void {
const currentUser = this._user();
if (currentUser) {
const updated = {
...currentUser,
burritoConsiderations: currentUser.burritoConsiderations + 1,
};
this.users.set(currentUser.username, updated);
this._user.set(updated);
}
}
}
src/app/services/posthog.service.ts
import { Injectable, inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import posthog, { PostHogConfig } from 'posthog-js';
@Injectable({ providedIn: 'root' })
export class PostHogService {
private readonly platformId = inject(PLATFORM_ID);
private initialized = false;
/**
* The posthog instance. Use this directly to call posthog methods.
* Returns the actual posthog instance on browser, or a no-op proxy on server.
*/
get posthog(): typeof posthog {
if (isPlatformBrowser(this.platformId) && this.initialized) {
return posthog;
}
// Return a no-op proxy for SSR safety
return new Proxy({} as typeof posthog, {
get: () => () => undefined,
});
}
init(apiKey: string, options: Partial<PostHogConfig>): void {
if (isPlatformBrowser(this.platformId) && !this.initialized) {
posthog.init(apiKey, options);
this.initialized = true;
}
}
}
src/env.d.ts
// Define the type of the environment variables.
declare interface Env {
readonly NODE_ENV: string;
readonly NG_APP_POSTHOG_PROJECT_TOKEN: string;
readonly NG_APP_POSTHOG_HOST: string;
}
// Use import.meta.env.YOUR_ENV_VAR in your code.
declare interface ImportMeta {
readonly env: Env;
}
src/environments/environment.prod.ts
export const environment = {
production: true,
posthogKey: import.meta.env['NG_APP_POSTHOG_PROJECT_TOKEN'] || '<ph_project_token>',
posthogHost: import.meta.env['NG_APP_POSTHOG_HOST'] || 'https://us.posthog.com',
};
src/environments/environment.production.ts
export const environment = {
production: true,
posthogKey: import.meta.env['NG_APP_POSTHOG_PROJECT_TOKEN'] || '<ph_project_token>',
posthogHost: import.meta.env['NG_APP_POSTHOG_HOST'] || 'https://us.posthog.com',
};
src/environments/environment.ts
export const environment = {
production: false,
posthogKey: import.meta.env['NG_APP_POSTHOG_PROJECT_TOKEN'] || '<ph_project_token>',
posthogHost: import.meta.env['NG_APP_POSTHOG_HOST'] || 'https://us.posthog.com',
};
src/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Burrito Consideration App</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Consider the potential of burritos">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>
src/main.server.ts
import { bootstrapApplication, BootstrapContext } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { config } from './app/app.config.server';
const bootstrap = (context: BootstrapContext) =>
bootstrapApplication(AppComponent, config, context);
export default bootstrap;
src/main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig).catch((err) =>
console.error(err)
);
references/EXAMPLE-astro-hybrid.md
PostHog astro-hybrid Example Project
Repository: https://github.com/PostHog/context-mill Path: example-apps/astro-hybrid
README.md
PostHog Astro Hybrid Example
This is an Astro hybrid rendering example demonstrating PostHog integration with both static and on-demand rendered pages.
Hybrid mode allows you to have most pages prerendered (static) while opting specific pages into server-side rendering (SSR) when needed.
It uses:
- Client-side: PostHog web snippet for browser analytics
- Server-side:
posthog-nodefor API route event tracking
This shows how to:
- Configure Astro for hybrid rendering (static default with per-page SSR opt-in)
- Opt specific pages into SSR with
export const prerender = false - Keep most pages static for performance
- Track events from API routes using
posthog-node - Link client and server sessions automatically with the
tracing_headersoption
Features
- Hybrid rendering: Static pages by default, SSR when needed
- API routes: Server-side endpoints for auth and event tracking
- Dual tracking: Events captured on both client and server
- Session continuity: Session and distinct ID forwarded automatically via
tracing_headers - Product analytics: Track login and burrito consideration events
- Error tracking: Manual error capture sent to PostHog
Getting started
1. Install dependencies
npm install
# or
pnpm install2. Configure environment variables
Create a .env file in the project root:
PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
PUBLIC_POSTHOG_HOST=https://us.i.posthog.comGet your PostHog project token from your project settings in PostHog.
3. Run the development server
npm run dev
# or
pnpm devOpen http://localhost:4321 in your browser.
Project structure
src/
components/
posthog.astro # PostHog snippet for client-side tracking
Header.astro # Navigation + logout, calls posthog.reset()
layouts/
PostHogLayout.astro # Root layout that includes PostHog + Header
lib/
auth.ts # Client-side auth utilities
posthog-server.ts # Server-side PostHog client singleton
pages/
index.astro # Static (prerendered) - login form
burrito.astro # SSR (prerender=false) - calls API routes
profile.astro # Static (prerendered) - user profile
api/
auth/
login.ts # Server-side login endpoint with PostHog tracking
events/
burrito.ts # Server-side event capture endpoint
styles/
global.css # Global stylesKey integration points
Hybrid mode configuration (astro.config.mjs)
In Astro 5, output: 'static' is the default and supports per-page SSR opt-in. You need an adapter for the SSR pages to work:
import { defineConfig } from "astro/config";
import node from "@astrojs/node";
export default defineConfig({
// 'static' is the default - pages are prerendered unless they opt out
output: "static",
adapter: node({ mode: "standalone" }),
});Opting a page into SSR (src/pages/burrito.astro)
---
// Opt this page into on-demand rendering (SSR)
// In hybrid mode, pages are static by default
export const prerender = false;
---Server-side PostHog client (src/lib/posthog-server.ts)
A singleton pattern ensures only one PostHog client is created:
import { PostHog } from "posthog-node";
let posthogClient: PostHog | null = null;
export function getPostHogServer(): PostHog {
if (!posthogClient) {
posthogClient = new PostHog(import.meta.env.PUBLIC_POSTHOG_PROJECT_TOKEN, {
host: import.meta.env.PUBLIC_POSTHOG_HOST,
flushAt: 1,
flushInterval: 0,
});
}
return posthogClient;
}API route with server-side tracking (src/pages/api/events/burrito.ts)
import { getPostHogServer } from "../../../lib/posthog-server";
export const POST: APIRoute = async ({ request }) => {
const body = await request.json();
const sessionId = request.headers.get("X-PostHog-Session-Id");
const posthog = getPostHogServer();
posthog.capture({
distinctId: body.username,
event: "burrito_considered",
properties: {
$session_id: sessionId || undefined,
source: "api",
},
});
return new Response(JSON.stringify({ success: true }));
};When to use Hybrid mode
Use hybrid mode when you want:
- Performance: Most pages prerendered as static HTML
- Flexibility: Some pages need server-side logic (auth, personalization)
- API routes: Server-side endpoints for data processing
Scripts
# Run dev server
npm run dev
# Build for production
npm run build
# Preview production build
npm run previewLearn more
.env.example
PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token_here
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
astro.config.mjs
import { defineConfig } from "astro/config";
import node from "@astrojs/node";
export default defineConfig({
// In Astro 5, 'static' is the default and supports per-page SSR opt-in
// Use `export const prerender = false` in pages that need server rendering
output: "static",
adapter: node({
mode: "standalone",
}),
image: {
service: { entrypoint: "astro/assets/services/noop" },
},
});
src/components/Header.astro
---
// Header component with navigation and logout functionality
---
<header class="header">
<div class="header-container">
<nav>
<a href="/">Home</a>
<a href="/burrito" class="auth-link" style="display: none;">Burrito Consideration</a>
<a href="/profile" class="auth-link" style="display: none;">Profile</a>
</nav>
<div class="user-section">
<span class="welcome-text" style="display: none;">Welcome, <span class="username"></span>!</span>
<span class="not-logged-in">Not logged in</span>
<button class="btn-logout" style="display: none;">Logout</button>
</div>
</div>
</header>
<script is:inline>
function updateHeader() {
const currentUser = localStorage.getItem('currentUser');
const authLinks = document.querySelectorAll('.auth-link');
const welcomeText = document.querySelector('.welcome-text');
const notLoggedIn = document.querySelector('.not-logged-in');
const logoutBtn = document.querySelector('.btn-logout');
const usernameSpan = document.querySelector('.username');
if (currentUser) {
authLinks.forEach(link => link.style.display = 'inline');
welcomeText.style.display = 'inline';
notLoggedIn.style.display = 'none';
logoutBtn.style.display = 'inline';
usernameSpan.textContent = currentUser;
} else {
authLinks.forEach(link => link.style.display = 'none');
welcomeText.style.display = 'none';
notLoggedIn.style.display = 'inline';
logoutBtn.style.display = 'none';
}
}
function handleLogout() {
const currentUser = localStorage.getItem('currentUser');
if (currentUser) {
window.posthog?.capture('user_logged_out');
}
localStorage.removeItem('currentUser');
localStorage.removeItem('burritoConsiderations');
// IMPORTANT: Reset the PostHog instance to clear the user session
window.posthog?.reset();
window.location.href = '/';
}
document.addEventListener('DOMContentLoaded', () => {
updateHeader();
document.querySelector('.btn-logout')?.addEventListener('click', handleLogout);
});
// Listen for storage changes (login/logout in other tabs)
window.addEventListener('storage', updateHeader);
</script>
<style>
.header {
background-color: #333;
color: white;
padding: 1rem;
}
.header-container {
max-width: 1200px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
}
.header nav {
display: flex;
gap: 1rem;
}
.header a {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background-color 0.2s;
}
.header a:hover {
background-color: #555;
text-decoration: none;
}
.user-section {
display: flex;
align-items: center;
gap: 1rem;
}
.btn-logout {
background-color: #dc3545;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.btn-logout:hover {
background-color: #c82333;
}
</style>
src/components/posthog.astro
---
// PostHog analytics snippet for client-side tracking
// Uses is:inline to prevent Astro from processing the script
---
<script is:inline define:vars={{ apiKey: import.meta.env.PUBLIC_POSTHOG_PROJECT_TOKEN, apiHost: import.meta.env.PUBLIC_POSTHOG_HOST }}>
// POSTHOG_BROWSER_SNIPPET_START
!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],Object.defineProperty(u,"toString",{configurable:!0,enumerable:!0,writable:!0,value:function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e}}),Object.defineProperty(u.people,"toString",{configurable:!0,enumerable:!0,writable:!0,value:function(){return u.toString(1)+".people (stub)"}}),o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys getNextSurveyStep onSessionId".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
// POSTHOG_BROWSER_SNIPPET_END
posthog.init(apiKey || '', {
api_host: apiHost || 'https://us.i.posthog.com',
defaults: '2026-01-30',
// Automatically add X-POSTHOG-SESSION-ID and X-POSTHOG-DISTINCT-ID headers
// to same-origin requests so server-side events join the same session.
tracing_headers: [window.location.hostname]
})
</script>
src/layouts/PostHogLayout.astro
---
import PostHog from '../components/posthog.astro';
import Header from '../components/Header.astro';
import '../styles/global.css';
interface Props {
title: string;
}
const { title } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Astro PostHog SSR Integration Example" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>{title}</title>
<PostHog />
</head>
<body>
<Header />
<main>
<slot />
</main>
</body>
</html>
src/lib/auth.ts
// Client-side auth utilities for localStorage-based authentication
export interface User {
username: string;
burritoConsiderations: number;
}
export function getCurrentUser(): User | null {
if (typeof window === "undefined") return null;
const username = localStorage.getItem("currentUser");
if (!username) return null;
const considerations = parseInt(
localStorage.getItem("burritoConsiderations") || "0",
10,
);
return {
username,
burritoConsiderations: considerations,
};
}
export function login(username: string, password: string): boolean {
if (!username || !password) return false;
localStorage.setItem("currentUser", username);
// Initialize burrito considerations if not set
if (!localStorage.getItem("burritoConsiderations")) {
localStorage.setItem("burritoConsiderations", "0");
}
return true;
}
export function logout(): void {
localStorage.removeItem("currentUser");
localStorage.removeItem("burritoConsiderations");
}
export function incrementBurritoConsiderations(): number {
const current = parseInt(
localStorage.getItem("burritoConsiderations") || "0",
10,
);
const newCount = current + 1;
localStorage.setItem("burritoConsiderations", newCount.toString());
return newCount;
}
src/lib/posthog-server.ts
import { PostHog } from "posthog-node";
let posthogClient: PostHog | null = null;
/**
* Get the PostHog server-side client.
* Uses a singleton pattern to avoid creating multiple clients.
*/
export function getPostHogServer(): PostHog {
if (!posthogClient) {
posthogClient = new PostHog(import.meta.env.PUBLIC_POSTHOG_PROJECT_TOKEN || "", {
host: import.meta.env.PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com",
// Flush immediately for demo purposes
// In production, you might want to batch events
flushAt: 1,
flushInterval: 0,
});
}
return posthogClient;
}
/**
* Shutdown the PostHog client gracefully.
* Call this when your server is shutting down.
*/
export async function shutdownPostHog(): Promise<void> {
if (posthogClient) {
await posthogClient.shutdown();
posthogClient = null;
}
}
src/pages/api/auth/login.ts
import type { APIRoute } from "astro";
import { getPostHogServer } from "../../../lib/posthog-server";
export const prerender = false;
// In-memory user store for demo purposes
const users = new Map<string, { username: string; createdAt: string }>();
export const POST: APIRoute = async ({ request }) => {
try {
const body = await request.json();
const { username, password } = body;
if (!username || !password) {
return new Response(
JSON.stringify({ error: "Username and password are required" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
// Check if this is a new user
const isNewUser = !users.has(username);
if (isNewUser) {
users.set(username, {
username,
createdAt: new Date().toISOString(),
});
}
// Get the PostHog server client
const posthog = getPostHogServer();
// Get session ID from client if available (passed via header)
const sessionId = request.headers.get("X-PostHog-Session-Id");
// Capture server-side login event
posthog.capture({
distinctId: username,
event: "server_login",
properties: {
$session_id: sessionId || undefined,
isNewUser,
source: "api",
timestamp: new Date().toISOString(),
},
});
// Also identify the user server-side
posthog.identify({
distinctId: username,
properties: {
username,
createdAt: isNewUser ? new Date().toISOString() : undefined,
},
});
// This endpoint is short-lived; flush so the enqueued events send before it returns
await posthog.flush();
return new Response(
JSON.stringify({
success: true,
username,
isNewUser,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
} catch (error) {
console.error("Login error:", error);
return new Response(JSON.stringify({ error: "Internal server error" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};
src/pages/api/events/burrito.ts
import type { APIRoute } from "astro";
import { getPostHogServer } from "../../../lib/posthog-server";
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
try {
const body = await request.json();
const { username, totalConsiderations } = body;
if (!username) {
return new Response(JSON.stringify({ error: "Username is required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
// Get the PostHog server client
const posthog = getPostHogServer();
// Get session ID from client if available (passed via header)
const sessionId = request.headers.get("X-PostHog-Session-Id");
// Capture server-side burrito consideration event
posthog.capture({
distinctId: username,
event: "burrito_considered",
properties: {
$session_id: sessionId || undefined,
total_considerations: totalConsiderations,
source: "api",
timestamp: new Date().toISOString(),
},
});
// This endpoint is short-lived; flush so the enqueued event sends before it returns
await posthog.flush();
return new Response(
JSON.stringify({
success: true,
totalConsiderations,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
} catch (error) {
console.error("Burrito event error:", error);
return new Response(JSON.stringify({ error: "Internal server error" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};
src/pages/burrito.astro
---
import PostHogLayout from '../layouts/PostHogLayout.astro';
// Opt this page into on-demand rendering (SSR)
// In hybrid mode, pages are static by default
export const prerender = false;
---
<PostHogLayout title="Burrito Consideration - Astro PostHog Hybrid Example">
<div class="container">
<h1>Burrito consideration zone</h1>
<p>Take a moment to truly consider the potential of burritos.</p>
<div style="text-align: center;">
<button id="consider-btn" class="btn-burrito">
I have considered the burrito potential
</button>
<p id="success-message" class="success" style="display: none;">
Thank you for your consideration! Count: <span id="consideration-count"></span>
</p>
</div>
<div class="stats">
<h3>Consideration stats</h3>
<p>Total considerations: <span id="total-considerations">0</span></p>
</div>
<p class="note" style="margin-top: 1rem;">
Events are tracked both client-side and server-side for demonstration.
</p>
</div>
</PostHogLayout>
<script is:inline>
function checkAuth() {
const currentUser = localStorage.getItem('currentUser');
if (!currentUser) {
window.location.href = '/';
return false;
}
return true;
}
function updateStats() {
const count = localStorage.getItem('burritoConsiderations') || '0';
document.getElementById('total-considerations').textContent = count;
}
async function handleConsideration() {
const currentUser = localStorage.getItem('currentUser');
if (!currentUser) return;
// Increment the count
const currentCount = parseInt(localStorage.getItem('burritoConsiderations') || '0', 10);
const newCount = currentCount + 1;
localStorage.setItem('burritoConsiderations', newCount.toString());
// Update the UI
updateStats();
const successMessage = document.getElementById('success-message');
const considerationCount = document.getElementById('consideration-count');
considerationCount.textContent = newCount;
successMessage.style.display = 'block';
// Hide success message after 2 seconds
setTimeout(() => {
successMessage.style.display = 'none';
}, 2000);
// Client-side event tracking
window.posthog?.capture('burrito_considered', {
total_considerations: newCount,
username: currentUser,
source: 'client'
});
// Also send to server-side API for server tracking. The session and distinct
// ID are added automatically by the tracing_headers option in posthog.init.
try {
await fetch('/api/events/burrito', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: currentUser,
totalConsiderations: newCount
})
});
} catch (error) {
console.error('Failed to send server-side event:', error);
}
}
document.addEventListener('DOMContentLoaded', () => {
if (!checkAuth()) return;
updateStats();
document.getElementById('consider-btn')?.addEventListener('click', handleConsideration);
});
</script>
src/pages/index.astro
---
import PostHogLayout from '../layouts/PostHogLayout.astro';
// This page is prerendered (static) by default in hybrid mode
// No need to set prerender = true explicitly
---
<PostHogLayout title="Home - Astro PostHog Hybrid Example">
<div class="container">
<div id="logged-in-view" style="display: none;">
<h1>Welcome back, <span id="welcome-username"></span>!</h1>
<p>You are logged in. Feel free to explore:</p>
<ul>
<li>Consider the potential of burritos</li>
<li>View your profile and statistics</li>
</ul>
</div>
<div id="logged-out-view">
<h1>Welcome to Burrito Consideration App</h1>
<p>Please sign in to begin your burrito journey</p>
<form id="login-form" class="form">
<div class="form-group">
<label for="username">Username:</label>
<input
type="text"
id="username"
placeholder="Enter any username"
required
/>
</div>
<div class="form-group">
<label for="password">Password:</label>
<input
type="password"
id="password"
placeholder="Enter any password"
required
/>
</div>
<p id="error-message" class="error" style="display: none;"></p>
<button type="submit" class="btn-primary">Sign In</button>
</form>
<p class="note">
Note: This is a demo app with server-side tracking. Use any username and password to sign in.
</p>
</div>
</div>
</PostHogLayout>
<script is:inline>
function updateView() {
const currentUser = localStorage.getItem('currentUser');
const loggedInView = document.getElementById('logged-in-view');
const loggedOutView = document.getElementById('logged-out-view');
const welcomeUsername = document.getElementById('welcome-username');
if (currentUser) {
loggedInView.style.display = 'block';
loggedOutView.style.display = 'none';
welcomeUsername.textContent = currentUser;
} else {
loggedInView.style.display = 'none';
loggedOutView.style.display = 'block';
}
}
async function handleLogin(event) {
event.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const errorMessage = document.getElementById('error-message');
if (!username || !password) {
errorMessage.textContent = 'Please provide both username and password';
errorMessage.style.display = 'block';
return;
}
try {
// Call the server-side login API. The session and distinct ID are added
// automatically by the tracing_headers option configured in posthog.init.
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Login failed');
}
// Store in localStorage for client-side state
localStorage.setItem('currentUser', username);
if (!localStorage.getItem('burritoConsiderations')) {
localStorage.setItem('burritoConsiderations', '0');
}
// Also identify on the client side (for session continuity)
window.posthog?.identify(username);
window.posthog?.capture('user_logged_in');
// Clear form
document.getElementById('username').value = '';
document.getElementById('password').value = '';
errorMessage.style.display = 'none';
// Update view
updateView();
// Trigger header update
window.dispatchEvent(new Event('storage'));
} catch (error) {
errorMessage.textContent = error.message || 'Login failed';
errorMessage.style.display = 'block';
}
}
document.addEventListener('DOMContentLoaded', () => {
updateView();
document.getElementById('login-form')?.addEventListener('submit', handleLogin);
});
// Listen for storage changes
window.addEventListener('storage', updateView);
</script>
src/pages/profile.astro
---
import PostHogLayout from '../layouts/PostHogLayout.astro';
// This page is prerendered (static) by default in hybrid mode
---
<PostHogLayout title="Profile - Astro PostHog Hybrid Example">
<div class="container">
<h1>User Profile</h1>
<div class="stats">
<h2>Your Information</h2>
<p><strong>Username:</strong> <span id="profile-username"></span></p>
<p><strong>Burrito Considerations:</strong> <span id="profile-considerations">0</span></p>
</div>
<div style="margin-top: 2rem;">
<h3>Your Burrito Journey</h3>
<p id="journey-message"></p>
</div>
<div style="margin-top: 2rem;">
<h3>Error Tracking Demo</h3>
<p>Click the button below to trigger a test error and send it to PostHog:</p>
<button id="error-btn" class="btn-error">
Trigger Test Error
</button>
<p id="error-feedback" class="success" style="display: none;">
Error captured and sent to PostHog!
</p>
</div>
</div>
</PostHogLayout>
<script is:inline>
function checkAuth() {
const currentUser = localStorage.getItem('currentUser');
if (!currentUser) {
window.location.href = '/';
return false;
}
return true;
}
function updateProfile() {
const username = localStorage.getItem('currentUser') || '';
const considerations = parseInt(localStorage.getItem('burritoConsiderations') || '0', 10);
document.getElementById('profile-username').textContent = username;
document.getElementById('profile-considerations').textContent = considerations;
// Update journey message based on consideration count
const journeyMessage = document.getElementById('journey-message');
if (considerations === 0) {
journeyMessage.textContent = "You haven't considered any burritos yet. Visit the Burrito Consideration page to start!";
} else if (considerations === 1) {
journeyMessage.textContent = "You've considered the burrito potential once. Keep going!";
} else if (considerations < 5) {
journeyMessage.textContent = "You're getting the hang of burrito consideration!";
} else if (considerations < 10) {
journeyMessage.textContent = "You're becoming a burrito consideration expert!";
} else {
journeyMessage.textContent = "You are a true burrito consideration master!";
}
}
function triggerTestError() {
try {
throw new Error('Test error for PostHog error tracking');
} catch (err) {
// Capture the error in PostHog
window.posthog?.captureException(err);
console.error('Captured error:', err);
// Show feedback to user
const feedback = document.getElementById('error-feedback');
feedback.style.display = 'block';
setTimeout(() => {
feedback.style.display = 'none';
}, 3000);
}
}
document.addEventListener('DOMContentLoaded', () => {
if (!checkAuth()) return;
updateProfile();
document.getElementById('error-btn')?.addEventListener('click', triggerTestError);
});
</script>
references/EXAMPLE-astro-ssr.md
PostHog astro-ssr Example Project
Repository: https://github.com/PostHog/context-mill Path: example-apps/astro-ssr
README.md
PostHog Astro SSR Example
This is an Astro server-side rendered (SSR) example demonstrating PostHog integration with both client-side and server-side event tracking.
It uses:
- Client-side: PostHog web snippet for browser analytics
- Server-side:
posthog-nodefor API route event tracking
This shows how to:
- Initialize PostHog on both client and server
- Track events from API routes using
posthog-node - Link client and server sessions automatically with the
tracing_headersoption - Identify users on both client and server
- Capture errors via
posthog.captureException() - Reset PostHog state on logout
Features
- Server-side rendering: Full SSR with
output: 'server' - API routes: Server-side endpoints for auth and event tracking
- Dual tracking: Events captured on both client and server
- Session continuity: Session and distinct ID forwarded automatically via
tracing_headers - Product analytics: Track login and burrito consideration events
- Session replay: Enabled via PostHog snippet configuration
- Error tracking: Manual error capture sent to PostHog
- Simple auth flow: Demo login using localStorage + server API
Getting started
1. Install dependencies
npm install
# or
pnpm install2. Configure environment variables
Create a .env file in the project root:
# Client-side (PUBLIC_ prefix exposes to browser)
PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
# Server-side (no PUBLIC_ prefix, server-only)
POSTHOG_PROJECT_TOKEN=your_posthog_project_token
POSTHOG_HOST=https://us.i.posthog.comGet your PostHog project token from your project settings in PostHog.
3. Run the development server
npm run dev
# or
pnpm devOpen http://localhost:4321 in your browser.
Project structure
src/
components/
posthog.astro # PostHog snippet for client-side tracking
Header.astro # Navigation + logout, calls posthog.reset()
layouts/
PostHogLayout.astro # Root layout that includes PostHog + Header
lib/
auth.ts # Client-side auth utilities
posthog-server.ts # Server-side PostHog client singleton
pages/
index.astro # Login form, calls /api/auth/login
burrito.astro # Burrito demo, calls /api/events/burrito
profile.astro # Profile + error tracking demo
api/
auth/
login.ts # Server-side login endpoint with PostHog tracking
events/
burrito.ts # Server-side event capture endpoint
styles/
global.css # Global stylesKey integration points
Server-side PostHog client (src/lib/posthog-server.ts)
A singleton pattern ensures only one PostHog client is created:
import { PostHog } from "posthog-node";
let posthogClient: PostHog | null = null;
export function getPostHogServer(): PostHog {
if (!posthogClient) {
posthogClient = new PostHog(import.meta.env.POSTHOG_PROJECT_TOKEN, {
host: import.meta.env.POSTHOG_HOST,
flushAt: 1,
flushInterval: 0,
});
}
return posthogClient;
}API route with server-side tracking (src/pages/api/auth/login.ts)
import { getPostHogServer } from "../../../lib/posthog-server";
export const POST: APIRoute = async ({ request }) => {
const body = await request.json();
const { username } = body;
// Get session ID from client
const sessionId = request.headers.get("X-PostHog-Session-Id");
const posthog = getPostHogServer();
// Capture server-side event
posthog.capture({
distinctId: username,
event: "server_login",
properties: {
$session_id: sessionId || undefined,
source: "api",
},
});
return new Response(JSON.stringify({ success: true }));
};Passing session context to the server (src/components/posthog.astro)
The tracing_headers option in posthog.init automatically adds the
X-POSTHOG-SESSION-ID and X-POSTHOG-DISTINCT-ID headers to same-origin
fetch/XHR requests, so the server route above receives them with no manual
wiring:
posthog.init(apiKey, {
api_host: apiHost,
defaults: "2026-01-30",
tracing_headers: [window.location.hostname],
});
// Client fetches then need no PostHog headers of their own:
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});Client-side identification (src/pages/index.astro)
After server login succeeds, also identify on client:
window.posthog?.identify(username);
window.posthog?.capture("user_logged_in");Logout and session reset (src/components/Header.astro)
On logout, both the local auth state and PostHog state are cleared:
window.posthog?.capture("user_logged_out");
localStorage.removeItem("currentUser");
window.posthog?.reset();Scripts
# Run dev server
npm run dev
# Build for production
npm run build
# Preview production build
npm run previewLearn more
.env.example
# Client-side environment variables (PUBLIC_ prefix)
PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token_here
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
# Server-side environment variables (no PUBLIC_ prefix)
POSTHOG_PROJECT_TOKEN=your_posthog_project_token_here
POSTHOG_HOST=https://us.i.posthog.com
astro.config.mjs
import { defineConfig } from "astro/config";
import node from "@astrojs/node";
export default defineConfig({
output: "server",
adapter: node({
mode: "standalone",
}),
image: {
service: { entrypoint: "astro/assets/services/noop" },
},
});
src/components/Header.astro
---
// Header component with navigation and logout functionality
---
<header class="header">
<div class="header-container">
<nav>
<a href="/">Home</a>
<a href="/burrito" class="auth-link" style="display: none;">Burrito Consideration</a>
<a href="/profile" class="auth-link" style="display: none;">Profile</a>
</nav>
<div class="user-section">
<span class="welcome-text" style="display: none;">Welcome, <span class="username"></span>!</span>
<span class="not-logged-in">Not logged in</span>
<button class="btn-logout" style="display: none;">Logout</button>
</div>
</div>
</header>
<script is:inline>
function updateHeader() {
const currentUser = localStorage.getItem('currentUser');
const authLinks = document.querySelectorAll('.auth-link');
const welcomeText = document.querySelector('.welcome-text');
const notLoggedIn = document.querySelector('.not-logged-in');
const logoutBtn = document.querySelector('.btn-logout');
const usernameSpan = document.querySelector('.username');
if (currentUser) {
authLinks.forEach(link => link.style.display = 'inline');
welcomeText.style.display = 'inline';
notLoggedIn.style.display = 'none';
logoutBtn.style.display = 'inline';
usernameSpan.textContent = currentUser;
} else {
authLinks.forEach(link => link.style.display = 'none');
welcomeText.style.display = 'none';
notLoggedIn.style.display = 'inline';
logoutBtn.style.display = 'none';
}
}
function handleLogout() {
const currentUser = localStorage.getItem('currentUser');
if (currentUser) {
window.posthog?.capture('user_logged_out');
}
localStorage.removeItem('currentUser');
localStorage.removeItem('burritoConsiderations');
// IMPORTANT: Reset the PostHog instance to clear the user session
window.posthog?.reset();
window.location.href = '/';
}
document.addEventListener('DOMContentLoaded', () => {
updateHeader();
document.querySelector('.btn-logout')?.addEventListener('click', handleLogout);
});
// Listen for storage changes (login/logout in other tabs)
window.addEventListener('storage', updateHeader);
</script>
<style>
.header {
background-color: #333;
color: white;
padding: 1rem;
}
.header-container {
max-width: 1200px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
}
.header nav {
display: flex;
gap: 1rem;
}
.header a {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background-color 0.2s;
}
.header a:hover {
background-color: #555;
text-decoration: none;
}
.user-section {
display: flex;
align-items: center;
gap: 1rem;
}
.btn-logout {
background-color: #dc3545;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.btn-logout:hover {
background-color: #c82333;
}
</style>
src/components/posthog.astro
---
// PostHog analytics snippet for client-side tracking
// Uses is:inline to prevent Astro from processing the script
---
<script is:inline define:vars={{ apiKey: import.meta.env.PUBLIC_POSTHOG_PROJECT_TOKEN, apiHost: import.meta.env.PUBLIC_POSTHOG_HOST }}>
// POSTHOG_BROWSER_SNIPPET_START
!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],Object.defineProperty(u,"toString",{configurable:!0,enumerable:!0,writable:!0,value:function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e}}),Object.defineProperty(u.people,"toString",{configurable:!0,enumerable:!0,writable:!0,value:function(){return u.toString(1)+".people (stub)"}}),o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys getNextSurveyStep onSessionId".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
// POSTHOG_BROWSER_SNIPPET_END
posthog.init(apiKey || '', {
api_host: apiHost || 'https://us.i.posthog.com',
defaults: '2026-01-30',
// Automatically add X-POSTHOG-SESSION-ID and X-POSTHOG-DISTINCT-ID headers
// to same-origin requests so server-side events join the same session.
tracing_headers: [window.location.hostname]
})
</script>
src/layouts/PostHogLayout.astro
---
import PostHog from '../components/posthog.astro';
import Header from '../components/Header.astro';
import '../styles/global.css';
interface Props {
title: string;
}
const { title } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Astro PostHog SSR Integration Example" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>{title}</title>
<PostHog />
</head>
<body>
<Header />
<main>
<slot />
</main>
</body>
</html>
src/lib/auth.ts
// Client-side auth utilities for localStorage-based authentication
export interface User {
username: string;
burritoConsiderations: number;
}
export function getCurrentUser(): User | null {
if (typeof window === "undefined") return null;
const username = localStorage.getItem("currentUser");
if (!username) return null;
const considerations = parseInt(
localStorage.getItem("burritoConsiderations") || "0",
10,
);
return {
username,
burritoConsiderations: considerations,
};
}
export function login(username: string, password: string): boolean {
if (!username || !password) return false;
localStorage.setItem("currentUser", username);
// Initialize burrito considerations if not set
if (!localStorage.getItem("burritoConsiderations")) {
localStorage.setItem("burritoConsiderations", "0");
}
return true;
}
export function logout(): void {
localStorage.removeItem("currentUser");
localStorage.removeItem("burritoConsiderations");
}
export function incrementBurritoConsiderations(): number {
const current = parseInt(
localStorage.getItem("burritoConsiderations") || "0",
10,
);
const newCount = current + 1;
localStorage.setItem("burritoConsiderations", newCount.toString());
return newCount;
}
src/lib/posthog-server.ts
import { PostHog } from "posthog-node";
let posthogClient: PostHog | null = null;
/**
* Get the PostHog server-side client.
* Uses a singleton pattern to avoid creating multiple clients.
*/
export function getPostHogServer(): PostHog {
if (!posthogClient) {
posthogClient = new PostHog(import.meta.env.POSTHOG_PROJECT_TOKEN || "", {
host: import.meta.env.POSTHOG_HOST || "https://us.i.posthog.com",
// Flush immediately for demo purposes
// In production, you might want to batch events
flushAt: 1,
flushInterval: 0,
});
}
return posthogClient;
}
/**
* Shutdown the PostHog client gracefully.
* Call this when your server is shutting down.
*/
export async function shutdownPostHog(): Promise<void> {
if (posthogClient) {
await posthogClient.shutdown();
posthogClient = null;
}
}
src/pages/api/auth/login.ts
import type { APIRoute } from "astro";
import { getPostHogServer } from "../../../lib/posthog-server";
// In-memory user store for demo purposes
const users = new Map<string, { username: string; createdAt: string }>();
export const POST: APIRoute = async ({ request }) => {
try {
const body = await request.json();
const { username, password } = body;
if (!username || !password) {
return new Response(
JSON.stringify({ error: "Username and password are required" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
// Check if this is a new user
const isNewUser = !users.has(username);
if (isNewUser) {
users.set(username, {
username,
createdAt: new Date().toISOString(),
});
}
// Get the PostHog server client
const posthog = getPostHogServer();
// Get session ID from client if available (passed via header)
const sessionId = request.headers.get("X-PostHog-Session-Id");
// Capture server-side login event
posthog.capture({
distinctId: username,
event: "server_login",
properties: {
$session_id: sessionId || undefined,
isNewUser,
source: "api",
timestamp: new Date().toISOString(),
},
});
// Also identify the user server-side
posthog.identify({
distinctId: username,
properties: {
username,
createdAt: isNewUser ? new Date().toISOString() : undefined,
},
});
// This endpoint is short-lived; flush so the enqueued events send before it returns
await posthog.flush();
return new Response(
JSON.stringify({
success: true,
username,
isNewUser,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
} catch (error) {
console.error("Login error:", error);
return new Response(JSON.stringify({ error: "Internal server error" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};
src/pages/api/events/burrito.ts
import type { APIRoute } from "astro";
import { getPostHogServer } from "../../../lib/posthog-server";
export const POST: APIRoute = async ({ request }) => {
try {
const body = await request.json();
const { username, totalConsiderations } = body;
if (!username) {
return new Response(JSON.stringify({ error: "Username is required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
// Get the PostHog server client
const posthog = getPostHogServer();
// Get session ID from client if available (passed via header)
const sessionId = request.headers.get("X-PostHog-Session-Id");
// Capture server-side burrito consideration event
posthog.capture({
distinctId: username,
event: "burrito_considered",
properties: {
$session_id: sessionId || undefined,
total_considerations: totalConsiderations,
source: "api",
timestamp: new Date().toISOString(),
},
});
// This endpoint is short-lived; flush so the enqueued event sends before it returns
await posthog.flush();
return new Response(
JSON.stringify({
success: true,
totalConsiderations,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
} catch (error) {
console.error("Burrito event error:", error);
return new Response(JSON.stringify({ error: "Internal server error" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};
src/pages/burrito.astro
---
import PostHogLayout from '../layouts/PostHogLayout.astro';
---
<PostHogLayout title="Burrito Consideration - Astro PostHog SSR Example">
<div class="container">
<h1>Burrito consideration zone</h1>
<p>Take a moment to truly consider the potential of burritos.</p>
<div style="text-align: center;">
<button id="consider-btn" class="btn-burrito">
I have considered the burrito potential
</button>
<p id="success-message" class="success" style="display: none;">
Thank you for your consideration! Count: <span id="consideration-count"></span>
</p>
</div>
<div class="stats">
<h3>Consideration stats</h3>
<p>Total considerations: <span id="total-considerations">0</span></p>
</div>
<p class="note" style="margin-top: 1rem;">
Events are tracked both client-side and server-side for demonstration.
</p>
</div>
</PostHogLayout>
<script is:inline>
function checkAuth() {
const currentUser = localStorage.getItem('currentUser');
if (!currentUser) {
window.location.href = '/';
return false;
}
return true;
}
function updateStats() {
const count = localStorage.getItem('burritoConsiderations') || '0';
document.getElementById('total-considerations').textContent = count;
}
async function handleConsideration() {
const currentUser = localStorage.getItem('currentUser');
if (!currentUser) return;
// Increment the count
const currentCount = parseInt(localStorage.getItem('burritoConsiderations') || '0', 10);
const newCount = currentCount + 1;
localStorage.setItem('burritoConsiderations', newCount.toString());
// Update the UI
updateStats();
const successMessage = document.getElementById('success-message');
const considerationCount = document.getElementById('consideration-count');
considerationCount.textContent = newCount;
successMessage.style.display = 'block';
// Hide success message after 2 seconds
setTimeout(() => {
successMessage.style.display = 'none';
}, 2000);
// Client-side event tracking
window.posthog?.capture('burrito_considered', {
total_considerations: newCount,
username: currentUser,
source: 'client'
});
// Also send to server-side API for server tracking. The session and distinct
// ID are added automatically by the tracing_headers option in posthog.init.
try {
await fetch('/api/events/burrito', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: currentUser,
totalConsiderations: newCount
})
});
} catch (error) {
console.error('Failed to send server-side event:', error);
}
}
document.addEventListener('DOMContentLoaded', () => {
if (!checkAuth()) return;
updateStats();
document.getElementById('consider-btn')?.addEventListener('click', handleConsideration);
});
</script>
src/pages/index.astro
---
import PostHogLayout from '../layouts/PostHogLayout.astro';
---
<PostHogLayout title="Home - Astro PostHog SSR Example">
<div class="container">
<div id="logged-in-view" style="display: none;">
<h1>Welcome back, <span id="welcome-username"></span>!</h1>
<p>You are logged in. Feel free to explore:</p>
<ul>
<li>Consider the potential of burritos</li>
<li>View your profile and statistics</li>
</ul>
</div>
<div id="logged-out-view">
<h1>Welcome to Burrito Consideration App</h1>
<p>Please sign in to begin your burrito journey</p>
<form id="login-form" class="form">
<div class="form-group">
<label for="username">Username:</label>
<input
type="text"
id="username"
placeholder="Enter any username"
required
/>
</div>
<div class="form-group">
<label for="password">Password:</label>
<input
type="password"
id="password"
placeholder="Enter any password"
required
/>
</div>
<p id="error-message" class="error" style="display: none;"></p>
<button type="submit" class="btn-primary">Sign In</button>
</form>
<p class="note">
Note: This is a demo app with server-side tracking. Use any username and password to sign in.
</p>
</div>
</div>
</PostHogLayout>
<script is:inline>
function updateView() {
const currentUser = localStorage.getItem('currentUser');
const loggedInView = document.getElementById('logged-in-view');
const loggedOutView = document.getElementById('logged-out-view');
const welcomeUsername = document.getElementById('welcome-username');
if (currentUser) {
loggedInView.style.display = 'block';
loggedOutView.style.display = 'none';
welcomeUsername.textContent = currentUser;
} else {
loggedInView.style.display = 'none';
loggedOutView.style.display = 'block';
}
}
async function handleLogin(event) {
event.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const errorMessage = document.getElementById('error-message');
if (!username || !password) {
errorMessage.textContent = 'Please provide both username and password';
errorMessage.style.display = 'block';
return;
}
try {
// Call the server-side login API. The session and distinct ID are added
// automatically by the tracing_headers option configured in posthog.init.
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Login failed');
}
// Store in localStorage for client-side state
localStorage.setItem('currentUser', username);
if (!localStorage.getItem('burritoConsiderations')) {
localStorage.setItem('burritoConsiderations', '0');
}
// Also identify on the client side (for session continuity)
window.posthog?.identify(username);
window.posthog?.capture('user_logged_in');
// Clear form
document.getElementById('username').value = '';
document.getElementById('password').value = '';
errorMessage.style.display = 'none';
// Update view
updateView();
// Trigger header update
window.dispatchEvent(new Event('storage'));
} catch (error) {
errorMessage.textContent = error.message || 'Login failed';
errorMessage.style.display = 'block';
}
}
document.addEventListener('DOMContentLoaded', () => {
updateView();
document.getElementById('login-form')?.addEventListener('submit', handleLogin);
});
// Listen for storage changes
window.addEventListener('storage', updateView);
</script>
src/pages/profile.astro
---
import PostHogLayout from '../layouts/PostHogLayout.astro';
---
<PostHogLayout title="Profile - Astro PostHog SSR Example">
<div class="container">
<h1>User Profile</h1>
<div class="stats">
<h2>Your Information</h2>
<p><strong>Username:</strong> <span id="profile-username"></span></p>
<p><strong>Burrito Considerations:</strong> <span id="profile-considerations">0</span></p>
</div>
<div style="margin-top: 2rem;">
<h3>Your Burrito Journey</h3>
<p id="journey-message"></p>
</div>
<div style="margin-top: 2rem;">
<h3>Error Tracking Demo</h3>
<p>Click the button below to trigger a test error and send it to PostHog:</p>
<button id="error-btn" class="btn-error">
Trigger Test Error
</button>
<p id="error-feedback" class="success" style="display: none;">
Error captured and sent to PostHog!
</p>
</div>
</div>
</PostHogLayout>
<script is:inline>
function checkAuth() {
const currentUser = localStorage.getItem('currentUser');
if (!currentUser) {
window.location.href = '/';
return false;
}
return true;
}
function updateProfile() {
const username = localStorage.getItem('currentUser') || '';
const considerations = parseInt(localStorage.getItem('burritoConsiderations') || '0', 10);
document.getElementById('profile-username').textContent = username;
document.getElementById('profile-considerations').textContent = considerations;
// Update journey message based on consideration count
const journeyMessage = document.getElementById('journey-message');
if (considerations === 0) {
journeyMessage.textContent = "You haven't considered any burritos yet. Visit the Burrito Consideration page to start!";
} else if (considerations === 1) {
journeyMessage.textContent = "You've considered the burrito potential once. Keep going!";
} else if (considerations < 5) {
journeyMessage.textContent = "You're getting the hang of burrito consideration!";
} else if (considerations < 10) {
journeyMessage.textContent = "You're becoming a burrito consideration expert!";
} else {
journeyMessage.textContent = "You are a true burrito consideration master!";
}
}
function triggerTestError() {
try {
throw new Error('Test error for PostHog error tracking');
} catch (err) {
// Capture the error in PostHog
window.posthog?.captureException(err);
console.error('Captured error:', err);
// Show feedback to user
const feedback = document.getElementById('error-feedback');
feedback.style.display = 'block';
setTimeout(() => {
feedback.style.display = 'none';
}, 3000);
}
}
document.addEventListener('DOMContentLoaded', () => {
if (!checkAuth()) return;
updateProfile();
document.getElementById('error-btn')?.addEventListener('click', triggerTestError);
});
</script>
references/EXAMPLE-astro-static.md
PostHog astro-static Example Project
Repository: https://github.com/PostHog/context-mill Path: example-apps/astro-static
README.md
PostHog Astro Static Example
This is an Astro static site (SSG) example demonstrating PostHog integration with product analytics, session replay, and error tracking.
It uses the PostHog web snippet directly and shows how to:
- Initialize PostHog in a static Astro site using a reusable component
- Identify users after login
- Track custom events from pages
- Capture errors via
posthog.captureException() - Reset PostHog state on logout
Features
- Product analytics: Track login and burrito consideration events
- Session replay: Enabled via PostHog snippet configuration
- Error tracking: Manual error capture sent to PostHog
- Simple auth flow: Demo login using localStorage
Getting started
1. Install dependencies
npm install
# or
pnpm install2. Configure environment variables
Create a .env file in the project root:
PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
PUBLIC_POSTHOG_HOST=https://us.i.posthog.comGet your PostHog project token from your project settings in PostHog.
3. Run the development server
npm run dev
# or
pnpm devOpen http://localhost:4321 in your browser.
Project structure
src/
components/
posthog.astro # PostHog snippet with is:inline directive
Header.astro # Navigation + logout, calls posthog.reset()
layouts/
PostHogLayout.astro # Root layout that includes PostHog + Header
lib/
auth.ts # Auth utilities (localStorage-based)
pages/
index.astro # Login form, identifies user + captures 'user_logged_in'
burrito.astro # Burrito consideration demo, captures 'burrito_considered'
profile.astro # Profile + error tracking demo
styles/
global.css # Global stylesKey integration points
PostHog initialization (src/components/posthog.astro)
The PostHog snippet is included as an inline script to prevent Astro from processing it:
<script is:inline>
!function(t,e){...}(document,window.posthog||[]);
posthog.init('<ph_project_token>', {
api_host: 'https://us.i.posthog.com',
defaults: '2026-01-30'
})
</script>The is:inline directive is required to prevent TypeScript errors about window.posthog.
User identification (src/pages/index.astro)
After a successful "login", the app identifies the user and captures a login event:
window.posthog?.identify(username);
window.posthog?.capture("user_logged_in");Identification happens only on login, all further requests will automatically use the same distinct ID.
Event tracking (src/pages/burrito.astro)
The burrito page tracks a custom event when a user "considers" the burrito:
window.posthog?.capture("burrito_considered", {
total_considerations: newCount,
username: currentUser,
});This shows how to attach useful properties to events (e.g. counts, usernames).
Error tracking (src/pages/profile.astro)
The profile page includes a button to trigger a test error:
try {
throw new Error("Test error for PostHog error tracking");
} catch (err) {
window.posthog?.captureException(err);
}Logout and session reset (src/components/Header.astro)
On logout, both the local auth state and PostHog state are cleared:
window.posthog?.capture("user_logged_out");
localStorage.removeItem("currentUser");
window.posthog?.reset();posthog.reset() clears the current distinct ID and session so the next login starts a fresh identity.
Scripts
# Run dev server
npm run dev
# Build for production
npm run build
# Preview production build
npm run previewLearn more
.env.example
PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token_here
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
astro.config.mjs
import { defineConfig } from "astro/config";
export default defineConfig({});
src/components/Header.astro
---
// Header component with navigation and logout functionality
---
<header class="header">
<div class="header-container">
<nav>
<a href="/">Home</a>
<a href="/burrito" class="auth-link" style="display: none;">Burrito Consideration</a>
<a href="/profile" class="auth-link" style="display: none;">Profile</a>
</nav>
<div class="user-section">
<span class="welcome-text" style="display: none;">Welcome, <span class="username"></span>!</span>
<span class="not-logged-in">Not logged in</span>
<button class="btn-logout" style="display: none;">Logout</button>
</div>
</div>
</header>
<script is:inline>
function updateHeader() {
const currentUser = localStorage.getItem('currentUser');
const authLinks = document.querySelectorAll('.auth-link');
const welcomeText = document.querySelector('.welcome-text');
const notLoggedIn = document.querySelector('.not-logged-in');
const logoutBtn = document.querySelector('.btn-logout');
const usernameSpan = document.querySelector('.username');
if (currentUser) {
authLinks.forEach(link => link.style.display = 'inline');
welcomeText.style.display = 'inline';
notLoggedIn.style.display = 'none';
logoutBtn.style.display = 'inline';
usernameSpan.textContent = currentUser;
} else {
authLinks.forEach(link => link.style.display = 'none');
welcomeText.style.display = 'none';
notLoggedIn.style.display = 'inline';
logoutBtn.style.display = 'none';
}
}
function handleLogout() {
const currentUser = localStorage.getItem('currentUser');
if (currentUser) {
window.posthog?.capture('user_logged_out');
}
localStorage.removeItem('currentUser');
localStorage.removeItem('burritoConsiderations');
// IMPORTANT: Reset the PostHog instance to clear the user session
window.posthog?.reset();
window.location.href = '/';
}
document.addEventListener('DOMContentLoaded', () => {
updateHeader();
document.querySelector('.btn-logout')?.addEventListener('click', handleLogout);
});
// Listen for storage changes (login/logout in other tabs)
window.addEventListener('storage', updateHeader);
</script>
<style>
.header {
background-color: #333;
color: white;
padding: 1rem;
}
.header-container {
max-width: 1200px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
}
.header nav {
display: flex;
gap: 1rem;
}
.header a {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background-color 0.2s;
}
.header a:hover {
background-color: #555;
text-decoration: none;
}
.user-section {
display: flex;
align-items: center;
gap: 1rem;
}
.btn-logout {
background-color: #dc3545;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.btn-logout:hover {
background-color: #c82333;
}
</style>
src/components/posthog.astro
---
// PostHog analytics snippet
// Uses is:inline to prevent Astro from processing the script
---
<script is:inline define:vars={{ apiKey: import.meta.env.PUBLIC_POSTHOG_PROJECT_TOKEN, apiHost: import.meta.env.PUBLIC_POSTHOG_HOST }}>
// POSTHOG_BROWSER_SNIPPET_START
!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],Object.defineProperty(u,"toString",{configurable:!0,enumerable:!0,writable:!0,value:function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e}}),Object.defineProperty(u.people,"toString",{configurable:!0,enumerable:!0,writable:!0,value:function(){return u.toString(1)+".people (stub)"}}),o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys getNextSurveyStep onSessionId".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
// POSTHOG_BROWSER_SNIPPET_END
posthog.init(apiKey || '', {
api_host: apiHost || 'https://us.i.posthog.com',
defaults: '2026-01-30'
})
</script>
src/layouts/PostHogLayout.astro
---
import PostHog from '../components/posthog.astro';
import Header from '../components/Header.astro';
import '../styles/global.css';
interface Props {
title: string;
}
const { title } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Astro PostHog Integration Example" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>{title}</title>
<PostHog />
</head>
<body>
<Header />
<main>
<slot />
</main>
</body>
</html>
src/lib/auth.ts
// Client-side auth utilities for localStorage-based authentication
export interface User {
username: string;
burritoConsiderations: number;
}
export function getCurrentUser(): User | null {
if (typeof window === "undefined") return null;
const username = localStorage.getItem("currentUser");
if (!username) return null;
const considerations = parseInt(
localStorage.getItem("burritoConsiderations") || "0",
10,
);
return {
username,
burritoConsiderations: considerations,
};
}
export function login(username: string, password: string): boolean {
if (!username || !password) return false;
localStorage.setItem("currentUser", username);
// Initialize burrito considerations if not set
if (!localStorage.getItem("burritoConsiderations")) {
localStorage.setItem("burritoConsiderations", "0");
}
return true;
}
export function logout(): void {
localStorage.removeItem("currentUser");
localStorage.removeItem("burritoConsiderations");
}
export function incrementBurritoConsiderations(): number {
const current = parseInt(
localStorage.getItem("burritoConsiderations") || "0",
10,
);
const newCount = current + 1;
localStorage.setItem("burritoConsiderations", newCount.toString());
return newCount;
}
src/pages/burrito.astro
---
import PostHogLayout from '../layouts/PostHogLayout.astro';
---
<PostHogLayout title="Burrito Consideration - Astro PostHog Example">
<div class="container">
<h1>Burrito consideration zone</h1>
<p>Take a moment to truly consider the potential of burritos.</p>
<div style="text-align: center;">
<button id="consider-btn" class="btn-burrito">
I have considered the burrito potential
</button>
<p id="success-message" class="success" style="display: none;">
Thank you for your consideration! Count: <span id="consideration-count"></span>
</p>
</div>
<div class="stats">
<h3>Consideration stats</h3>
<p>Total considerations: <span id="total-considerations">0</span></p>
</div>
</div>
</PostHogLayout>
<script is:inline>
function checkAuth() {
const currentUser = localStorage.getItem('currentUser');
if (!currentUser) {
window.location.href = '/';
return false;
}
return true;
}
function updateStats() {
const count = localStorage.getItem('burritoConsiderations') || '0';
document.getElementById('total-considerations').textContent = count;
}
function handleConsideration() {
const currentUser = localStorage.getItem('currentUser');
if (!currentUser) return;
// Increment the count
const currentCount = parseInt(localStorage.getItem('burritoConsiderations') || '0', 10);
const newCount = currentCount + 1;
localStorage.setItem('burritoConsiderations', newCount.toString());
// Update the UI
updateStats();
const successMessage = document.getElementById('success-message');
const considerationCount = document.getElementById('consideration-count');
considerationCount.textContent = newCount;
successMessage.style.display = 'block';
// Hide success message after 2 seconds
setTimeout(() => {
successMessage.style.display = 'none';
}, 2000);
// Capture burrito consideration event in PostHog
window.posthog?.capture('burrito_considered', {
total_considerations: newCount,
username: currentUser
});
}
document.addEventListener('DOMContentLoaded', () => {
if (!checkAuth()) return;
updateStats();
document.getElementById('consider-btn')?.addEventListener('click', handleConsideration);
});
</script>
src/pages/index.astro
---
import PostHogLayout from '../layouts/PostHogLayout.astro';
---
<PostHogLayout title="Home - Astro PostHog Example">
<div class="container">
<div id="logged-in-view" style="display: none;">
<h1>Welcome back, <span id="welcome-username"></span>!</h1>
<p>You are logged in. Feel free to explore:</p>
<ul>
<li>Consider the potential of burritos</li>
<li>View your profile and statistics</li>
</ul>
</div>
<div id="logged-out-view">
<h1>Welcome to Burrito Consideration App</h1>
<p>Please sign in to begin your burrito journey</p>
<form id="login-form" class="form">
<div class="form-group">
<label for="username">Username:</label>
<input
type="text"
id="username"
placeholder="Enter any username"
required
/>
</div>
<div class="form-group">
<label for="password">Password:</label>
<input
type="password"
id="password"
placeholder="Enter any password"
required
/>
</div>
<p id="error-message" class="error" style="display: none;"></p>
<button type="submit" class="btn-primary">Sign In</button>
</form>
<p class="note">
Note: This is a demo app. Use any username and password to sign in.
</p>
</div>
</div>
</PostHogLayout>
<script is:inline>
function updateView() {
const currentUser = localStorage.getItem('currentUser');
const loggedInView = document.getElementById('logged-in-view');
const loggedOutView = document.getElementById('logged-out-view');
const welcomeUsername = document.getElementById('welcome-username');
if (currentUser) {
loggedInView.style.display = 'block';
loggedOutView.style.display = 'none';
welcomeUsername.textContent = currentUser;
} else {
loggedInView.style.display = 'none';
loggedOutView.style.display = 'block';
}
}
function handleLogin(event) {
event.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const errorMessage = document.getElementById('error-message');
if (!username || !password) {
errorMessage.textContent = 'Please provide both username and password';
errorMessage.style.display = 'block';
return;
}
// Client-side only fake auth - store in localStorage
localStorage.setItem('currentUser', username);
if (!localStorage.getItem('burritoConsiderations')) {
localStorage.setItem('burritoConsiderations', '0');
}
// Identify the user in PostHog (once on login is enough)
window.posthog?.identify(username);
window.posthog?.capture('user_logged_in');
// Clear form
document.getElementById('username').value = '';
document.getElementById('password').value = '';
errorMessage.style.display = 'none';
// Update view
updateView();
// Trigger header update
window.dispatchEvent(new Event('storage'));
}
document.addEventListener('DOMContentLoaded', () => {
updateView();
document.getElementById('login-form')?.addEventListener('submit', handleLogin);
});
// Listen for storage changes
window.addEventListener('storage', updateView);
</script>
src/pages/profile.astro
---
import PostHogLayout from '../layouts/PostHogLayout.astro';
---
<PostHogLayout title="Profile - Astro PostHog Example">
<div class="container">
<h1>User Profile</h1>
<div class="stats">
<h2>Your Information</h2>
<p><strong>Username:</strong> <span id="profile-username"></span></p>
<p><strong>Burrito Considerations:</strong> <span id="profile-considerations">0</span></p>
</div>
<div style="margin-top: 2rem;">
<h3>Your Burrito Journey</h3>
<p id="journey-message"></p>
</div>
<div style="margin-top: 2rem;">
<h3>Error Tracking Demo</h3>
<p>Click the button below to trigger a test error and send it to PostHog:</p>
<button id="error-btn" class="btn-error">
Trigger Test Error
</button>
<p id="error-feedback" class="success" style="display: none;">
Error captured and sent to PostHog!
</p>
</div>
</div>
</PostHogLayout>
<script is:inline>
function checkAuth() {
const currentUser = localStorage.getItem('currentUser');
if (!currentUser) {
window.location.href = '/';
return false;
}
return true;
}
function updateProfile() {
const username = localStorage.getItem('currentUser') || '';
const considerations = parseInt(localStorage.getItem('burritoConsiderations') || '0', 10);
document.getElementById('profile-username').textContent = username;
document.getElementById('profile-considerations').textContent = considerations;
// Update journey message based on consideration count
const journeyMessage = document.getElementById('journey-message');
if (considerations === 0) {
journeyMessage.textContent = "You haven't considered any burritos yet. Visit the Burrito Consideration page to start!";
} else if (considerations === 1) {
journeyMessage.textContent = "You've considered the burrito potential once. Keep going!";
} else if (considerations < 5) {
journeyMessage.textContent = "You're getting the hang of burrito consideration!";
} else if (considerations < 10) {
journeyMessage.textContent = "You're becoming a burrito consideration expert!";
} else {
journeyMessage.textContent = "You are a true burrito consideration master!";
}
}
function triggerTestError() {
try {
throw new Error('Test error for PostHog error tracking');
} catch (err) {
// Capture the error in PostHog
window.posthog?.captureException(err);
console.error('Captured error:', err);
// Show feedback to user
const feedback = document.getElementById('error-feedback');
feedback.style.display = 'block';
setTimeout(() => {
feedback.style.display = 'none';
}, 3000);
}
}
document.addEventListener('DOMContentLoaded', () => {
if (!checkAuth()) return;
updateProfile();
document.getElementById('error-btn')?.addEventListener('click', triggerTestError);
});
</script>
references/EXAMPLE-astro-view-transitions.md
PostHog astro-view-transitions Example Project
Repository: https://github.com/PostHog/context-mill Path: example-apps/astro-view-transitions
README.md
PostHog Astro View Transitions Example
This is an Astro example demonstrating PostHog integration with View Transitions (ClientRouter) for SPA-like navigation.
It uses the PostHog web snippet with special handling to prevent stack overflow errors during soft navigation, and shows how to:
- Initialize PostHog with an initialization guard for View Transitions
- Track pageviews automatically during soft navigation
- Identify users after login
- Track custom events from pages
- Capture errors via
posthog.captureException() - Reset PostHog state on logout
Features
- View Transitions: Smooth client-side navigation with
<ClientRouter /> - Product analytics: Track login and burrito consideration events
- Automatic pageview tracking: Uses
capture_pageview: 'history_change'for soft navigation - Session replay: Enabled via PostHog snippet configuration
- Error tracking: Manual error capture sent to PostHog
- Simple auth flow: Demo login using localStorage
Getting started
1. Install dependencies
npm install
# or
pnpm install2. Configure environment variables
Create a .env file in the project root:
PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
PUBLIC_POSTHOG_HOST=https://us.i.posthog.comGet your PostHog project token from your project settings in PostHog.
3. Run the development server
npm run dev
# or
pnpm devOpen http://localhost:4321 in your browser.
Project structure
src/
components/
posthog.astro # PostHog snippet WITH initialization guard
Header.astro # Navigation + logout, uses astro:page-load event
layouts/
PostHogLayout.astro # Root layout with <ClientRouter /> and PostHog
lib/
auth.ts # Auth utilities (localStorage-based)
pages/
index.astro # Login form, identifies user + captures 'user_logged_in'
burrito.astro # Burrito consideration demo, captures 'burrito_considered'
profile.astro # Profile + error tracking demo
styles/
global.css # Global styles + view transition animationsKey integration points
PostHog initialization with View Transitions (src/components/posthog.astro)
When using Astro's View Transitions (ClientRouter), you must wrap the PostHog initialization with a guard to prevent stack overflow errors:
<script is:inline>
// IMPORTANT: Guard against multiple initializations during view transitions
if (!window.__posthog_initialized) {
window.__posthog_initialized = true;
!function(t,e){...}(document,window.posthog||[]);
posthog.init('<ph_project_token>', {
api_host: 'https://us.i.posthog.com',
defaults: '2026-01-30',
// IMPORTANT: Use 'history_change' for automatic pageview tracking during soft navigation
capture_pageview: 'history_change'
})
}
</script>Without this guard, ClientRouter's soft navigation can re-execute the inline script during page transitions, causing a stack overflow error.
The capture_pageview: 'history_change' option ensures pageviews are tracked automatically as users navigate between pages.
Layout with ClientRouter (src/layouts/PostHogLayout.astro)
The layout includes Astro's ClientRouter for smooth page transitions:
---
import { ClientRouter } from 'astro:transitions';
import PostHog from '../components/posthog.astro';
---
<html>
<head>
<ClientRouter />
<PostHog />
</head>
...
</html>Handling View Transitions in scripts
When using View Transitions, you need to set up event listeners after each page navigation:
function setupPage() {
// Your setup code here
}
// Run on initial page load
document.addEventListener("DOMContentLoaded", setupPage);
// Run after view transitions complete (for soft navigation)
document.addEventListener("astro:page-load", setupPage);User identification (src/pages/index.astro)
After a successful "login", the app identifies the user and captures a login event:
window.posthog?.identify(username);
window.posthog?.capture("user_logged_in");Event tracking (src/pages/burrito.astro)
The burrito page tracks a custom event when a user "considers" the burrito:
window.posthog?.capture("burrito_considered", {
total_considerations: newCount,
username: currentUser,
});Logout and session reset (src/components/Header.astro)
On logout, both the local auth state and PostHog state are cleared:
window.posthog?.capture("user_logged_out");
localStorage.removeItem("currentUser");
window.posthog?.reset();Scripts
# Run dev server
npm run dev
# Build for production
npm run build
# Preview production build
npm run previewLearn more
.env.example
PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token_here
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
astro.config.mjs
import { defineConfig } from "astro/config";
export default defineConfig({});
src/components/Header.astro
---
// Header component with navigation and logout functionality
// Works with View Transitions by using data-astro-reload for logout
---
<header class="header">
<div class="header-container">
<nav>
<a href="/">Home</a>
<a href="/burrito" class="auth-link" style="display: none;">Burrito Consideration</a>
<a href="/profile" class="auth-link" style="display: none;">Profile</a>
</nav>
<div class="user-section">
<span class="welcome-text" style="display: none;">Welcome, <span class="username"></span>!</span>
<span class="not-logged-in">Not logged in</span>
<button class="btn-logout" style="display: none;">Logout</button>
</div>
</div>
</header>
<script is:inline>
function updateHeader() {
const currentUser = localStorage.getItem('currentUser');
const authLinks = document.querySelectorAll('.auth-link');
const welcomeText = document.querySelector('.welcome-text');
const notLoggedIn = document.querySelector('.not-logged-in');
const logoutBtn = document.querySelector('.btn-logout');
const usernameSpan = document.querySelector('.username');
if (currentUser) {
authLinks.forEach(link => link.style.display = 'inline');
welcomeText.style.display = 'inline';
notLoggedIn.style.display = 'none';
logoutBtn.style.display = 'inline';
usernameSpan.textContent = currentUser;
} else {
authLinks.forEach(link => link.style.display = 'none');
welcomeText.style.display = 'none';
notLoggedIn.style.display = 'inline';
logoutBtn.style.display = 'none';
}
}
function handleLogout() {
const currentUser = localStorage.getItem('currentUser');
if (currentUser) {
window.posthog?.capture('user_logged_out');
}
localStorage.removeItem('currentUser');
localStorage.removeItem('burritoConsiderations');
// IMPORTANT: Reset the PostHog instance to clear the user session
window.posthog?.reset();
window.location.href = '/';
}
function setupHeader() {
updateHeader();
const logoutBtn = document.querySelector('.btn-logout');
// Remove existing listeners to prevent duplicates during view transitions
logoutBtn?.removeEventListener('click', handleLogout);
logoutBtn?.addEventListener('click', handleLogout);
}
// Run on initial page load
document.addEventListener('DOMContentLoaded', setupHeader);
// Run after view transitions complete (for soft navigation)
document.addEventListener('astro:page-load', setupHeader);
// Listen for storage changes (login/logout in other tabs)
window.addEventListener('storage', updateHeader);
</script>
<style>
.header {
background-color: #333;
color: white;
padding: 1rem;
}
.header-container {
max-width: 1200px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
}
.header nav {
display: flex;
gap: 1rem;
}
.header a {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background-color 0.2s;
}
.header a:hover {
background-color: #555;
text-decoration: none;
}
.user-section {
display: flex;
align-items: center;
gap: 1rem;
}
.btn-logout {
background-color: #dc3545;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.btn-logout:hover {
background-color: #c82333;
}
</style>
src/components/posthog.astro
---
// PostHog analytics snippet with View Transitions support
// Uses is:inline to prevent Astro from processing the script
// Includes initialization guard to prevent stack overflow with ClientRouter
---
<script is:inline define:vars={{ apiKey: import.meta.env.PUBLIC_POSTHOG_PROJECT_TOKEN, apiHost: import.meta.env.PUBLIC_POSTHOG_HOST }}>
// IMPORTANT: Guard against multiple initializations during view transitions
// Without this guard, ClientRouter's soft navigation can re-execute the inline script
// during page transitions, causing a stack overflow error.
if (!window.__posthog_initialized) {
window.__posthog_initialized = true;
// POSTHOG_BROWSER_SNIPPET_START
!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],Object.defineProperty(u,"toString",{configurable:!0,enumerable:!0,writable:!0,value:function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e}}),Object.defineProperty(u.people,"toString",{configurable:!0,enumerable:!0,writable:!0,value:function(){return u.toString(1)+".people (stub)"}}),o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys getNextSurveyStep onSessionId".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
// POSTHOG_BROWSER_SNIPPET_END
posthog.init(apiKey || '', {
api_host: apiHost || 'https://us.i.posthog.com',
defaults: '2026-01-30',
// IMPORTANT: Use 'history_change' to automatically track pageviews during soft navigation
capture_pageview: 'history_change'
})
}
</script>
src/layouts/PostHogLayout.astro
---
import { ClientRouter } from 'astro:transitions';
import PostHog from '../components/posthog.astro';
import Header from '../components/Header.astro';
import '../styles/global.css';
interface Props {
title: string;
}
const { title } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Astro PostHog Integration with View Transitions" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>{title}</title>
<ClientRouter />
<PostHog />
</head>
<body>
<Header />
<main>
<slot />
</main>
</body>
</html>
src/lib/auth.ts
// Client-side auth utilities for localStorage-based authentication
export interface User {
username: string;
burritoConsiderations: number;
}
export function getCurrentUser(): User | null {
if (typeof window === "undefined") return null;
const username = localStorage.getItem("currentUser");
if (!username) return null;
const considerations = parseInt(
localStorage.getItem("burritoConsiderations") || "0",
10,
);
return {
username,
burritoConsiderations: considerations,
};
}
export function login(username: string, password: string): boolean {
if (!username || !password) return false;
localStorage.setItem("currentUser", username);
// Initialize burrito considerations if not set
if (!localStorage.getItem("burritoConsiderations")) {
localStorage.setItem("burritoConsiderations", "0");
}
return true;
}
export function logout(): void {
localStorage.removeItem("currentUser");
localStorage.removeItem("burritoConsiderations");
}
export function incrementBurritoConsiderations(): number {
const current = parseInt(
localStorage.getItem("burritoConsiderations") || "0",
10,
);
const newCount = current + 1;
localStorage.setItem("burritoConsiderations", newCount.toString());
return newCount;
}
src/pages/burrito.astro
---
import PostHogLayout from '../layouts/PostHogLayout.astro';
---
<PostHogLayout title="Burrito Consideration - Astro PostHog with View Transitions">
<div class="container">
<h1>Burrito consideration zone</h1>
<p>Take a moment to truly consider the potential of burritos.</p>
<div style="text-align: center;">
<button id="consider-btn" class="btn-burrito">
I have considered the burrito potential
</button>
<p id="success-message" class="success" style="display: none;">
Thank you for your consideration! Count: <span id="consideration-count"></span>
</p>
</div>
<div class="stats">
<h3>Consideration stats</h3>
<p>Total considerations: <span id="total-considerations">0</span></p>
</div>
</div>
</PostHogLayout>
<script is:inline>
function checkAuth() {
const currentUser = localStorage.getItem('currentUser');
if (!currentUser) {
window.location.href = '/';
return false;
}
return true;
}
function updateStats() {
const count = localStorage.getItem('burritoConsiderations') || '0';
const totalElement = document.getElementById('total-considerations');
if (totalElement) {
totalElement.textContent = count;
}
}
function handleConsideration() {
const currentUser = localStorage.getItem('currentUser');
if (!currentUser) return;
// Increment the count
const currentCount = parseInt(localStorage.getItem('burritoConsiderations') || '0', 10);
const newCount = currentCount + 1;
localStorage.setItem('burritoConsiderations', newCount.toString());
// Update the UI
updateStats();
const successMessage = document.getElementById('success-message');
const considerationCount = document.getElementById('consideration-count');
if (considerationCount) {
considerationCount.textContent = newCount;
}
if (successMessage) {
successMessage.style.display = 'block';
// Hide success message after 2 seconds
setTimeout(() => {
successMessage.style.display = 'none';
}, 2000);
}
// Capture burrito consideration event in PostHog
window.posthog?.capture('burrito_considered', {
total_considerations: newCount,
username: currentUser
});
}
function setupBurritoPage() {
if (!checkAuth()) return;
updateStats();
const btn = document.getElementById('consider-btn');
// Remove existing listener to prevent duplicates during view transitions
btn?.removeEventListener('click', handleConsideration);
btn?.addEventListener('click', handleConsideration);
}
// Run on initial page load
document.addEventListener('DOMContentLoaded', setupBurritoPage);
// Run after view transitions complete (for soft navigation)
document.addEventListener('astro:page-load', setupBurritoPage);
</script>
src/pages/index.astro
---
import PostHogLayout from '../layouts/PostHogLayout.astro';
---
<PostHogLayout title="Home - Astro PostHog with View Transitions">
<div class="container">
<div id="logged-in-view" style="display: none;">
<h1>Welcome back, <span id="welcome-username"></span>!</h1>
<p>You are logged in. Feel free to explore:</p>
<ul>
<li>Consider the potential of burritos</li>
<li>View your profile and statistics</li>
</ul>
</div>
<div id="logged-out-view">
<h1>Welcome to Burrito Consideration App</h1>
<p>Please sign in to begin your burrito journey</p>
<form id="login-form" class="form">
<div class="form-group">
<label for="username">Username:</label>
<input
type="text"
id="username"
placeholder="Enter any username"
required
/>
</div>
<div class="form-group">
<label for="password">Password:</label>
<input
type="password"
id="password"
placeholder="Enter any password"
required
/>
</div>
<p id="error-message" class="error" style="display: none;"></p>
<button type="submit" class="btn-primary">Sign In</button>
</form>
<p class="note">
Note: This is a demo app. Use any username and password to sign in.
</p>
</div>
</div>
</PostHogLayout>
<script is:inline>
function updateView() {
const currentUser = localStorage.getItem('currentUser');
const loggedInView = document.getElementById('logged-in-view');
const loggedOutView = document.getElementById('logged-out-view');
const welcomeUsername = document.getElementById('welcome-username');
if (currentUser) {
loggedInView.style.display = 'block';
loggedOutView.style.display = 'none';
welcomeUsername.textContent = currentUser;
} else {
loggedInView.style.display = 'none';
loggedOutView.style.display = 'block';
}
}
function handleLogin(event) {
event.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const errorMessage = document.getElementById('error-message');
if (!username || !password) {
errorMessage.textContent = 'Please provide both username and password';
errorMessage.style.display = 'block';
return;
}
// Client-side only fake auth - store in localStorage
localStorage.setItem('currentUser', username);
if (!localStorage.getItem('burritoConsiderations')) {
localStorage.setItem('burritoConsiderations', '0');
}
// Identify the user in PostHog (once on login is enough)
window.posthog?.identify(username);
window.posthog?.capture('user_logged_in');
// Clear form
document.getElementById('username').value = '';
document.getElementById('password').value = '';
errorMessage.style.display = 'none';
// Update view
updateView();
// Trigger header update
window.dispatchEvent(new Event('storage'));
}
function setupIndexPage() {
updateView();
const form = document.getElementById('login-form');
// Remove existing listener to prevent duplicates during view transitions
form?.removeEventListener('submit', handleLogin);
form?.addEventListener('submit', handleLogin);
}
// Run on initial page load
document.addEventListener('DOMContentLoaded', setupIndexPage);
// Run after view transitions complete (for soft navigation)
document.addEventListener('astro:page-load', setupIndexPage);
// Listen for storage changes
window.addEventListener('storage', updateView);
</script>
src/pages/profile.astro
---
import PostHogLayout from '../layouts/PostHogLayout.astro';
---
<PostHogLayout title="Profile - Astro PostHog with View Transitions">
<div class="container">
<h1>User Profile</h1>
<div class="stats">
<h2>Your Information</h2>
<p><strong>Username:</strong> <span id="profile-username"></span></p>
<p><strong>Burrito Considerations:</strong> <span id="profile-considerations">0</span></p>
</div>
<div style="margin-top: 2rem;">
<h3>Your Burrito Journey</h3>
<p id="journey-message"></p>
</div>
<div style="margin-top: 2rem;">
<h3>Error Tracking Demo</h3>
<p>Click the button below to trigger a test error and send it to PostHog:</p>
<button id="error-btn" class="btn-error">
Trigger Test Error
</button>
<p id="error-feedback" class="success" style="display: none;">
Error captured and sent to PostHog!
</p>
</div>
</div>
</PostHogLayout>
<script is:inline>
function checkAuth() {
const currentUser = localStorage.getItem('currentUser');
if (!currentUser) {
window.location.href = '/';
return false;
}
return true;
}
function updateProfile() {
const username = localStorage.getItem('currentUser') || '';
const considerations = parseInt(localStorage.getItem('burritoConsiderations') || '0', 10);
const usernameEl = document.getElementById('profile-username');
const considerationsEl = document.getElementById('profile-considerations');
const journeyMessage = document.getElementById('journey-message');
if (usernameEl) usernameEl.textContent = username;
if (considerationsEl) considerationsEl.textContent = considerations.toString();
// Update journey message based on consideration count
if (journeyMessage) {
if (considerations === 0) {
journeyMessage.textContent = "You haven't considered any burritos yet. Visit the Burrito Consideration page to start!";
} else if (considerations === 1) {
journeyMessage.textContent = "You've considered the burrito potential once. Keep going!";
} else if (considerations < 5) {
journeyMessage.textContent = "You're getting the hang of burrito consideration!";
} else if (considerations < 10) {
journeyMessage.textContent = "You're becoming a burrito consideration expert!";
} else {
journeyMessage.textContent = "You are a true burrito consideration master!";
}
}
}
function triggerTestError() {
try {
throw new Error('Test error for PostHog error tracking');
} catch (err) {
// Capture the error in PostHog
window.posthog?.captureException(err);
console.error('Captured error:', err);
// Show feedback to user
const feedback = document.getElementById('error-feedback');
if (feedback) {
feedback.style.display = 'block';
setTimeout(() => {
feedback.style.display = 'none';
}, 3000);
}
}
}
function setupProfilePage() {
if (!checkAuth()) return;
updateProfile();
const btn = document.getElementById('error-btn');
// Remove existing listener to prevent duplicates during view transitions
btn?.removeEventListener('click', triggerTestError);
btn?.addEventListener('click', triggerTestError);
}
// Run on initial page load
document.addEventListener('DOMContentLoaded', setupProfilePage);
// Run after view transitions complete (for soft navigation)
document.addEventListener('astro:page-load', setupProfilePage);
</script>
references/EXAMPLE-django.md
PostHog django Example Project
Repository: https://github.com/PostHog/context-mill Path: example-apps/django
README.md
PostHog Django example
This is a Django example demonstrating PostHog integration with product analytics, error tracking, feature flags, and user identification.
Features
- Product analytics: Track user events and behaviors
- Error tracking: Capture and track exceptions automatically
- User identification: Associate events with authenticated users via context
- Feature flags: Control feature rollouts with PostHog feature flags
- Server-side tracking: All tracking happens server-side with the Python SDK
- Context middleware: Automatic session and user context extraction
Getting started
1. Install dependencies
pip install posthog2. Configure environment variables
Create a .env file in the root directory:
POSTHOG_PROJECT_TOKEN=your_posthog_project_token
POSTHOG_HOST=https://us.i.posthog.comGet your PostHog project token from your PostHog project settings.
3. Run migrations
python manage.py migrate4. Run the development server
python manage.py runserverOpen http://localhost:8000 with your browser to see the app.
Project structure
django/
├── manage.py # Django management script
├── requirements.txt # Python dependencies
├── .env.example # Environment variable template
├── .gitignore
├── posthog_example/
│ ├── __init__.py
│ ├── settings.py # Django settings with PostHog config
│ ├── urls.py # URL routing
│ ├── wsgi.py # WSGI application
│ └── asgi.py # ASGI application
└── core/
├── __init__.py
├── apps.py # AppConfig with PostHog initialization
├── views.py # Views with event tracking examples
├── urls.py # App URL patterns
└── templates/
└── core/
├── base.html # Base template
├── home.html # Home/login page
├── burrito.html # Burrito page with event tracking
├── dashboard.html # Dashboard with feature flag example
└── profile.html # Profile pageKey integration points
PostHog initialization (core/apps.py)
import posthog
from django.conf import settings
class CoreConfig(AppConfig):
name = 'core'
def ready(self):
posthog.api_key = settings.POSTHOG_PROJECT_TOKEN
posthog.host = settings.POSTHOG_HOSTDjango settings configuration (settings.py)
import os
# PostHog configuration
POSTHOG_PROJECT_TOKEN = os.environ.get('POSTHOG_PROJECT_TOKEN', '<ph_project_token>')
POSTHOG_HOST = os.environ.get('POSTHOG_HOST', 'https://us.i.posthog.com')
MIDDLEWARE = [
# ... other middleware
'posthog.integrations.django.PosthogContextMiddleware',
]Built-in context middleware
The PostHog SDK includes a Django middleware that automatically wraps all requests with a context. It extracts session and user information from request headers and tags all events captured during the request.
The middleware automatically extracts:
- Session ID from the
X-POSTHOG-SESSION-IDheader - Distinct ID from the
X-POSTHOG-DISTINCT-IDheader - Current URL as
$current_url - Request method as
$request_method
User identification (core/views.py)
import posthog
def login_view(request):
# ... authentication logic
if user:
with posthog.new_context():
posthog.identify_context(str(user.id))
posthog.tag('email', user.email)
posthog.tag('username', user.username)
posthog.capture('user_logged_in', properties={
'login_method': 'email',
})Event tracking (core/views.py)
import posthog
def consider_burrito(request):
user_id = str(request.user.id) if request.user.is_authenticated else 'anonymous'
with posthog.new_context():
posthog.identify_context(user_id)
posthog.capture('burrito_considered', properties={
'total_considerations': request.session.get('burrito_count', 0),
})Feature flags (core/views.py)
import posthog
def dashboard_view(request):
user_id = str(request.user.id) if request.user.is_authenticated else 'anonymous'
show_new_feature = posthog.feature_enabled(
'new-dashboard-feature',
distinct_id=user_id
)
return render(request, 'core/dashboard.html', {
'show_new_feature': show_new_feature
})Error tracking (core/views.py)
Capture exceptions manually using capture_exception():
import posthog
def profile_view(request):
try:
risky_operation()
except Exception as e:
posthog.capture_exception(e)Frontend integration (optional)
If you're using PostHog's JavaScript SDK on the frontend, enable tracing headers to connect frontend sessions with backend events:
posthog.init('<ph_project_token>', {
api_host: 'https://us.i.posthog.com',
tracing_headers: ['your-backend-domain.com'],
})This automatically adds X-POSTHOG-SESSION-ID and X-POSTHOG-DISTINCT-ID headers to requests, which the Django middleware extracts to maintain context.
Learn more
.env.example
POSTHOG_PROJECT_TOKEN=
POSTHOG_HOST=https://us.i.posthog.com
DJANGO_SECRET_KEY=your-secret-key-here
DEBUG=True
core/init.py
# Core app for PostHog Django example
core/apps.py
"""
Django AppConfig that initializes PostHog when the application starts.
This ensures the SDK is configured once when Django starts, making it available throughout the application.
"""
from django.apps import AppConfig
from django.conf import settings
class CoreConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'core'
def ready(self):
"""
Initialize PostHog when Django starts.
This method is called once when Django starts. We configure the
PostHog SDK here so it's available everywhere in the application.
Note: Import posthog inside this method to avoid import issues
during Django's startup sequence.
"""
import posthog
# Configure PostHog with settings from Django settings
posthog.api_key = settings.POSTHOG_PROJECT_TOKEN
posthog.host = settings.POSTHOG_HOST
# Honor the POSTHOG_DISABLED setting (useful for testing)
if settings.POSTHOG_DISABLED:
posthog.disabled = True
# Optional: Enable debug mode in development
if settings.DEBUG:
posthog.debug = True
# Register the auth signal that identifies the login request's context.
from . import signals # noqa: F401
core/signals.py
"""PostHog identity for the login request.
The middleware reads request.user once, before any view runs. On a login request
the visitor is still anonymous at that point, so the request's context has no
distinct ID, and calling login() inside the view does not change that. This
signal runs inside the login request and identifies the ambient context, so
every capture later in that same request is attributed to the user who just
logged in. Requests made after login don't need this: the middleware sees the
authenticated user from the start.
"""
import posthog
from posthog import identify_context
from django.contrib.auth.signals import user_logged_in
from django.dispatch import receiver
@receiver(user_logged_in)
def identify_posthog_user(sender, request, user, **kwargs):
identify_context(str(user.pk))
# PII belongs in person properties, never in event properties.
posthog.set(
distinct_id=str(user.pk),
properties={
'email': user.email,
'username': user.username,
'name': user.get_full_name() or user.username,
'is_staff': user.is_staff,
'date_joined': user.date_joined.isoformat(),
},
)
core/templates/core/base.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}PostHog Django example{% endblock %}</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
line-height: 1.6;
background-color: #f5f5f5;
color: #333;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
nav {
background: #1d4ed8;
padding: 15px 20px;
margin-bottom: 30px;
}
nav a {
color: white;
text-decoration: none;
margin-right: 20px;
}
nav a:hover {
text-decoration: underline;
}
.card {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1, h2, h3 {
margin-bottom: 15px;
color: #1d4ed8;
}
button, .btn {
background: #1d4ed8;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
display: inline-block;
text-decoration: none;
}
button:hover, .btn:hover {
background: #1e40af;
}
button.danger {
background: #dc2626;
}
button.danger:hover {
background: #b91c1c;
}
input {
width: 100%;
padding: 10px;
margin-bottom: 15px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 14px;
}
.messages {
margin-bottom: 20px;
}
.message {
padding: 10px 15px;
border-radius: 5px;
margin-bottom: 10px;
}
.message.error {
background: #fee2e2;
color: #dc2626;
}
.message.success {
background: #d1fae5;
color: #059669;
}
.feature-flag {
background: #fef3c7;
border: 2px dashed #f59e0b;
padding: 15px;
border-radius: 8px;
margin: 20px 0;
}
code {
background: #f3f4f6;
padding: 2px 6px;
border-radius: 3px;
font-family: monospace;
}
.count {
font-size: 48px;
font-weight: bold;
color: #1d4ed8;
text-align: center;
padding: 20px;
}
</style>
</head>
<body>
{% if user.is_authenticated %}
<nav>
<a href="{% url 'dashboard' %}">Dashboard</a>
<a href="{% url 'burrito' %}">Burrito</a>
<a href="{% url 'profile' %}">Profile</a>
<a href="{% url 'logout' %}" style="float: right;">Logout ({{ user.username }})</a>
</nav>
{% endif %}
<div class="container">
{% if messages %}
<div class="messages">
{% for message in messages %}
<div class="message {{ message.tags }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% block content %}{% endblock %}
</div>
{% block scripts %}{% endblock %}
</body>
</html>
core/templates/core/burrito.html
{% extends 'core/base.html' %}
{% block title %}Burrito - PostHog Django example{% endblock %}
{% block content %}
<div class="card">
<h1>Burrito consideration tracker</h1>
<p>This page demonstrates custom event tracking with PostHog.</p>
</div>
<div class="card" style="text-align: center;">
<h2>Times considered</h2>
<div class="count" id="burrito-count">{{ burrito_count }}</div>
<button onclick="considerBurrito()" style="font-size: 18px; padding: 15px 30px;">
Consider a burrito
</button>
</div>
<div class="card">
<h3>How event tracking works</h3>
<p>Each time you click the button, a <code>burrito_considered</code> event is sent to PostHog:</p>
<pre style="background: #f3f4f6; padding: 15px; border-radius: 5px; overflow-x: auto; margin-top: 15px;"><code>from posthog import new_context, identify_context, capture
with new_context():
identify_context(user_id)
capture('burrito_considered', properties={
'total_considerations': count,
})</code></pre>
</div>
{% endblock %}
{% block scripts %}
<script>
async function considerBurrito() {
try {
const response = await fetch('{% url "consider_burrito" %}', {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token }}',
'Content-Type': 'application/json',
},
});
const data = await response.json();
if (data.success) {
document.getElementById('burrito-count').textContent = data.count;
}
} catch (error) {
console.error('Error:', error);
}
}
</script>
{% endblock %}
core/templates/core/dashboard.html
{% extends 'core/base.html' %}
{% block title %}Dashboard - PostHog Django example{% endblock %}
{% block content %}
<div class="card">
<h1>Dashboard</h1>
<p>Welcome back, <strong>{{ user.username }}</strong>!</p>
</div>
<div class="card">
<h2>Feature flags</h2>
<p>Feature flags allow you to control feature rollouts and run A/B tests.</p>
{% if show_new_feature %}
<div class="feature-flag">
<h3>New feature enabled!</h3>
<p>
This section is only visible because the <code>new-dashboard-feature</code>
flag is enabled for your user.
</p>
{% if feature_config %}
<p><strong>Feature config:</strong> {{ feature_config }}</p>
{% endif %}
</div>
{% else %}
<div style="background: #f3f4f6; padding: 15px; border-radius: 8px; margin-top: 15px;">
<p>
The <code>new-dashboard-feature</code> flag is not enabled for your user.
Create this flag in your PostHog project to see it in action.
</p>
</div>
{% endif %}
</div>
<div class="card">
<h3>How feature flags work</h3>
<pre style="background: #f3f4f6; padding: 15px; border-radius: 5px; overflow-x: auto;"><code># Check if a feature flag is enabled
show_feature = posthog.feature_enabled(
'new-dashboard-feature',
distinct_id=user_id,
person_properties={
'email': user.email,
'is_staff': user.is_staff,
}
)
# Get feature flag payload for configuration
config = posthog.get_feature_flag_payload(
'new-dashboard-feature',
distinct_id=user_id,
)</code></pre>
</div>
{% endblock %}
core/templates/core/home.html
{% extends 'core/base.html' %}
{% block title %}Login - PostHog Django example{% endblock %}
{% block content %}
<div class="card">
<h1>PostHog Django example</h1>
<p>Welcome! This example demonstrates PostHog integration with Django.</p>
</div>
<div class="card">
<h2>Login</h2>
<p>Login to see PostHog analytics in action.</p>
<form method="post" style="margin-top: 20px;">
{% csrf_token %}
<input type="text" name="username" placeholder="Username" required>
<input type="password" name="password" placeholder="Password" required>
<button type="submit">Login</button>
</form>
<p style="margin-top: 15px; color: #666; font-size: 14px;">
Tip: Create a user with <code>python manage.py createsuperuser</code>
</p>
</div>
<div class="card">
<h3>What this example demonstrates</h3>
<ul style="padding-left: 20px;">
<li><strong>User identification</strong> - Users are identified with <code>identify_context()</code> on login</li>
<li><strong>Pageview tracking</strong> - Middleware extracts session and user context</li>
<li><strong>Event tracking</strong> - Custom events captured with <code>capture()</code> in context</li>
<li><strong>Feature flags</strong> - Conditional features with <code>posthog.feature_enabled()</code></li>
<li><strong>Error tracking</strong> - Exceptions captured with <code>capture_exception()</code></li>
</ul>
</div>
{% endblock %}
core/templates/core/profile.html
{% extends 'core/base.html' %}
{% block title %}Profile - PostHog Django example{% endblock %}
{% block content %}
<div class="card">
<h1>Profile</h1>
<p>This page demonstrates error tracking with PostHog.</p>
</div>
<div class="card">
<h2>User information</h2>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 10px; border-bottom: 1px solid #eee;"><strong>Username:</strong></td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">{{ user.username }}</td>
</tr>
<tr>
<td style="padding: 10px; border-bottom: 1px solid #eee;"><strong>Email:</strong></td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">{{ user.email|default:"Not set" }}</td>
</tr>
<tr>
<td style="padding: 10px; border-bottom: 1px solid #eee;"><strong>Date Joined:</strong></td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">{{ user.date_joined }}</td>
</tr>
<tr>
<td style="padding: 10px;"><strong>Staff Status:</strong></td>
<td style="padding: 10px;">{{ user.is_staff|yesno:"Yes,No" }}</td>
</tr>
</table>
</div>
<div class="card">
<h2>Error tracking demo</h2>
<p>Click the buttons below to trigger different types of errors. These errors are caught and sent to PostHog.</p>
<div style="margin-top: 20px;">
<button class="danger" onclick="triggerError('value')">
Trigger ValueError
</button>
<button class="danger" onclick="triggerError('key')" style="margin-left: 10px;">
Trigger KeyError
</button>
<button class="danger" onclick="triggerError('generic')" style="margin-left: 10px;">
Trigger Generic Error
</button>
</div>
<div id="error-result" style="margin-top: 20px; display: none;"></div>
</div>
<div class="card">
<h3>How error tracking works</h3>
<pre style="background: #f3f4f6; padding: 15px; border-radius: 5px; overflow-x: auto;"><code>import posthog
try:
risky_operation()
except Exception as e:
posthog.capture_exception(e)</code></pre>
</div>
{% endblock %}
{% block scripts %}
<script>
async function triggerError(errorType) {
const resultDiv = document.getElementById('error-result');
try {
const response = await fetch('{% url "trigger_error" %}', {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token }}',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'error_type=' + errorType,
});
const data = await response.json();
resultDiv.style.display = 'block';
if (data.success) {
resultDiv.innerHTML = '<div class="message success">No error occurred</div>';
} else {
resultDiv.innerHTML = `
<div class="message error">
<strong>Error captured:</strong> ${data.error}<br>
<small>${data.message}</small>
</div>
`;
}
} catch (error) {
resultDiv.style.display = 'block';
resultDiv.innerHTML = `<div class="message error">Request failed: ${error}</div>`;
}
}
</script>
{% endblock %}
core/urls.py
"""
URL configuration for the core app.
This module defines all the URL patterns for the PostHog example views.
"""
from django.urls import path
from . import views
urlpatterns = [
# Home login page
path('', views.home_view, name='home'),
# Authentication
path('logout/', views.logout_view, name='logout'),
# Dashboard with feature flags
path('dashboard/', views.dashboard_view, name='dashboard'),
# Burrito example for event tracking
path('burrito/', views.burrito_view, name='burrito'),
path('api/burrito/consider/', views.consider_burrito_view, name='consider_burrito'),
# Profile with error tracking
path('profile/', views.profile_view, name='profile'),
path('api/trigger-error/', views.trigger_error_view, name='trigger_error'),
# Group analytics example
path('api/group-analytics/', views.group_analytics_view, name='group_analytics'),
]
core/views.py
"""Django views demonstrating PostHog integration patterns"""
import posthog
from posthog import capture
from django.shortcuts import render, redirect
from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from django.http import JsonResponse
from django.views.decorators.http import require_POST
def home_view(request):
"""Home page with login functionality"""
if request.user.is_authenticated:
return redirect('dashboard')
if request.method == 'POST':
username = request.POST.get('username')
password = request.POST.get('password')
user = authenticate(request, username=username, password=password)
if user is not None:
login(request, user)
# PostHog: the user_logged_in signal (core/signals.py) has identified
# this request's context, so a plain capture is attributed.
capture('user_logged_in', properties={
'login_method': 'email',
})
return redirect('dashboard')
else:
messages.error(request, 'Invalid username or password')
return render(request, 'core/home.html')
def logout_view(request):
"""Logout the current user"""
if request.user.is_authenticated:
# PostHog: the middleware identified this request's context from the
# still-authenticated user, so capture before calling logout().
capture('user_logged_out')
logout(request)
return redirect('home')
@login_required
def dashboard_view(request):
"""Dashboard page with feature flag example"""
user_id = str(request.user.id)
# PostHog: the middleware already identified this request's context from the
# logged-in user, so a plain capture is attributed to them.
capture('dashboard_viewed', properties={
'is_staff': request.user.is_staff,
})
# PostHog: Check feature flag
show_new_feature = posthog.feature_enabled(
'new-dashboard-feature',
distinct_id=user_id,
person_properties={
'email': request.user.email,
'is_staff': request.user.is_staff,
}
)
# PostHog: Get feature flag payload
feature_config = posthog.get_feature_flag_payload(
'new-dashboard-feature',
distinct_id=user_id,
)
context = {
'show_new_feature': show_new_feature,
'feature_config': feature_config,
}
return render(request, 'core/dashboard.html', context)
@login_required
def burrito_view(request):
"""Example page demonstrating event tracking"""
count = request.session.get('burrito_count', 0)
context = {
'burrito_count': count,
}
return render(request, 'core/burrito.html', context)
@login_required
@require_POST
def consider_burrito_view(request):
"""API endpoint for tracking burrito considerations"""
count = request.session.get('burrito_count', 0) + 1
request.session['burrito_count'] = count
# PostHog: Track custom event
capture('burrito_considered', properties={
'total_considerations': count,
})
return JsonResponse({
'success': True,
'count': count,
})
@login_required
def profile_view(request):
"""Profile page with error tracking demonstration"""
user_id = str(request.user.id)
# PostHog: Track profile view
capture('profile_viewed')
context = {
'user': request.user,
}
return render(request, 'core/profile.html', context)
@login_required
@require_POST
def trigger_error_view(request):
"""API endpoint that demonstrates error tracking"""
try:
error_type = request.POST.get('error_type', 'generic')
if error_type == 'value':
raise ValueError("Invalid value provided by user")
elif error_type == 'key':
data = {}
_ = data['nonexistent_key']
else:
raise Exception("Something went wrong!")
except Exception as e:
# PostHog: Capture exception
posthog.capture_exception(e)
# PostHog: Track error trigger event
capture('error_triggered', properties={
'error_type': error_type,
'error_message': str(e),
})
return JsonResponse({
'success': False,
'error': str(e),
'message': 'Error has been captured by PostHog',
}, status=400)
return JsonResponse({'success': True})
@login_required
def group_analytics_view(request):
"""Example demonstrating group analytics"""
user_id = str(request.user.id)
# PostHog: Identify group
posthog.group_identify(
group_type='company',
group_key='acme-corp',
properties={
'name': 'Acme Corporation',
'plan': 'enterprise',
'employee_count': 150,
}
)
# PostHog: Capture event with group
capture(
'feature_used',
properties={
'feature_name': 'group_analytics',
},
groups={
'company': 'acme-corp',
}
)
return JsonResponse({
'success': True,
'message': 'Group analytics event captured',
})
manage.py
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'posthog_example.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()
posthog_example/init.py
# PostHog Django example project
posthog_example/asgi.py
"""
ASGI config for PostHog example project
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'posthog_example.settings')
application = get_asgi_application()
posthog_example/settings.py
"""Django settings for PostHog example project"""
import os
from pathlib import Path
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'django-insecure-example-key-change-in-production')
DEBUG = os.environ.get('DEBUG', 'True').lower() == 'true'
ALLOWED_HOSTS = ['localhost', '127.0.0.1']
# PostHog configuration
POSTHOG_PROJECT_TOKEN = os.environ.get('POSTHOG_PROJECT_TOKEN', '<ph_project_token>')
POSTHOG_HOST = os.environ.get('POSTHOG_HOST', 'https://us.i.posthog.com')
POSTHOG_DISABLED = os.environ.get('POSTHOG_DISABLED', 'False').lower() == 'true'
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'core.apps.CoreConfig',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'posthog.integrations.django.PosthogContextMiddleware',
]
ROOT_URLCONF = 'posthog_example.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'posthog_example.wsgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
]
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
STATIC_URL = 'static/'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
posthog_example/urls.py
"""
URL configuration for PostHog example project
"""
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
# Include the core app URLs for PostHog examples
path('', include('core.urls')),
]
posthog_example/wsgi.py
"""
WSGI config for PostHog example project
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'posthog_example.settings')
application = get_wsgi_application()
requirements.txt
Django>=4.2,<5.0
posthog # Always use latest version
python-dotenv>=1.0.0
references/EXAMPLE-expo.md
PostHog expo Example Project
Repository: https://github.com/PostHog/context-mill Path: example-apps/expo
README.md
Burrito Consideration App (Expo)
A React Native Expo app demonstrating PostHog product analytics integration with modern React Native best practices.
Features
- Product Analytics: Full PostHog integration with event tracking
- Autocapture: Touch events and screen tracking
- Error Tracking: Manual exception capture with
captureException - User Authentication: Demo login with PostHog user identification
- Session Persistence: AsyncStorage for session management
- Modern React: React 19 with React Compiler for automatic memoization
- File-based Routing: Expo Router for navigation
- New Architecture: Enabled by default for better performance
Project Structure
basics/expo/
├── app/ # Expo Router screens (file-based routing)
│ ├── _layout.tsx # Root layout with PostHogProvider + AuthProvider
│ ├── index.tsx # Home screen (login/welcome)
│ ├── burrito.tsx # Burrito consideration screen
│ └── profile.tsx # User profile screen
├── src/
│ ├── config/
│ │ └── posthog.ts # PostHog client configuration
│ ├── contexts/
│ │ └── AuthContext.tsx # Authentication context with PostHog
│ ├── services/
│ │ └── storage.ts # AsyncStorage wrapper
│ └── styles/
│ └── theme.ts # Shared style constants
├── app.json # Expo configuration
├── babel.config.js # Babel config with React Compiler
├── eslint.config.js # ESLint flat config
├── package.json # Dependencies
├── tsconfig.json # TypeScript strict configuration
└── .env.example # Environment variables templateGetting Started
Prerequisites
- Node.js 18+
- iOS: Xcode (for iOS Simulator)
- Android: Android Studio with emulator
For Android builds: Set environment variables (required):
Add to ~/.zshrc or ~/.bashrc:
# Java from Android Studio (required for Gradle)
export JAVA_HOME="<path-to-android-studio-jdk>"
# Android SDK location
export ANDROID_HOME="$HOME/Library/Android/sdk"Examples:
export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"export ANDROID_HOME="$HOME/Library/Android/sdk"
Then run source ~/.zshrc to apply.
Installation
Install dependencies:
cd basics/expo npm installConfigure PostHog (optional):
cp .env.example .env # Edit .env with your PostHog project tokenStart the development server:
npx expo start
Running the App
# Start development server
npx expo start
# Run on iOS Simulator
npx expo run:ios
# Run on Android Emulator
npx expo run:androidPostHog Integration
Configuration
PostHog is configured in src/config/posthog.ts using environment variables from app.json:
import Constants from 'expo-constants'
const projectToken = Constants.expoConfig?.extra?.posthogProjectTokenEvent Tracking
Events are captured with properties:
posthog.capture('burrito_considered', {
total_considerations: count,
username: user.username,
})User Identification
Users are identified on login:
posthog.identify(username, {
$set: { username },
$set_once: { first_login_date: new Date().toISOString() },
})Screen Tracking
Manual screen tracking with Expo Router:
useEffect(() => {
posthog.screen(pathname, {
previous_screen: previousPathname.current,
})
}, [pathname])Error Tracking
Manual exception capture:
posthog.captureException(error)Modern React Features
React Compiler
Automatic memoization is enabled via babel-plugin-react-compiler. No need for manual useMemo, useCallback, or React.memo.
React 19 use API
The useAuth hook uses the new use API for context:
export function useAuth() {
const context = use(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}New Architecture
Enabled in app.json for better performance:
{
"expo": {
"newArchEnabled": true
}
}Building for Production
Use EAS Build for production builds:
# Install EAS CLI
npm install -g eas-cli
# Configure EAS
eas build:configure
# Build for iOS
eas build --platform ios
# Build for Android
eas build --platform androidPerformance Debugging
- Press
Jin Expo CLI to open Chrome DevTools - Go to: Profiler > [Gear icon] > "Highlight updates when components render"
- Interact with your app to see which components re-render
Tech Stack
- Expo SDK 54 - Managed workflow
- React 19 - Latest React with Compiler support
- React Native 0.81 - Latest stable
- Expo Router 6 - File-based navigation
- PostHog - Product analytics
- TypeScript - Strict mode enabled
- React Native Reanimated - Smooth animations
- React Native Gesture Handler - Native gestures
License
MIT
.env.example
POSTHOG_PROJECT_TOKEN=phc_your_project_token_here
POSTHOG_HOST=https://us.i.posthog.com
.npmrc
legacy-peer-deps=true
min-release-age=7
app.config.js
export default {
expo: {
name: 'BurritoApp',
slug: 'burrito-app',
version: '1.0.0',
orientation: 'portrait',
icon: './assets/icon.png',
userInterfaceStyle: 'light',
newArchEnabled: true,
experiments: {
reactCompiler: true,
},
splash: {
image: './assets/splash-icon.png',
resizeMode: 'contain',
backgroundColor: '#333333',
},
ios: {
supportsTablet: true,
bundleIdentifier: 'com.posthog.burritoapp',
},
android: {
adaptiveIcon: {
foregroundImage: './assets/adaptive-icon.png',
backgroundColor: '#333333',
},
package: 'com.posthog.burritoapp',
edgeToEdgeEnabled: true,
},
web: {
favicon: './assets/favicon.png',
},
scheme: 'burritoapp',
extra: {
posthogProjectToken: process.env.POSTHOG_PROJECT_TOKEN,
posthogHost: process.env.POSTHOG_HOST || 'https://us.i.posthog.com',
},
plugins: ['expo-router', 'expo-localization'],
},
}
app/_layout.tsx
import { Stack, usePathname, useGlobalSearchParams } from 'expo-router'
import { useEffect, useRef } from 'react'
import { StatusBar } from 'expo-status-bar'
import { PostHogProvider } from 'posthog-react-native'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import { AuthProvider } from '../src/contexts/AuthContext'
import { posthog } from '../src/config/posthog'
import { colors } from '../src/styles/theme'
export default function RootLayout() {
const pathname = usePathname()
const params = useGlobalSearchParams()
const previousPathname = useRef<string | undefined>(undefined)
// Manual screen tracking for Expo Router
// @see https://docs.expo.dev/router/reference/screen-tracking/
// React Compiler will auto-optimize this effect
useEffect(() => {
if (previousPathname.current !== pathname) {
posthog.screen(pathname, {
previous_screen: previousPathname.current ?? null,
// Include route params for analytics (filter sensitive data if needed)
...params,
})
previousPathname.current = pathname
}
}, [pathname, params])
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<StatusBar style="light" backgroundColor={colors.headerBackground} />
<PostHogProvider
client={posthog}
autocapture={{
captureScreens: false, // Manual tracking with Expo Router
captureTouches: true,
propsToCapture: ['testID'],
maxElementsCaptured: 20,
}}
>
<AuthProvider>
<Stack
screenOptions={{
headerStyle: { backgroundColor: colors.headerBackground },
headerTintColor: colors.headerText,
headerTitleStyle: { fontWeight: 'bold' },
animation: 'slide_from_right',
}}
>
<Stack.Screen name="index" options={{ title: 'Burrito App' }} />
<Stack.Screen name="burrito" options={{ title: 'Burrito Consideration' }} />
<Stack.Screen name="profile" options={{ title: 'Profile' }} />
</Stack>
</AuthProvider>
</PostHogProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
)
}
app/burrito.tsx
import { useState, useEffect } from 'react'
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'
import { useRouter } from 'expo-router'
import { usePostHog } from 'posthog-react-native'
import { useAuth } from '../src/contexts/AuthContext'
import { colors, spacing, typography, borderRadius, shadows } from '../src/styles/theme'
/**
* Burrito Consideration Screen
*
* Demonstrates PostHog event tracking with custom properties.
* Each time the user considers a burrito, an event is captured.
*
* @see https://posthog.com/docs/libraries/react-native#capturing-events
*/
export default function BurritoScreen() {
const { user, incrementBurritoConsiderations } = useAuth()
const router = useRouter()
const posthog = usePostHog()
const [hasConsidered, setHasConsidered] = useState(false)
// Redirect to home if not logged in
useEffect(() => {
if (!user) {
router.replace('/')
}
}, [user, router])
if (!user) {
return null
}
const handleConsideration = async () => {
const newCount = user.burritoConsiderations + 1
// Update state first for immediate feedback
await incrementBurritoConsiderations()
setHasConsidered(true)
// Hide success message after 2 seconds
setTimeout(() => setHasConsidered(false), 2000)
// Capture custom event in PostHog with properties
// We recommend using a [object] [verb] format for event names
// @see https://posthog.com/docs/libraries/react-native#capturing-events
posthog.capture('burrito_considered', {
total_considerations: newCount,
username: user.username,
})
}
return (
<View style={styles.container}>
<View style={styles.card}>
<Text style={styles.title}>Burrito Consideration Zone</Text>
<Text style={styles.text}>
Take a moment to truly consider the potential of burritos.
</Text>
{/*
testID is captured by PostHog autocapture for touch events
This helps identify the button in analytics
@see https://posthog.com/docs/libraries/react-native#autocapture
*/}
<TouchableOpacity
style={styles.burritoButton}
onPress={handleConsideration}
activeOpacity={0.8}
testID="consider-burrito-button"
>
<Text style={styles.burritoButtonText}>Consider Burrito</Text>
</TouchableOpacity>
{hasConsidered && (
<View style={styles.successContainer}>
<Text style={styles.success}>Thank you for your consideration!</Text>
<Text style={styles.successCount}>Count: {user.burritoConsiderations}</Text>
</View>
)}
<View style={styles.stats}>
<Text style={styles.statsTitle}>Consideration Stats</Text>
<Text style={styles.statsText}>Total considerations: {user.burritoConsiderations}</Text>
</View>
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
padding: spacing.md,
},
card: {
backgroundColor: colors.cardBackground,
borderRadius: borderRadius.md,
padding: spacing.lg,
...shadows.md,
},
title: {
fontSize: typography.sizes.xl,
fontWeight: typography.weights.bold,
color: colors.text,
marginBottom: spacing.sm,
},
text: {
fontSize: typography.sizes.md,
color: colors.text,
marginBottom: spacing.lg,
lineHeight: 24,
},
burritoButton: {
backgroundColor: colors.burrito,
borderRadius: borderRadius.sm,
padding: spacing.lg,
alignItems: 'center',
marginVertical: spacing.md,
...shadows.sm,
},
burritoButtonText: {
color: colors.white,
fontSize: typography.sizes.lg,
fontWeight: typography.weights.bold,
},
successContainer: {
alignItems: 'center',
marginVertical: spacing.sm,
},
success: {
color: colors.success,
fontSize: typography.sizes.md,
fontWeight: typography.weights.medium,
},
successCount: {
color: colors.success,
fontSize: typography.sizes.lg,
fontWeight: typography.weights.bold,
marginTop: spacing.xs,
},
stats: {
backgroundColor: colors.statsBackground,
padding: spacing.md,
borderRadius: borderRadius.sm,
marginTop: spacing.lg,
},
statsTitle: {
fontSize: typography.sizes.lg,
fontWeight: typography.weights.semibold,
color: colors.text,
marginBottom: spacing.xs,
},
statsText: {
fontSize: typography.sizes.md,
color: colors.text,
},
})
app/index.tsx
import { useState } from 'react'
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ScrollView,
KeyboardAvoidingView,
Platform,
} from 'react-native'
import { useRouter } from 'expo-router'
import { useAuth } from '../src/contexts/AuthContext'
import { colors, spacing, typography, borderRadius, shadows } from '../src/styles/theme'
export default function HomeScreen() {
const { user, login, logout } = useAuth()
const router = useRouter()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const handleSubmit = async () => {
setError('')
if (!username.trim() || !password.trim()) {
setError('Please provide both username and password')
return
}
setIsSubmitting(true)
try {
const success = await login(username, password)
if (success) {
setUsername('')
setPassword('')
} else {
setError('An error occurred during login')
}
} catch {
setError('An error occurred during login')
} finally {
setIsSubmitting(false)
}
}
// Logged in view
if (user) {
return (
<ScrollView style={styles.scrollView} contentContainerStyle={styles.scrollContent}>
<View style={styles.card}>
<Text style={styles.title}>Welcome back, {user.username}!</Text>
<Text style={styles.text}>You are logged in. Feel free to explore:</Text>
<View style={styles.buttonGroup}>
<TouchableOpacity
style={[styles.button, styles.burritoButton]}
onPress={() => router.push('/burrito')}
activeOpacity={0.8}
>
<Text style={styles.buttonText}>Consider Burritos</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.primaryButton]}
onPress={() => router.push('/profile')}
activeOpacity={0.8}
>
<Text style={styles.buttonText}>View Profile</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.logoutButton]}
onPress={logout}
activeOpacity={0.8}
>
<Text style={styles.buttonText}>Logout</Text>
</TouchableOpacity>
</View>
</View>
</ScrollView>
)
}
// Login view
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
<View style={styles.card}>
<Text style={styles.title}>Welcome to Burrito Consideration App</Text>
<Text style={styles.text}>Please sign in to begin your burrito journey</Text>
<View style={styles.form}>
<Text style={styles.label}>Username:</Text>
<TextInput
style={styles.input}
value={username}
onChangeText={setUsername}
placeholder="Enter any username"
placeholderTextColor={colors.textLight}
autoCapitalize="none"
autoCorrect={false}
autoComplete="username"
editable={!isSubmitting}
/>
<Text style={styles.label}>Password:</Text>
<TextInput
style={styles.input}
value={password}
onChangeText={setPassword}
placeholder="Enter any password"
placeholderTextColor={colors.textLight}
secureTextEntry
autoComplete="password"
editable={!isSubmitting}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<TouchableOpacity
style={[styles.button, styles.primaryButton, isSubmitting && styles.buttonDisabled]}
onPress={handleSubmit}
disabled={isSubmitting}
activeOpacity={0.8}
>
<Text style={styles.buttonText}>{isSubmitting ? 'Signing In...' : 'Sign In'}</Text>
</TouchableOpacity>
</View>
<Text style={styles.note}>
Note: This is a demo app. Use any username and password to sign in.
</Text>
</View>
</ScrollView>
</KeyboardAvoidingView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
scrollView: {
flex: 1,
backgroundColor: colors.background,
},
scrollContent: {
flexGrow: 1,
padding: spacing.md,
justifyContent: 'center',
},
card: {
backgroundColor: colors.cardBackground,
borderRadius: borderRadius.md,
padding: spacing.lg,
...shadows.md,
},
title: {
fontSize: typography.sizes.xl,
fontWeight: typography.weights.bold,
color: colors.text,
marginBottom: spacing.sm,
},
text: {
fontSize: typography.sizes.md,
color: colors.text,
marginBottom: spacing.md,
lineHeight: 24,
},
form: {
marginTop: spacing.md,
},
label: {
fontSize: typography.sizes.md,
fontWeight: typography.weights.medium,
color: colors.text,
marginBottom: spacing.xs,
},
input: {
backgroundColor: colors.inputBackground,
borderWidth: 1,
borderColor: colors.border,
borderRadius: borderRadius.sm,
padding: spacing.sm,
fontSize: typography.sizes.md,
color: colors.text,
marginBottom: spacing.md,
},
buttonGroup: {
marginTop: spacing.md,
gap: spacing.sm,
},
button: {
borderRadius: borderRadius.sm,
padding: spacing.md,
alignItems: 'center',
marginTop: spacing.sm,
},
primaryButton: {
backgroundColor: colors.primary,
},
burritoButton: {
backgroundColor: colors.burrito,
},
logoutButton: {
backgroundColor: colors.danger,
},
buttonDisabled: {
opacity: 0.6,
},
buttonText: {
color: colors.white,
fontSize: typography.sizes.md,
fontWeight: typography.weights.semibold,
},
error: {
color: colors.danger,
marginBottom: spacing.sm,
fontSize: typography.sizes.sm,
},
note: {
marginTop: spacing.lg,
color: colors.textSecondary,
fontSize: typography.sizes.sm,
textAlign: 'center',
lineHeight: 20,
},
})
app/profile.tsx
import { useEffect } from 'react'
import { View, Text, TouchableOpacity, StyleSheet, Alert } from 'react-native'
import { useRouter } from 'expo-router'
import { usePostHog } from 'posthog-react-native'
import { useAuth } from '../src/contexts/AuthContext'
import { colors, spacing, typography, borderRadius, shadows } from '../src/styles/theme'
/**
* Profile Screen
*
* Displays user information and demonstrates PostHog error tracking.
* The test error button shows how to capture exceptions manually.
*
* @see https://posthog.com/docs/libraries/react-native#error-tracking
*/
export default function ProfileScreen() {
const { user } = useAuth()
const router = useRouter()
const posthog = usePostHog()
// Redirect to home if not logged in
useEffect(() => {
if (!user) {
router.replace('/')
}
}, [user, router])
if (!user) {
return null
}
/**
* Triggers a test error and captures it in PostHog
*
* This demonstrates manual exception capture via captureException.
* In production, you would typically set up automatic exception capture
* or use the before_send callback for customization.
*
* @see https://posthog.com/docs/libraries/react-native#error-tracking
*/
const triggerTestError = () => {
try {
throw new Error('Test error for PostHog error tracking')
} catch (err) {
const error = err as Error
// @see https://posthog.com/docs/error-tracking
posthog.captureException(error, {
username: user.username,
screen: 'Profile',
})
console.error('Captured error:', error)
Alert.alert('Error Captured', 'The test error has been sent to PostHog!', [{ text: 'OK' }])
}
}
const getJourneyMessage = () => {
const count = user.burritoConsiderations
if (count === 0) {
return "You haven't considered any burritos yet. Visit the Burrito Consideration page to start!"
} else if (count === 1) {
return "You've considered the burrito potential once. Keep going!"
} else if (count < 5) {
return "You're getting the hang of burrito consideration!"
} else if (count < 10) {
return "You're becoming a burrito consideration expert!"
} else {
return 'You are a true burrito consideration master!'
}
}
return (
<View style={styles.container}>
<View style={styles.card}>
<Text style={styles.title}>User Profile</Text>
<View style={styles.stats}>
<Text style={styles.statsTitle}>Your Information</Text>
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>Username:</Text>
<Text style={styles.infoValue}>{user.username}</Text>
</View>
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>Burrito Considerations:</Text>
<Text style={styles.infoValue}>{user.burritoConsiderations}</Text>
</View>
</View>
{/*
testID is captured by PostHog autocapture for touch events
@see https://posthog.com/docs/libraries/react-native#autocapture
*/}
<TouchableOpacity
style={styles.errorButton}
onPress={triggerTestError}
activeOpacity={0.8}
testID="trigger-error-button"
>
<Text style={styles.buttonText}>Trigger Test Error (for PostHog)</Text>
</TouchableOpacity>
<View style={styles.journey}>
<Text style={styles.journeyTitle}>Your Burrito Journey</Text>
<Text style={styles.journeyText}>{getJourneyMessage()}</Text>
</View>
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
padding: spacing.md,
},
card: {
backgroundColor: colors.cardBackground,
borderRadius: borderRadius.md,
padding: spacing.lg,
...shadows.md,
},
title: {
fontSize: typography.sizes.xl,
fontWeight: typography.weights.bold,
color: colors.text,
marginBottom: spacing.md,
},
stats: {
backgroundColor: colors.statsBackground,
padding: spacing.md,
borderRadius: borderRadius.sm,
},
statsTitle: {
fontSize: typography.sizes.lg,
fontWeight: typography.weights.semibold,
color: colors.text,
marginBottom: spacing.sm,
},
infoRow: {
flexDirection: 'row',
marginBottom: spacing.xs,
},
infoLabel: {
fontSize: typography.sizes.md,
fontWeight: typography.weights.bold,
color: colors.text,
marginRight: spacing.xs,
},
infoValue: {
fontSize: typography.sizes.md,
color: colors.text,
},
errorButton: {
backgroundColor: colors.danger,
borderRadius: borderRadius.sm,
padding: spacing.md,
alignItems: 'center',
marginTop: spacing.lg,
},
buttonText: {
color: colors.white,
fontSize: typography.sizes.md,
fontWeight: typography.weights.semibold,
},
journey: {
marginTop: spacing.lg,
},
journeyTitle: {
fontSize: typography.sizes.lg,
fontWeight: typography.weights.semibold,
color: colors.text,
marginBottom: spacing.sm,
},
journeyText: {
fontSize: typography.sizes.md,
color: colors.text,
lineHeight: 24,
},
})
babel.config.js
module.exports = function (api) {
api.cache(true)
return {
presets: ['babel-preset-expo'],
plugins: [
['babel-plugin-react-compiler'],
'react-native-reanimated/plugin', // Must be last
],
}
}
src/config/posthog.ts
import PostHog from 'posthog-react-native'
import Constants from 'expo-constants'
// Configuration loaded from app.config.js extras via expo-constants
// Environment variables are read at build time in app.config.js
const projectToken = Constants.expoConfig?.extra?.posthogProjectToken as string | undefined
const host = (Constants.expoConfig?.extra?.posthogHost as string) || 'https://us.i.posthog.com'
const isPostHogConfigured = projectToken && projectToken !== 'phc_your_project_token_here'
if (__DEV__) {
console.log('PostHog config:', {
projectToken: projectToken ? `SET` : 'NOT SET',
host,
isConfigured: isPostHogConfigured,
})
}
if (!isPostHogConfigured) {
console.warn(
'PostHog project token not configured. Analytics will be disabled. ' +
'Set POSTHOG_PROJECT_TOKEN in your .env file to enable analytics.'
)
}
/**
* PostHog client instance for Expo
*
* Configuration loaded from app.config.js extras via expo-constants.
* Required peer dependencies: expo-file-system, expo-application,
* expo-device, expo-localization
*
* For React Native Web targets, use @react-native-async-storage/async-storage
* instead of expo-file-system (Web and macOS targets not supported by expo-file-system).
*
* @see https://posthog.com/docs/libraries/react-native
*/
export const posthog = new PostHog(projectToken || 'placeholder_key', {
// PostHog API host
host,
// Enable PostHog only when a project token is configured
disabled: !isPostHogConfigured,
// Capture app lifecycle events:
// - Application Installed, Application Updated
// - Application Opened, Application Became Active, Application Backgrounded
captureAppLifecycleEvents: true,
// Enable debug mode in development for verbose logging
debug: __DEV__,
// Batching: queue events and flush periodically to optimize battery usage
flushAt: 20, // Number of events to queue before sending
flushInterval: 10000, // Interval in ms between periodic flushes
maxBatchSize: 100, // Maximum events per batch
maxQueueSize: 1000, // Maximum queued events (oldest dropped when full)
// Feature flags
preloadFeatureFlags: true, // Load flags on initialization
sendFeatureFlagEvent: true, // Track getFeatureFlag calls for experiments
featureFlagsRequestTimeoutMs: 10000, // Timeout for flag requests (prevents blocking)
// Network settings
requestTimeout: 10000, // General request timeout in ms
fetchRetryCount: 3, // Number of retry attempts for failed requests
fetchRetryDelay: 3000, // Delay between retries in ms
})
export const isPostHogEnabled = isPostHogConfigured
src/contexts/AuthContext.tsx
import React, { createContext, useState, useEffect, use } from 'react'
import type { ReactNode } from 'react'
import { usePostHog } from 'posthog-react-native'
import { storage } from '../services/storage'
import type { User } from '../services/storage'
interface AuthContextType {
user: User | null
isLoading: boolean
login: (username: string, password: string) => Promise<boolean>
logout: () => Promise<void>
incrementBurritoConsiderations: () => Promise<void>
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
interface AuthProviderProps {
children: ReactNode
}
export function AuthProvider({ children }: AuthProviderProps) {
const posthog = usePostHog()
const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
const restoreSession = async () => {
try {
const storedUsername = await storage.getCurrentUser()
if (storedUsername) {
const existingUser = await storage.getUser(storedUsername)
if (existingUser) {
setUser(existingUser)
posthog.identify(storedUsername, {
$set: { username: storedUsername },
})
}
}
} catch (error) {
console.error('Failed to restore session:', error)
} finally {
setIsLoading(false)
}
}
restoreSession()
}, [posthog])
// React Compiler auto-memoizes these callbacks - no useCallback needed!
const login = async (username: string, password: string): Promise<boolean> => {
if (!username.trim() || !password.trim()) {
return false
}
try {
const existingUser = await storage.getUser(username)
const isNewUser = !existingUser
const userData: User = existingUser || {
username,
burritoConsiderations: 0,
}
await storage.saveUser(userData)
await storage.setCurrentUser(username)
setUser(userData)
posthog.identify(username, {
$set: { username },
$set_once: { first_login_date: new Date().toISOString() },
})
posthog.capture('user_logged_in', {
username,
is_new_user: isNewUser,
})
return true
} catch (error) {
console.error('Login error:', error)
return false
}
}
const logout = async () => {
posthog.capture('user_logged_out')
posthog.reset()
await storage.removeCurrentUser()
setUser(null)
}
const incrementBurritoConsiderations = async () => {
if (user) {
const updatedUser: User = {
...user,
burritoConsiderations: user.burritoConsiderations + 1,
}
setUser(updatedUser)
await storage.saveUser(updatedUser)
}
}
return (
<AuthContext
value={{
user,
isLoading,
login,
logout,
incrementBurritoConsiderations,
}}
>
{children}
</AuthContext>
)
}
/**
* React 19: Use the `use` API instead of useContext
* - Can be called conditionally (unlike useContext)
* - Enables more flexible component composition
*/
export function useAuth() {
const context = use(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}
src/services/storage.ts
import AsyncStorage from '@react-native-async-storage/async-storage'
const CURRENT_USER_KEY = 'currentUser'
const USERS_KEY = 'users'
export interface User {
username: string
burritoConsiderations: number
}
/**
* Storage service for persisting user data
* Uses AsyncStorage (React Native's async key-value storage)
*/
export const storage = {
/**
* Get the currently logged in user's username
*/
getCurrentUser: async (): Promise<string | null> => {
try {
return await AsyncStorage.getItem(CURRENT_USER_KEY)
} catch (error) {
console.error('Error getting current user:', error)
return null
}
},
/**
* Set the currently logged in user's username
*/
setCurrentUser: async (username: string): Promise<void> => {
try {
await AsyncStorage.setItem(CURRENT_USER_KEY, username)
} catch (error) {
console.error('Error setting current user:', error)
}
},
/**
* Remove the current user (logout)
*/
removeCurrentUser: async (): Promise<void> => {
try {
await AsyncStorage.removeItem(CURRENT_USER_KEY)
} catch (error) {
console.error('Error removing current user:', error)
}
},
/**
* Get all stored users
*/
getUsers: async (): Promise<Record<string, User>> => {
try {
const data = await AsyncStorage.getItem(USERS_KEY)
return data ? JSON.parse(data) : {}
} catch (error) {
console.error('Error getting users:', error)
return {}
}
},
/**
* Get a specific user by username
*/
getUser: async (username: string): Promise<User | null> => {
try {
const users = await storage.getUsers()
return users[username] || null
} catch (error) {
console.error('Error getting user:', error)
return null
}
},
/**
* Save a user to storage
*/
saveUser: async (user: User): Promise<void> => {
try {
const users = await storage.getUsers()
users[user.username] = user
await AsyncStorage.setItem(USERS_KEY, JSON.stringify(users))
} catch (error) {
console.error('Error saving user:', error)
}
},
/**
* Clear all stored data (for testing/debugging)
*/
clearAll: async (): Promise<void> => {
try {
await AsyncStorage.multiRemove([CURRENT_USER_KEY, USERS_KEY])
} catch (error) {
console.error('Error clearing storage:', error)
}
},
}
src/styles/theme.ts
/**
* Theme constants for consistent styling across the app
* Matches the color scheme from the TanStack Start web version
*/
export const colors = {
// Primary colors
primary: '#0070f3',
primaryDark: '#0051cc',
// Status colors
success: '#28a745',
successDark: '#218838',
danger: '#dc3545',
dangerDark: '#c82333',
// Feature colors
burrito: '#e07c24',
burritoDark: '#c96a1a',
// Neutral colors
background: '#f5f5f5',
white: '#ffffff',
text: '#333333',
textSecondary: '#666666',
textLight: '#999999',
border: '#dddddd',
borderLight: '#eeeeee',
// Component-specific
statsBackground: '#f8f9fa',
headerBackground: '#333333',
headerText: '#ffffff',
inputBackground: '#ffffff',
cardBackground: '#ffffff',
}
export const spacing = {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
xxl: 48,
}
export const typography = {
sizes: {
xs: 12,
sm: 14,
md: 16,
lg: 18,
xl: 24,
xxl: 32,
},
weights: {
normal: '400' as const,
medium: '500' as const,
semibold: '600' as const,
bold: '700' as const,
},
}
export const borderRadius = {
sm: 4,
md: 8,
lg: 12,
full: 9999,
}
export const shadows = {
sm: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
md: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
lg: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 5,
},
}
references/EXAMPLE-fastapi.md
PostHog fastapi Example Project
Repository: https://github.com/PostHog/context-mill Path: example-apps/fastapi
README.md
PostHog FastAPI Example
A FastAPI application demonstrating PostHog integration for analytics, feature flags, and error tracking.
Features
- User registration and authentication with cookie-based sessions
- SQLite database persistence with SQLAlchemy
- User identification and property tracking
- Custom event tracking
- Feature flags with payload support
- Error tracking with manual exception capture
Quick Start
Create and activate a virtual environment:
python -m venv venv source venv/bin/activate # On Windows: venv\Scripts\activateInstall dependencies:
pip install -r requirements.txtCopy the environment file and configure:
cp .env.example .env # Edit .env with your PostHog project keyRun the application:
python run.pyOpen http://localhost:5002 and either:
- Login with default credentials:
admin@example.com/admin - Or click "Sign up here" to create a new account
- Login with default credentials:
PostHog Integration Points
User Registration
New users are identified and tracked on signup using the context-based API:
with new_context():
identify_context(user.email)
tag('email', user.email)
tag('is_staff', user.is_staff)
capture('user_signed_up', properties={'signup_method': 'form'})User Identification
Users are identified on login with their properties:
with new_context():
identify_context(user.email)
tag('email', user.email)
tag('is_staff', user.is_staff)
capture('user_logged_in', properties={'login_method': 'password'})Event Tracking
Custom events are captured throughout the app:
with new_context():
identify_context(current_user.email)
capture('burrito_considered', properties={'total_considerations': count})Feature Flags
The dashboard demonstrates feature flag checking:
show_new_feature = posthog.feature_enabled(
'new-dashboard-feature',
current_user.email,
person_properties={'email': current_user.email, 'is_staff': current_user.is_staff}
)
feature_config = posthog.get_feature_flag_payload('new-dashboard-feature', current_user.email)Error Tracking
The example demonstrates two approaches to error tracking:
Manual capture for specific critical operations** (app/routers/api.py).
try:
# Critical operation that might fail
result = process_payment()
except Exception as e:
# Manually capture this specific exception
with new_context():
identify_context(current_user.email)
event_id = posthog.capture_exception(e)
return JSONResponse({
"error": "Operation failed",
"error_id": event_id,
"message": f"Error captured in PostHog. Reference ID: {event_id}"
}, status_code=500)The /api/test-error endpoint demonstrates manual exception capture. Use ?capture=true to capture in PostHog, or ?capture=false to skip tracking.
Project Structure
basics/fastapi/
├── app/
│ ├── __init__.py # Package marker
│ ├── config.py # Pydantic Settings configuration
│ ├── database.py # SQLAlchemy setup
│ ├── dependencies.py # FastAPI dependency injection
│ ├── main.py # Application factory and lifespan
│ ├── models.py # User model (SQLAlchemy)
│ ├── routers/
│ │ ├── __init__.py # Routers package
│ │ ├── main.py # Page routes (HTML)
│ │ └── api.py # API endpoints (JSON)
│ └── templates/ # Jinja2 templates
├── .env.example
├── .gitignore
├── requirements.txt
├── README.md
└── run.py # Entry point (uvicorn).env.example
POSTHOG_PROJECT_TOKEN=<ph_project_token>
POSTHOG_HOST=https://us.i.posthog.com
SECRET_KEY=your-secret-key-here
DEBUG=True
POSTHOG_DISABLED=False
app/init.py
"""FastAPI PostHog example application."""
app/config.py
"""FastAPI application configuration using Pydantic Settings."""
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
# Application
secret_key: str = "dev-secret-key-change-in-production"
debug: bool = True
# Database (SQLite like Flask example)
database_url: str = "sqlite:///./db.sqlite3"
# PostHog
posthog_project_token: str = "<ph_project_token>"
posthog_host: str = "https://us.i.posthog.com"
posthog_disabled: bool = False
@lru_cache
def get_settings() -> Settings:
"""Get cached settings instance."""
return Settings()
app/database.py
"""Database configuration with SQLAlchemy."""
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.config import get_settings
settings = get_settings()
engine = create_engine(
settings.database_url,
connect_args={"check_same_thread": False}, # Required for SQLite
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
"""Base class for SQLAlchemy models."""
pass
def get_db():
"""Dependency that provides a database session."""
db = SessionLocal()
try:
yield db
finally:
db.close()
def init_db():
"""Create all database tables."""
Base.metadata.create_all(bind=engine)
app/dependencies.py
"""Authentication dependencies for FastAPI."""
from typing import Annotated, Optional
from fastapi import Cookie, Depends, HTTPException, status
from itsdangerous import BadSignature, URLSafeSerializer
from sqlalchemy.orm import Session
from app.config import get_settings
from app.database import get_db
from app.models import User
settings = get_settings()
serializer = URLSafeSerializer(settings.secret_key)
def get_session_user_id(session_token: Annotated[Optional[str], Cookie()] = None) -> Optional[int]:
"""Extract user ID from session cookie."""
if not session_token:
return None
try:
data = serializer.loads(session_token)
return data.get("user_id")
except BadSignature:
return None
def get_current_user(
db: Annotated[Session, Depends(get_db)],
user_id: Annotated[Optional[int], Depends(get_session_user_id)],
) -> Optional[User]:
"""Get the current authenticated user, or None if not authenticated."""
if user_id is None:
return None
return User.get_by_id(db, user_id)
def require_auth(
current_user: Annotated[Optional[User], Depends(get_current_user)],
) -> User:
"""Require authentication - raises 401 if not authenticated."""
if current_user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
)
return current_user
def create_session_token(user_id: int) -> str:
"""Create a signed session token for the user."""
return serializer.dumps({"user_id": user_id})
# Type aliases for cleaner dependency injection
CurrentUser = Annotated[Optional[User], Depends(get_current_user)]
RequiredUser = Annotated[User, Depends(require_auth)]
DbSession = Annotated[Session, Depends(get_db)]
app/main.py
"""FastAPI application with PostHog integration."""
from contextlib import asynccontextmanager
from pathlib import Path
import posthog
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from app.config import get_settings
from app.database import SessionLocal, init_db
from app.middleware import PostHogMiddleware
from app.models import User
from app.routers import api, main
settings = get_settings()
# Setup templates
templates_dir = Path(__file__).parent / "templates"
templates = Jinja2Templates(directory=str(templates_dir))
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan events for startup/shutdown."""
# Startup: Initialize PostHog
if not settings.posthog_disabled:
posthog.api_key = settings.posthog_project_token
posthog.host = settings.posthog_host
posthog.debug = settings.debug
# Initialize database and seed default user
init_db()
db = SessionLocal()
try:
if not User.get_by_email(db, "admin@example.com"):
User.create_user(
db,
email="admin@example.com",
password="admin",
is_staff=True,
)
finally:
db.close()
yield
# Shutdown: Flush PostHog events
if not settings.posthog_disabled:
posthog.flush()
app = FastAPI(
title="PostHog FastAPI Example",
description="Example application demonstrating PostHog integration with FastAPI",
lifespan=lifespan,
)
app.add_middleware(PostHogMiddleware)
# Include routers
app.include_router(main.router)
app.include_router(api.router, prefix="/api")
# Error handlers
@app.exception_handler(404)
async def not_found_handler(request: Request, exc):
"""Handle 404 errors."""
if request.url.path.startswith("/api/"):
return JSONResponse({"error": "Not found"}, status_code=404)
return templates.TemplateResponse(
request, "errors/404.html", status_code=404
)
@app.exception_handler(500)
async def internal_error_handler(request: Request, exc):
"""Handle 500 errors."""
if request.url.path.startswith("/api/"):
return JSONResponse({"error": "Internal server error"}, status_code=500)
return templates.TemplateResponse(
request, "errors/500.html", status_code=500
)
app/middleware.py
"""PostHog middleware for automatic context and user identification.
Uses pure ASGI middleware instead of BaseHTTPMiddleware for better performance/best practices.
"""
from http.cookies import SimpleCookie
from typing import Callable, Optional
from posthog import identify_context, new_context, tag
from app.config import get_settings
from app.database import SessionLocal
from app.dependencies import serializer
from app.models import User
class PostHogMiddleware:
"""Pure ASGI middleware that wraps each request in a PostHog context.
If the user is authenticated, identifies them in the context so routes
can just call capture() without needing to set up context each time.
Uses pure ASGI interface for better performance than BaseHTTPMiddleware.
"""
def __init__(self, app):
self.app = app
self.settings = get_settings()
async def __call__(self, scope, receive, send):
if scope["type"] != "http" or self.settings.posthog_disabled:
await self.app(scope, receive, send)
return
user = self._get_user_from_scope(scope)
with new_context():
if user:
identify_context(str(user.id))
tag("is_staff", user.is_staff)
await self.app(scope, receive, send)
def _get_user_from_scope(self, scope) -> Optional[User]:
"""Extract authenticated user from session cookie in ASGI scope."""
headers = dict(scope.get("headers", []))
cookie_header = headers.get(b"cookie", b"").decode("utf-8")
if not cookie_header:
return None
cookies = SimpleCookie()
cookies.load(cookie_header)
session_cookie = cookies.get("session_token")
if not session_cookie:
return None
session_token = session_cookie.value
try:
data = serializer.loads(session_token)
user_id = data.get("user_id")
except Exception:
return None
if not user_id:
return None
db = SessionLocal()
try:
return User.get_by_id(db, user_id)
finally:
db.close()
app/models.py
"""User model with SQLite persistence (similar to Flask example)."""
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import Boolean, DateTime, Integer, String
from sqlalchemy.orm import Mapped, Session, mapped_column
from werkzeug.security import check_password_hash, generate_password_hash
from app.database import Base
class User(Base):
"""User model with SQLite persistence."""
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
email: Mapped[str] = mapped_column(String(254), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(256), nullable=False)
name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
is_staff: Mapped[bool] = mapped_column(Boolean, default=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
login_count: Mapped[int] = mapped_column(Integer, default=0)
date_joined: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.now(timezone.utc)
)
def set_password(self, password: str) -> None:
"""Hash and set the user's password."""
self.password_hash = generate_password_hash(password, method="pbkdf2:sha256")
def check_password(self, password: str) -> bool:
"""Verify the password against the hash."""
return check_password_hash(self.password_hash, password)
@classmethod
def create_user(
cls, db: Session, email: str, password: str, is_staff: bool = False
) -> "User":
"""Create and save a new user."""
user = cls(email=email, is_staff=is_staff)
# nosemgrep: python.django.security.audit.unvalidated-password.unvalidated-password
user.set_password(password)
db.add(user)
db.commit()
db.refresh(user)
return user
@classmethod
def get_by_id(cls, db: Session, user_id: int) -> Optional["User"]:
"""Get user by ID."""
return db.query(cls).filter(cls.id == user_id).first()
@classmethod
def get_by_email(cls, db: Session, email: str) -> Optional["User"]:
"""Get user by email."""
return db.query(cls).filter(cls.email == email).first()
@classmethod
def authenticate(cls, db: Session, email: str, password: str) -> Optional["User"]:
"""Authenticate user with email and password."""
user = cls.get_by_email(db, email)
if user and user.check_password(password):
return user
return None
def record_login(self, db: Session) -> bool:
"""Record a login and return whether this is the user's first login."""
is_first_login = self.login_count == 0
self.login_count += 1
db.commit()
return is_first_login
def update_profile(self, db: Session, name: Optional[str] = None) -> list:
"""Update user profile and return list of changed fields."""
changed_fields = []
if name is not None and name != self.name:
self.name = name
changed_fields.append("name")
if changed_fields:
db.commit()
return changed_fields
def __repr__(self) -> str:
return f"<User {self.email}>"
app/routers/init.py
"""FastAPI routers package."""
app/routers/api.py
"""API endpoints demonstrating PostHog integration patterns."""
from typing import Annotated
import posthog
from fastapi import APIRouter, Cookie, Form, Query
from fastapi.responses import JSONResponse
from posthog import capture
from app.dependencies import RequiredUser
router = APIRouter()
MAX_BURRITO_COUNT = 10000
@router.post("/burrito/consider")
async def consider_burrito(
current_user: RequiredUser,
burrito_count: Annotated[int, Cookie()] = 0,
):
"""Track burrito consideration event."""
safe_count = max(0, min(burrito_count, MAX_BURRITO_COUNT))
new_count = safe_count + 1
capture("burrito_considered", properties={"total_considerations": new_count})
response = JSONResponse({"success": True, "count": new_count})
response.set_cookie(
key="burrito_count",
value=str(new_count),
httponly=True,
samesite="lax",
)
return response
@router.post("/test-error")
async def test_error(
current_user: RequiredUser,
capture_param: Annotated[str, Query(alias="capture")] = "true",
):
"""Test endpoint demonstrating manual exception capture in PostHog."""
should_capture = capture_param.lower() == "true"
try:
raise Exception("Test exception from critical operation")
except Exception as e:
if should_capture:
event_id = posthog.capture_exception(e)
return JSONResponse(
{
"error": "Operation failed",
"error_id": event_id,
"message": f"Error captured in PostHog. Reference ID: {event_id}",
},
status_code=500,
)
else:
return JSONResponse({"error": "Operation failed"}, status_code=500)
@router.post("/trigger-error")
async def trigger_error(
current_user: RequiredUser,
error_type: Annotated[str, Form()] = "generic",
):
"""Trigger different error types for testing error tracking."""
error_messages = {
"value": "Invalid value provided",
"key": "Missing required key",
"generic": "Generic test error",
}
safe_error_type = error_type if error_type in error_messages else "generic"
error_message = error_messages[safe_error_type]
try:
if safe_error_type == "value":
raise ValueError(error_message)
elif safe_error_type == "key":
raise KeyError("missing_key")
else:
raise Exception(error_message)
except Exception as e:
posthog.capture_exception(e)
capture(
"error_triggered",
properties={"error_type": safe_error_type, "error_message": error_message},
)
return JSONResponse(
{
"success": True,
"message": "Error captured in PostHog",
"error": error_message,
}
)
@router.post("/reports/activity")
async def generate_activity_report(
current_user: RequiredUser,
report_type: Annotated[str, Form()] = "summary",
):
"""Generate user activity report."""
valid_report_types = {"summary", "detailed", "export"}
safe_report_type = report_type if report_type in valid_report_types else "summary"
report_data = {
"user": current_user.email,
"name": current_user.name,
"date_joined": current_user.date_joined.isoformat(),
"login_count": current_user.login_count,
"is_staff": current_user.is_staff,
}
if safe_report_type == "detailed":
report_data["account_age_days"] = (
__import__("datetime").datetime.now(__import__("datetime").timezone.utc)
- current_user.date_joined
).days
row_count = len(report_data)
capture(
"report_generated",
properties={
"report_type": safe_report_type,
"row_count": row_count,
"username": current_user.email,
},
)
return JSONResponse(
{
"success": True,
"report_type": safe_report_type,
"row_count": row_count,
"data": report_data,
}
)
app/routers/main.py
"""Main routes demonstrating PostHog integration patterns."""
from pathlib import Path
from typing import Annotated
import posthog
from fastapi import APIRouter, Cookie, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from posthog import capture, identify_context, new_context
from app.dependencies import (
CurrentUser,
DbSession,
RequiredUser,
create_session_token,
)
from app.models import User
router = APIRouter()
# Setup templates
templates_dir = Path(__file__).parent.parent / "templates"
templates = Jinja2Templates(directory=str(templates_dir))
@router.get("/", response_class=HTMLResponse)
async def home(request: Request, current_user: CurrentUser, db: DbSession):
"""Home/login page."""
if current_user:
return RedirectResponse(url="/dashboard", status_code=302)
return templates.TemplateResponse(
request, "home.html", {"current_user": current_user}
)
@router.post("/", response_class=HTMLResponse)
async def login(
request: Request,
db: DbSession,
email: Annotated[str, Form()],
password: Annotated[str, Form()],
):
"""Handle login form submission."""
user = User.authenticate(db, email, password)
if user:
is_new_user = user.record_login(db)
with new_context():
identify_context(str(user.id))
capture(
"user_logged_in",
properties={
"$set": {"email": user.email, "is_staff": user.is_staff},
"is_new_user": is_new_user,
},
)
# Create session and redirect
response = RedirectResponse(url="/dashboard", status_code=302)
response.set_cookie(
key="session_token",
value=create_session_token(user.id),
httponly=True,
samesite="lax",
)
return response
# Login failed
return templates.TemplateResponse(
request,
"home.html",
{"current_user": None, "error": "Invalid email or password"},
)
@router.get("/signup", response_class=HTMLResponse)
async def signup_page(request: Request, current_user: CurrentUser):
"""User registration page."""
if current_user:
return RedirectResponse(url="/dashboard", status_code=302)
return templates.TemplateResponse(
request, "signup.html", {"current_user": current_user}
)
@router.post("/signup", response_class=HTMLResponse)
async def signup(
request: Request,
db: DbSession,
email: Annotated[str, Form()],
password: Annotated[str, Form()],
password_confirm: Annotated[str, Form()],
):
"""Handle signup form submission."""
error = None
if not email or not password:
error = "Email and password are required"
elif password != password_confirm:
error = "Passwords do not match"
elif User.get_by_email(db, email):
error = "Email already registered"
if error:
return templates.TemplateResponse(
request, "signup.html", {"current_user": None, "error": error}
)
# Create new user
user = User.create_user(db, email=email, password=password, is_staff=False)
with new_context():
identify_context(str(user.id))
capture(
"user_signed_up",
properties={
"$set": {"email": user.email, "is_staff": user.is_staff},
"signup_method": "form",
},
)
# Create session and redirect
response = RedirectResponse(url="/dashboard", status_code=302)
response.set_cookie(
key="session_token",
value=create_session_token(user.id),
httponly=True,
samesite="lax",
)
return response
@router.get("/logout")
async def logout(current_user: RequiredUser):
"""Logout and capture event."""
capture("user_logged_out")
response = RedirectResponse(url="/", status_code=302)
response.delete_cookie(key="session_token")
return response
@router.get("/dashboard", response_class=HTMLResponse)
async def dashboard(
request: Request,
current_user: RequiredUser,
):
"""Dashboard with feature flag demonstration."""
capture("dashboard_viewed", properties={"is_staff": current_user.is_staff})
# Check feature flag
show_new_feature = posthog.feature_enabled(
"new-dashboard-feature",
current_user.email,
person_properties={
"email": current_user.email,
"is_staff": current_user.is_staff,
},
)
# Get feature flag payload
feature_config = posthog.get_feature_flag_payload(
"new-dashboard-feature", current_user.email
)
return templates.TemplateResponse(
request,
"dashboard.html",
{
"current_user": current_user,
"show_new_feature": show_new_feature,
"feature_config": feature_config,
},
)
@router.get("/burrito", response_class=HTMLResponse)
async def burrito(
request: Request,
current_user: RequiredUser,
burrito_count: Annotated[int, Cookie()] = 0,
):
"""Burrito consideration tracker page."""
return templates.TemplateResponse(
request,
"burrito.html",
{"current_user": current_user, "burrito_count": burrito_count},
)
@router.get("/profile", response_class=HTMLResponse)
async def profile(request: Request, current_user: RequiredUser):
"""User profile page."""
capture("profile_viewed")
return templates.TemplateResponse(
request, "profile.html", {"current_user": current_user}
)
@router.post("/profile", response_class=HTMLResponse)
async def update_profile(
request: Request,
db: DbSession,
current_user: RequiredUser,
name: Annotated[str, Form()],
):
"""Handle profile update."""
fields_changed = current_user.update_profile(db, name=name)
if fields_changed:
capture(
"profile_updated",
properties={
"username": current_user.email,
"fields_changed": fields_changed,
},
)
return templates.TemplateResponse(
request,
"profile.html",
{
"current_user": current_user,
"success": "Profile updated" if fields_changed else None,
},
)
app/templates/base.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}PostHog FastAPI Example{% endblock %}</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
line-height: 1.6;
background-color: #f5f5f5;
color: #333;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
nav {
background: #1d4ed8;
padding: 15px 20px;
margin-bottom: 30px;
}
nav a {
color: white;
text-decoration: none;
margin-right: 20px;
}
nav a:hover {
text-decoration: underline;
}
.card {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1, h2, h3 {
margin-bottom: 15px;
color: #1d4ed8;
}
button, .btn {
background: #1d4ed8;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
display: inline-block;
text-decoration: none;
}
button:hover, .btn:hover {
background: #1e40af;
}
button.danger {
background: #dc2626;
}
button.danger:hover {
background: #b91c1c;
}
input {
width: 100%;
padding: 10px;
margin-bottom: 15px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 14px;
}
.messages {
margin-bottom: 20px;
}
.message {
padding: 10px 15px;
border-radius: 5px;
margin-bottom: 10px;
}
.message.error {
background: #fee2e2;
color: #dc2626;
}
.message.success {
background: #d1fae5;
color: #059669;
}
.feature-flag {
background: #fef3c7;
border: 2px dashed #f59e0b;
padding: 15px;
border-radius: 8px;
margin: 20px 0;
}
code {
background: #f3f4f6;
padding: 2px 6px;
border-radius: 3px;
font-family: monospace;
}
pre {
background: #1e293b;
color: #e2e8f0;
padding: 16px;
border-radius: 8px;
overflow-x: auto;
font-size: 13px;
}
.count {
font-size: 48px;
font-weight: bold;
color: #1d4ed8;
text-align: center;
padding: 20px;
}
table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #eee;
}
th {
background: #f8fafc;
font-weight: 600;
}
</style>
</head>
<body>
{% if current_user %}
<nav>
<a href="/dashboard">Dashboard</a>
<a href="/burrito">Burrito</a>
<a href="/profile">Profile</a>
<a href="/logout" style="float: right;">Logout ({{ current_user.email }})</a>
</nav>
{% endif %}
<div class="container">
{% if error %}
<div class="messages">
<div class="message error">{{ error }}</div>
</div>
{% endif %}
{% if success %}
<div class="messages">
<div class="message success">{{ success }}</div>
</div>
{% endif %}
{% block content %}{% endblock %}
</div>
{% block scripts %}{% endblock %}
</body>
</html>
app/templates/burrito.html
{% extends "base.html" %}
{% block title %}Burrito - PostHog FastAPI Example{% endblock %}
{% block content %}
<div class="card">
<h1>Burrito Consideration Tracker</h1>
<p>This page demonstrates custom event tracking with PostHog.</p>
<div class="count" id="burrito-count">{{ burrito_count }}</div>
<p style="text-align: center; color: #666;">Times you've considered a burrito</p>
<div style="text-align: center; margin-top: 20px;">
<button onclick="considerBurrito()">Consider a Burrito</button>
</div>
</div>
<div class="card">
<h3>Code Example</h3>
<pre>
# API endpoint captures the event
with new_context():
identify_context(current_user.email)
capture('burrito_considered', properties={
'total_considerations': burrito_count
})</pre>
</div>
{% endblock %}
{% block scripts %}
<script>
async function considerBurrito() {
try {
const response = await fetch('/api/burrito/consider', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (data.success) {
document.getElementById('burrito-count').textContent = data.count;
}
} catch (error) {
console.error('Error:', error);
}
}
</script>
{% endblock %}
app/templates/dashboard.html
{% extends "base.html" %}
{% block title %}Dashboard - PostHog FastAPI Example{% endblock %}
{% block content %}
<div class="card">
<h1>Dashboard</h1>
<p>Welcome back, {{ current_user.email }}!</p>
</div>
<div class="card">
<h2>Feature Flags</h2>
{% if show_new_feature %}
<div class="feature-flag">
<strong>New Feature Enabled!</strong>
<p>You're seeing this because the <code>new-dashboard-feature</code> flag is enabled for you.</p>
{% if feature_config %}
<p><strong>Feature Configuration:</strong></p>
<pre>{{ feature_config | tojson(indent=2) }}</pre>
{% endif %}
</div>
{% else %}
<p>The <code>new-dashboard-feature</code> flag is not enabled for your account.</p>
{% endif %}
<h3 style="margin-top: 20px;">Code Example</h3>
<pre>
# Check if feature flag is enabled
show_new_feature = posthog.feature_enabled(
'new-dashboard-feature',
user_id,
person_properties={
'email': current_user.email,
'is_staff': current_user.is_staff
}
)
# Get feature flag payload
feature_config = posthog.get_feature_flag_payload(
'new-dashboard-feature',
user_id
)</pre>
</div>
{% endblock %}
app/templates/errors/404.html
{% extends "base.html" %}
{% block title %}Page Not Found - PostHog FastAPI Example{% endblock %}
{% block content %}
<div class="card">
<h1>404 - Page Not Found</h1>
<p>The page you're looking for doesn't exist.</p>
<a href="/" class="btn">Go Home</a>
</div>
{% endblock %}
app/templates/errors/500.html
{% extends "base.html" %}
{% block title %}Server Error - PostHog FastAPI Example{% endblock %}
{% block content %}
<div class="card">
<h1>500 - Internal Server Error</h1>
<p>Something went wrong on our end. Please try again later.</p>
<a href="/" class="btn">Go Home</a>
</div>
{% endblock %}
app/templates/home.html
{% extends "base.html" %}
{% block title %}Login - PostHog FastAPI Example{% endblock %}
{% block content %}
<div class="card">
<h1>Welcome to PostHog FastAPI Example</h1>
<p>This example demonstrates how to integrate PostHog with a FastAPI application.</p>
<form method="POST">
<label for="email">Email</label>
<input type="email" id="email" name="email" required>
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
<button type="submit">Login</button>
</form>
<p style="margin-top: 16px; font-size: 14px; color: #666;">
Don't have an account? <a href="/signup">Sign up here</a>
</p>
<p style="font-size: 14px; color: #666;">
<strong>Tip:</strong> Default credentials are admin@example.com/admin
</p>
</div>
<div class="card">
<h2>Features Demonstrated</h2>
<ul style="margin-left: 20px; color: #666;">
<li>User registration and identification</li>
<li>Event tracking</li>
<li>Feature flags</li>
<li>Error tracking</li>
<li>Group analytics</li>
</ul>
</div>
{% endblock %}
app/templates/profile.html
{% extends "base.html" %}
{% block title %}Profile - PostHog FastAPI Example{% endblock %}
{% block content %}
<div class="card">
<h1>Your Profile</h1>
<p>This page demonstrates profile updates and report generation with PostHog.</p>
{% if success %}
<div class="message success">{{ success }}</div>
{% endif %}
<form method="POST" action="/profile">
<table>
<tr>
<th>Email</th>
<td>{{ current_user.email }}</td>
</tr>
<tr>
<th>Name</th>
<td>
<input type="text" name="name" value="{{ current_user.name or '' }}" placeholder="Enter your name">
</td>
</tr>
<tr>
<th>Date Joined</th>
<td>{{ current_user.date_joined.strftime('%Y-%m-%d %H:%M') }}</td>
</tr>
<tr>
<th>Login Count</th>
<td>{{ current_user.login_count }}</td>
</tr>
<tr>
<th>Staff Status</th>
<td>{{ 'Yes' if current_user.is_staff else 'No' }}</td>
</tr>
</table>
<button type="submit">Update Profile</button>
</form>
</div>
<div class="card">
<h2>Activity Reports</h2>
<p>Generate a report of your account activity:</p>
<div style="margin: 20px 0;">
<button onclick="generateReport('summary')">Summary Report</button>
<button onclick="generateReport('detailed')">Detailed Report</button>
</div>
<div id="report-result" style="display: none;" class="message"></div>
</div>
<div class="card">
<h2>Error Tracking Demo</h2>
<p>Click a button to trigger an error and see it captured in PostHog:</p>
<div style="margin: 20px 0;">
<button class="danger" onclick="triggerError('value')">
Trigger ValueError
</button>
<button class="danger" onclick="triggerError('key')">
Trigger KeyError
</button>
<button class="danger" onclick="triggerError('generic')">
Trigger Generic Error
</button>
</div>
<div id="error-result" style="display: none;" class="message"></div>
</div>
<div class="card">
<h3>Code Example</h3>
<pre>
try:
raise ValueError('Invalid value provided')
except Exception as e:
# Capture exception and event with user context
with new_context():
identify_context(current_user.email)
posthog.capture_exception(e)
capture('error_triggered', properties={
'error_type': 'value',
'error_message': str(e)
})</pre>
</div>
{% endblock %}
{% block scripts %}
<script>
async function triggerError(errorType) {
const resultDiv = document.getElementById('error-result');
try {
const formData = new FormData();
formData.append('error_type', errorType);
const response = await fetch('/api/trigger-error', {
method: 'POST',
body: formData
});
const data = await response.json();
resultDiv.style.display = 'block';
resultDiv.className = 'message ' + (data.success ? 'success' : 'error');
resultDiv.textContent = data.message + ': ' + data.error;
} catch (error) {
console.error('Error:', error);
resultDiv.style.display = 'block';
resultDiv.className = 'message error';
resultDiv.textContent = 'Request failed: ' + error.message;
}
}
async function generateReport(reportType) {
const resultDiv = document.getElementById('report-result');
try {
const formData = new FormData();
formData.append('report_type', reportType);
const response = await fetch('/api/reports/activity', {
method: 'POST',
body: formData
});
const data = await response.json();
resultDiv.style.display = 'block';
resultDiv.className = 'message success';
resultDiv.innerHTML = '<strong>' + data.report_type + ' report generated</strong> (' + data.row_count + ' rows)<br><pre>' + JSON.stringify(data.data, null, 2) + '</pre>';
} catch (error) {
console.error('Error:', error);
resultDiv.style.display = 'block';
resultDiv.className = 'message error';
resultDiv.textContent = 'Request failed: ' + error.message;
}
}
</script>
{% endblock %}
app/templates/signup.html
{% extends "base.html" %}
{% block title %}Sign Up - PostHog FastAPI Example{% endblock %}
{% block content %}
<div class="card">
<h1>Create an Account</h1>
<p>Sign up to explore the PostHog FastAPI integration example.</p>
<form method="POST">
<label for="email">Email *</label>
<input type="email" id="email" name="email" required>
<label for="password">Password *</label>
<input type="password" id="password" name="password" required>
<label for="password_confirm">Confirm Password *</label>
<input type="password" id="password_confirm" name="password_confirm" required>
<button type="submit">Sign Up</button>
</form>
<p style="margin-top: 16px; font-size: 14px; color: #666;">
Already have an account? <a href="/">Login here</a>
</p>
</div>
<div class="card">
<h2>PostHog Integration</h2>
<p>When you sign up, the following PostHog events are captured:</p>
<ul style="margin-left: 20px; color: #666;">
<li><code>identify_context()</code> - Associates your email with the context</li>
<li><code>tag()</code> - Sets person properties (email, etc.)</li>
<li><code>user_signed_up</code> event - Tracks the signup action</li>
</ul>
<h3 style="margin-top: 20px;">Code Example</h3>
<pre>
# After creating the user
with new_context():
identify_context(user.email)
tag('email', user.email)
tag('is_staff', user.is_staff)
tag('date_joined', user.date_joined.isoformat())
capture('user_signed_up', properties={'signup_method': 'form'})</pre>
</div>
{% endblock %}
requirements.txt
fastapi>=0.109.0
uvicorn>=0.27.0
sqlalchemy>=2.0.0
python-dotenv>=1.0.0
posthog>=3.0.0
pydantic>=2.0.0
pydantic-settings>=2.0.0
jinja2>=3.0.0
python-multipart>=0.0.9
werkzeug>=3.0.0
itsdangerous>=2.0.0
run.py
"""Development server entry point."""
import uvicorn
if __name__ == "__main__":
uvicorn.run("app.main:app", host="0.0.0.0", port=5002, reload=True)
references/EXAMPLE-flask.md
PostHog flask Example Project
Repository: https://github.com/PostHog/context-mill Path: example-apps/flask
README.md
PostHog Flask Example
A Flask application demonstrating PostHog integration for analytics, feature flags, and error tracking.
Features
- User registration and authentication with Flask-Login
- SQLite database persistence with Flask-SQLAlchemy
- User identification and property tracking
- Custom event tracking
- Feature flags with payload support
- Error tracking with manual exception capture
Quick Start
Create and activate a virtual environment:
python -m venv venv source venv/bin/activate # On Windows: venv\Scripts\activateInstall dependencies:
pip install -r requirements.txtCopy the environment file and configure:
cp .env.example .env # Edit .env with your PostHog project keyRun the application:
python run.pyOpen http://localhost:5001 and either:
- Login with default credentials:
admin@example.com/admin - Or click "Sign up here" to create a new account
- Login with default credentials:
PostHog Integration Points
User Registration
New users are identified and tracked on signup using the context-based API:
with new_context():
identify_context(user.email)
tag('email', user.email)
tag('is_staff', user.is_staff)
capture('user_signed_up', properties={'signup_method': 'form'})User Identification
Users are identified on login with their properties:
with new_context():
identify_context(user.email)
tag('email', user.email)
tag('is_staff', user.is_staff)
capture('user_logged_in', properties={'login_method': 'password'})Event Tracking
Custom events are captured throughout the app:
with new_context():
identify_context(current_user.email)
capture('burrito_considered', properties={'total_considerations': count})Feature Flags
The dashboard demonstrates feature flag checking:
show_new_feature = posthog.feature_enabled(
'new-dashboard-feature',
current_user.email,
person_properties={'email': current_user.email, 'is_staff': current_user.is_staff}
)
feature_config = posthog.get_feature_flag_payload('new-dashboard-feature', current_user.email)Error Tracking
The example demonstrates two approaches to error tracking:
Manual capture for specific critical operations** (app/api/routes.py).
try:
# Critical operation that might fail
result = process_payment()
except Exception as e:
# Manually capture this specific exception
with new_context():
identify_context(current_user.email)
event_id = posthog.capture_exception(e)
return jsonify({
"error": "Operation failed",
"error_id": event_id,
"message": f"Error captured in PostHog. Reference ID: {event_id}"
}), 500The /api/test-error endpoint demonstrates manual exception capture. Use ?capture=true to capture in PostHog, or ?capture=false to skip tracking.
Project Structure
basics/flask/
├── app/
│ ├── __init__.py # Application factory
│ ├── config.py # Configuration classes
│ ├── extensions.py # Extension instances
│ ├── models.py # User model (SQLAlchemy)
│ ├── main/
│ │ ├── __init__.py # Main blueprint
│ │ └── routes.py # View functions
│ ├── templates/ # HTML templates
│ └── api/
│ ├── __init__.py # API blueprint
│ └── routes.py # API endpoints
├── .env.example
├── .gitignore
├── requirements.txt
├── README.md
└── run.py # Entry point.env.example
POSTHOG_PROJECT_TOKEN=<ph_project_token>
POSTHOG_HOST=https://us.i.posthog.com
FLASK_SECRET_KEY=your-secret-key-here
FLASK_DEBUG=True
POSTHOG_DISABLED=False
app/init.py
"""Flask application factory."""
import posthog
from flask import Flask, g, jsonify, render_template, request
from flask_login import current_user
from posthog import identify_context, new_context
from werkzeug.exceptions import HTTPException
from app.config import config
from app.extensions import db, login_manager
def create_app(config_name="default"):
"""Application factory."""
app = Flask(__name__)
app.config.from_object(config[config_name])
# Initialize extensions
db.init_app(app)
login_manager.init_app(app)
# Initialize PostHog
if not app.config["POSTHOG_DISABLED"]:
posthog.api_key = app.config["POSTHOG_PROJECT_TOKEN"]
posthog.host = app.config["POSTHOG_HOST"]
posthog.debug = app.config["DEBUG"]
# Import models after db is initialized
from app.models import User
# User loader for Flask-Login
@login_manager.user_loader
def load_user(user_id):
return User.get_by_id(user_id)
# Simple error handlers - no automatic PostHog capture
# Capture exceptions manually only where it makes sense (e.g., test endpoints)
@app.errorhandler(404)
def page_not_found(e):
if request.path.startswith('/api/'):
return jsonify({"error": "Not found"}), 404
return render_template('errors/404.html'), 404
@app.errorhandler(500)
def internal_server_error(e):
if request.path.startswith('/api/'):
return jsonify({"error": "Internal server error"}), 500
return render_template('errors/500.html'), 500
# Register blueprints
from app.api import api_bp
from app.main import main_bp
app.register_blueprint(main_bp)
app.register_blueprint(api_bp, url_prefix="/api")
# Create database tables and seed default admin user
with app.app_context():
db.create_all()
if not User.get_by_email("admin@example.com"):
User.create_user(
email="admin@example.com",
password="admin",
is_staff=True,
)
return app
app/api/init.py
"""API blueprint registration."""
from flask import Blueprint
api_bp = Blueprint("api", __name__)
from app.api import routes # noqa: E402, F401
app/api/routes.py
"""API endpoints demonstrating PostHog integration patterns."""
import posthog
from flask import jsonify, request, session
from flask_login import current_user, login_required
from posthog import capture, identify_context, new_context
from app.api import api_bp
@api_bp.route("/burrito/consider", methods=["POST"])
@login_required
def consider_burrito():
"""Track burrito consideration event."""
# Increment session counter
burrito_count = session.get("burrito_count", 0) + 1
session["burrito_count"] = burrito_count
# PostHog: Capture custom event
with new_context():
identify_context(str(current_user.id))
capture("burrito_considered", properties={"total_considerations": burrito_count})
return jsonify({"success": True, "count": burrito_count})
@api_bp.route("/test-error", methods=["POST"])
@login_required
def test_error():
"""Test endpoint demonstrating manual exception capture in PostHog.
Shows how to intentionally capture specific errors in PostHog.
Use this pattern for critical operations where you want error tracking.
Query params:
- capture: "true" to capture the exception in PostHog, "false" to just raise it
"""
should_capture = request.args.get("capture", "true").lower() == "true"
try:
# Simulate a critical operation failure
raise Exception("Test exception from critical operation")
except Exception as e:
if should_capture:
# Manually capture this specific exception in PostHog
with new_context():
identify_context(str(current_user.id))
event_id = posthog.capture_exception(e)
return jsonify({
"error": "Operation failed",
"error_id": event_id,
"message": f"Error captured in PostHog. Reference ID: {event_id}"
}), 500
else:
# Just return error without PostHog capture
return jsonify({"error": str(e)}), 500
app/config.py
"""Flask application configuration."""
import os
from dotenv import load_dotenv
load_dotenv()
class Config:
"""Base configuration."""
SECRET_KEY = os.environ.get("FLASK_SECRET_KEY", "dev-secret-key-change-in-production")
# Database configuration (SQLite like Django example)
SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", "sqlite:///db.sqlite3")
SQLALCHEMY_TRACK_MODIFICATIONS = False
# PostHog configuration
POSTHOG_PROJECT_TOKEN = os.environ.get("POSTHOG_PROJECT_TOKEN", "<ph_project_token>")
POSTHOG_HOST = os.environ.get("POSTHOG_HOST", "https://us.i.posthog.com")
POSTHOG_DISABLED = os.environ.get("POSTHOG_DISABLED", "False").lower() == "true"
class DevelopmentConfig(Config):
"""Development configuration."""
DEBUG = True
class ProductionConfig(Config):
"""Production configuration."""
DEBUG = False
config = {
"development": DevelopmentConfig,
"production": ProductionConfig,
"default": DevelopmentConfig,
}
app/extensions.py
"""Flask extensions initialized without binding to app."""
from flask_login import LoginManager
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
login_manager = LoginManager()
login_manager.login_view = "main.home"
login_manager.login_message = "Please log in to access this page."
app/main/init.py
"""Main blueprint registration."""
from flask import Blueprint
main_bp = Blueprint("main", __name__, template_folder="../templates")
from app.main import routes # noqa: E402, F401
app/main/routes.py
"""Core view functions demonstrating PostHog integration patterns."""
import posthog
from flask import flash, redirect, render_template, request, session, url_for
from flask_login import current_user, login_required, login_user, logout_user
from posthog import capture, identify_context, new_context
from app.main import main_bp
from app.models import User
@main_bp.route("/", methods=["GET", "POST"])
def home():
"""Home/login page."""
if current_user.is_authenticated:
return redirect(url_for("main.dashboard"))
if request.method == "POST":
email = request.form.get("email")
password = request.form.get("password")
user = User.authenticate(email, password)
if user:
login_user(user)
# PostHog: Identify user and capture login event
with new_context():
identify_context(str(user.id))
# PII belongs in person properties, never in event properties
posthog.set(
distinct_id=str(user.id),
properties={
"email": user.email,
"is_staff": user.is_staff,
"date_joined": user.date_joined.isoformat(),
},
)
capture("user_logged_in", properties={"login_method": "password"})
return redirect(url_for("main.dashboard"))
else:
flash("Invalid email or password", "error")
return render_template("home.html")
@main_bp.route("/signup", methods=["GET", "POST"])
def signup():
"""User registration page."""
if current_user.is_authenticated:
return redirect(url_for("main.dashboard"))
if request.method == "POST":
email = request.form.get("email")
password = request.form.get("password")
password_confirm = request.form.get("password_confirm")
# Validation
if not email or not password:
flash("Email and password are required", "error")
elif password != password_confirm:
flash("Passwords do not match", "error")
elif User.get_by_email(email):
flash("Email already registered", "error")
else:
# Create new user
user = User.create_user(
email=email,
password=password,
is_staff=False,
)
# PostHog: Identify new user and capture signup event
with new_context():
identify_context(str(user.id))
posthog.set(
distinct_id=str(user.id),
properties={
"email": user.email,
"is_staff": user.is_staff,
"date_joined": user.date_joined.isoformat(),
},
)
capture("user_signed_up", properties={"signup_method": "form"})
# Log the user in
login_user(user)
flash("Account created successfully!", "success")
return redirect(url_for("main.dashboard"))
return render_template("signup.html")
@main_bp.route("/logout")
@login_required
def logout():
"""Logout and capture event."""
# PostHog: Capture logout event before session ends
with new_context():
identify_context(str(current_user.id))
capture("user_logged_out")
logout_user()
return redirect(url_for("main.home"))
@main_bp.route("/dashboard")
@login_required
def dashboard():
"""Dashboard with feature flag demonstration."""
# PostHog: Capture dashboard view
with new_context():
identify_context(str(current_user.id))
capture("dashboard_viewed", properties={"is_staff": current_user.is_staff})
# Check feature flag
show_new_feature = posthog.feature_enabled(
"new-dashboard-feature",
current_user.email,
person_properties={
"email": current_user.email,
"is_staff": current_user.is_staff,
},
)
# Get feature flag payload
feature_config = posthog.get_feature_flag_payload(
"new-dashboard-feature", current_user.email
)
return render_template(
"dashboard.html",
show_new_feature=show_new_feature,
feature_config=feature_config,
)
@main_bp.route("/burrito")
@login_required
def burrito():
"""Burrito consideration tracker page."""
burrito_count = session.get("burrito_count", 0)
return render_template("burrito.html", burrito_count=burrito_count)
@main_bp.route("/profile")
@login_required
def profile():
"""User profile page."""
# PostHog: Capture profile view
with new_context():
identify_context(str(current_user.id))
capture("profile_viewed")
return render_template("profile.html")
app/models.py
"""User model with SQLite persistence (similar to Django's auth.User)."""
from datetime import datetime, timezone
from flask_login import UserMixin
from werkzeug.security import check_password_hash, generate_password_hash
from app.extensions import db
class User(UserMixin, db.Model):
"""User model with SQLite persistence."""
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(254), unique=True, nullable=False)
password_hash = db.Column(db.String(256), nullable=False)
is_staff = db.Column(db.Boolean, default=False)
is_active = db.Column(db.Boolean, default=True)
date_joined = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
def set_password(self, password):
"""Hash and set the user's password."""
self.password_hash = generate_password_hash(password)
def check_password(self, password):
"""Verify the password against the hash."""
return check_password_hash(self.password_hash, password)
@classmethod
def create_user(cls, email, password, is_staff=False):
"""Create and save a new user."""
user = cls(email=email, is_staff=is_staff)
# nosemgrep: python.django.security.audit.unvalidated-password.unvalidated-password
user.set_password(password)
db.session.add(user)
db.session.commit()
return user
@classmethod
def get_by_id(cls, user_id):
"""Get user by ID."""
return cls.query.get(int(user_id))
@classmethod
def get_by_email(cls, email):
"""Get user by email."""
return cls.query.filter_by(email=email).first()
@classmethod
def authenticate(cls, email, password):
"""Authenticate user with email and password."""
user = cls.get_by_email(email)
if user and user.check_password(password):
return user
return None
def __repr__(self):
return f"<User {self.email}>"
app/templates/base.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}PostHog Flask Example{% endblock %}</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
line-height: 1.6;
background-color: #f5f5f5;
color: #333;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
nav {
background: #1d4ed8;
padding: 15px 20px;
margin-bottom: 30px;
}
nav a {
color: white;
text-decoration: none;
margin-right: 20px;
}
nav a:hover {
text-decoration: underline;
}
.card {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1, h2, h3 {
margin-bottom: 15px;
color: #1d4ed8;
}
button, .btn {
background: #1d4ed8;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
display: inline-block;
text-decoration: none;
}
button:hover, .btn:hover {
background: #1e40af;
}
button.danger {
background: #dc2626;
}
button.danger:hover {
background: #b91c1c;
}
input {
width: 100%;
padding: 10px;
margin-bottom: 15px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 14px;
}
.messages {
margin-bottom: 20px;
}
.message {
padding: 10px 15px;
border-radius: 5px;
margin-bottom: 10px;
}
.message.error {
background: #fee2e2;
color: #dc2626;
}
.message.success {
background: #d1fae5;
color: #059669;
}
.feature-flag {
background: #fef3c7;
border: 2px dashed #f59e0b;
padding: 15px;
border-radius: 8px;
margin: 20px 0;
}
code {
background: #f3f4f6;
padding: 2px 6px;
border-radius: 3px;
font-family: monospace;
}
pre {
background: #1e293b;
color: #e2e8f0;
padding: 16px;
border-radius: 8px;
overflow-x: auto;
font-size: 13px;
}
.count {
font-size: 48px;
font-weight: bold;
color: #1d4ed8;
text-align: center;
padding: 20px;
}
table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #eee;
}
th {
background: #f8fafc;
font-weight: 600;
}
</style>
</head>
<body>
{% if current_user.is_authenticated %}
<nav>
<a href="{{ url_for('main.dashboard') }}">Dashboard</a>
<a href="{{ url_for('main.burrito') }}">Burrito</a>
<a href="{{ url_for('main.profile') }}">Profile</a>
<a href="{{ url_for('main.logout') }}" style="float: right;">Logout ({{ current_user.email }})</a>
</nav>
{% endif %}
<div class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="messages">
{% for category, message in messages %}
<div class="message {{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
{% block scripts %}{% endblock %}
</body>
</html>
app/templates/burrito.html
{% extends "base.html" %}
{% block title %}Burrito - PostHog Flask Example{% endblock %}
{% block content %}
<div class="card">
<h1>Burrito Consideration Tracker</h1>
<p>This page demonstrates custom event tracking with PostHog.</p>
<div class="count" id="burrito-count">{{ burrito_count }}</div>
<p style="text-align: center; color: #666;">Times you've considered a burrito</p>
<div style="text-align: center; margin-top: 20px;">
<button onclick="considerBurrito()">Consider a Burrito</button>
</div>
</div>
<div class="card">
<h3>Code Example</h3>
<pre>
# API endpoint captures the event
with new_context():
identify_context(current_user.email)
capture('burrito_considered', properties={
'total_considerations': burrito_count
})</pre>
</div>
{% endblock %}
{% block scripts %}
<script>
async function considerBurrito() {
try {
const response = await fetch('/api/burrito/consider', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (data.success) {
document.getElementById('burrito-count').textContent = data.count;
}
} catch (error) {
console.error('Error:', error);
}
}
</script>
{% endblock %}
app/templates/dashboard.html
{% extends "base.html" %}
{% block title %}Dashboard - PostHog Flask Example{% endblock %}
{% block content %}
<div class="card">
<h1>Dashboard</h1>
<p>Welcome back, {{ current_user.username }}!</p>
</div>
<div class="card">
<h2>Feature Flags</h2>
{% if show_new_feature %}
<div class="feature-flag">
<strong>New Feature Enabled!</strong>
<p>You're seeing this because the <code>new-dashboard-feature</code> flag is enabled for you.</p>
{% if feature_config %}
<p><strong>Feature Configuration:</strong></p>
<pre>{{ feature_config | tojson(indent=2) }}</pre>
{% endif %}
</div>
{% else %}
<p>The <code>new-dashboard-feature</code> flag is not enabled for your account.</p>
{% endif %}
<h3 style="margin-top: 20px;">Code Example</h3>
<pre>
# Check if feature flag is enabled
show_new_feature = posthog.feature_enabled(
'new-dashboard-feature',
user_id,
person_properties={
'email': current_user.email,
'is_staff': current_user.is_staff
}
)
# Get feature flag payload
feature_config = posthog.get_feature_flag_payload(
'new-dashboard-feature',
user_id
)</pre>
</div>
{% endblock %}
app/templates/errors/404.html
{% extends "base.html" %}
{% block title %}404 - Page Not Found{% endblock %}
{% block content %}
<div class="card" style="text-align: center; padding: 60px 20px;">
<h1 style="font-size: 72px; color: #dc2626; margin-bottom: 10px;">404</h1>
<h2 style="color: #333; margin-bottom: 20px;">Page Not Found</h2>
<p style="font-size: 18px; color: #666; margin-bottom: 30px;">
The page you're looking for doesn't exist or has been moved.
</p>
{% if error_id %}
<div style="background: #fef3c7; border: 1px solid #fbbf24; border-radius: 8px; padding: 15px; margin: 30px 0;">
<p style="color: #92400e; margin-bottom: 5px; font-weight: 600;">Error Reference ID:</p>
<code style="background: #fff; padding: 5px 10px; border-radius: 4px; font-family: monospace; color: #1e40af;">{{ error_id }}</code>
<p style="color: #92400e; margin-top: 10px; font-size: 14px;">
Share this ID with support if you need assistance.
</p>
</div>
{% endif %}
<div style="margin-top: 40px;">
<a href="{{ url_for('main.home') }}" class="btn" style="margin-right: 10px;">Go to Home</a>
{% if current_user.is_authenticated %}
<a href="{{ url_for('main.dashboard') }}" class="btn">Go to Dashboard</a>
{% endif %}
</div>
</div>
{% endblock %}
app/templates/errors/500.html
{% extends "base.html" %}
{% block title %}500 - Internal Server Error{% endblock %}
{% block content %}
<div class="card" style="text-align: center; padding: 60px 20px;">
<h1 style="font-size: 72px; color: #dc2626; margin-bottom: 10px;">500</h1>
<h2 style="color: #333; margin-bottom: 20px;">Internal Server Error</h2>
<p style="font-size: 18px; color: #666; margin-bottom: 30px;">
Something went wrong on our end. We've been notified and are looking into it.
</p>
{% if error_id %}
<div style="background: #fef3c7; border: 1px solid #fbbf24; border-radius: 8px; padding: 15px; margin: 30px 0;">
<p style="color: #92400e; margin-bottom: 5px; font-weight: 600;">Error Reference ID:</p>
<code style="background: #fff; padding: 5px 10px; border-radius: 4px; font-family: monospace; color: #1e40af;">{{ error_id }}</code>
<p style="color: #92400e; margin-top: 10px; font-size: 14px;">
Share this ID with support if you need assistance. This error has been logged in PostHog.
</p>
</div>
{% endif %}
{% if error and config.DEBUG %}
<div style="background: #fee2e2; border: 1px solid #dc2626; border-radius: 8px; padding: 15px; margin: 30px 0; text-align: left;">
<p style="color: #7f1d1d; margin-bottom: 5px; font-weight: 600;">Debug Information:</p>
<code style="background: #fff; padding: 10px; border-radius: 4px; font-family: monospace; color: #dc2626; display: block; overflow-x: auto;">{{ error }}</code>
</div>
{% endif %}
<div style="margin-top: 40px;">
<a href="{{ url_for('main.home') }}" class="btn" style="margin-right: 10px;">Go to Home</a>
{% if current_user.is_authenticated %}
<a href="{{ url_for('main.dashboard') }}" class="btn">Go to Dashboard</a>
{% endif %}
</div>
</div>
{% endblock %}
app/templates/home.html
{% extends "base.html" %}
{% block title %}Login - PostHog Flask Example{% endblock %}
{% block content %}
<div class="card">
<h1>Welcome to PostHog Flask Example</h1>
<p>This example demonstrates how to integrate PostHog with a Flask application.</p>
<form method="POST">
<label for="email">Email</label>
<input type="email" id="email" name="email" required>
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
<button type="submit">Login</button>
</form>
<p style="margin-top: 16px; font-size: 14px; color: #666;">
Don't have an account? <a href="{{ url_for('main.signup') }}">Sign up here</a>
</p>
<p style="font-size: 14px; color: #666;">
<strong>Tip:</strong> Default credentials are admin@example.com/admin
</p>
</div>
<div class="card">
<h2>Features Demonstrated</h2>
<ul style="margin-left: 20px; color: #666;">
<li>User registration and identification</li>
<li>Event tracking</li>
<li>Feature flags</li>
<li>Error tracking</li>
<li>Group analytics</li>
</ul>
</div>
{% endblock %}
app/templates/profile.html
{% extends "base.html" %}
{% block title %}Profile - PostHog Flask Example{% endblock %}
{% block content %}
<div class="card">
<h1>Your Profile</h1>
<p>This page demonstrates error tracking with PostHog.</p>
<table>
<tr>
<th>Email</th>
<td>{{ current_user.email }}</td>
</tr>
<tr>
<th>Date Joined</th>
<td>{{ current_user.date_joined.strftime('%Y-%m-%d %H:%M') }}</td>
</tr>
<tr>
<th>Staff Status</th>
<td>{{ 'Yes' if current_user.is_staff else 'No' }}</td>
</tr>
</table>
</div>
<div class="card">
<h2>Error Tracking Demo</h2>
<p>Click a button to trigger an error and see it captured in PostHog:</p>
<div style="margin: 20px 0;">
<button class="danger" onclick="triggerError('value')">
Trigger ValueError
</button>
<button class="danger" onclick="triggerError('key')">
Trigger KeyError
</button>
<button class="danger" onclick="triggerError('generic')">
Trigger Generic Error
</button>
</div>
<div id="error-result" style="display: none;" class="message"></div>
</div>
<div class="card">
<h3>Code Example</h3>
<pre>
try:
raise ValueError('Invalid value provided')
except Exception as e:
# Capture exception and event with user context
with new_context():
identify_context(current_user.email)
posthog.capture_exception(e)
capture('error_triggered', properties={
'error_type': 'value',
'error_message': str(e)
})</pre>
</div>
{% endblock %}
{% block scripts %}
<script>
async function triggerError(errorType) {
const resultDiv = document.getElementById('error-result');
try {
const formData = new FormData();
formData.append('error_type', errorType);
const response = await fetch('/api/trigger-error', {
method: 'POST',
body: formData
});
const data = await response.json();
resultDiv.style.display = 'block';
resultDiv.className = 'message ' + (data.success ? 'success' : 'error');
resultDiv.textContent = data.message + ': ' + data.error;
} catch (error) {
console.error('Error:', error);
resultDiv.style.display = 'block';
resultDiv.className = 'message error';
resultDiv.textContent = 'Request failed: ' + error.message;
}
}
</script>
{% endblock %}
app/templates/signup.html
{% extends "base.html" %}
{% block title %}Sign Up - PostHog Flask Example{% endblock %}
{% block content %}
<div class="card">
<h1>Create an Account</h1>
<p>Sign up to explore the PostHog Flask integration example.</p>
<form method="POST">
<label for="email">Email *</label>
<input type="email" id="email" name="email" required>
<label for="password">Password *</label>
<input type="password" id="password" name="password" required>
<label for="password_confirm">Confirm Password *</label>
<input type="password" id="password_confirm" name="password_confirm" required>
<button type="submit">Sign Up</button>
</form>
<p style="margin-top: 16px; font-size: 14px; color: #666;">
Already have an account? <a href="{{ url_for('main.home') }}">Login here</a>
</p>
</div>
<div class="card">
<h2>PostHog Integration</h2>
<p>When you sign up, the following PostHog events are captured:</p>
<ul style="margin-left: 20px; color: #666;">
<li><code>identify_context()</code> - Associates your email with the context</li>
<li><code>tag()</code> - Sets person properties (email, etc.)</li>
<li><code>user_signed_up</code> event - Tracks the signup action</li>
</ul>
<h3 style="margin-top: 20px;">Code Example</h3>
<pre>
# After creating the user
with new_context():
identify_context(user.email)
tag('email', user.email)
tag('is_staff', user.is_staff)
tag('date_joined', user.date_joined.isoformat())
capture('user_signed_up', properties={'signup_method': 'form'})</pre>
</div>
{% endblock %}
requirements.txt
Flask>=3.1.0
Flask-Login>=0.6.3
Flask-SQLAlchemy>=3.1.0
python-dotenv>=1.0.0
posthog>=3.0.0
Werkzeug>=3.0.0
run.py
"""Development server entry point."""
from app import create_app
app = create_app()
if __name__ == "__main__":
app.run(port=5001)
references/EXAMPLE-laravel.md
PostHog laravel Example Project
Repository: https://github.com/PostHog/context-mill Path: example-apps/laravel
README.md
PostHog Laravel Example
A Laravel application demonstrating PostHog integration for analytics, feature flags, and error tracking using Livewire for reactive UI components.
Features
- User registration and authentication with Livewire
- SQLite database persistence with Eloquent ORM
- User identification and property tracking
- Custom event tracking (burrito consideration tracker)
- Page view tracking (dashboard, profile)
- Feature flags with payload support
- Error tracking with manual exception capture
- Reactive UI components with Livewire
Tech Stack
- Framework: Laravel 11.x
- Reactive Components: Livewire 3.x
- Database: SQLite
- Analytics: PostHog PHP SDK
Quick Start
Note: This is a minimal implementation demonstrating PostHog integration. For a production application, you would need to install Laravel via Composer and set up additional dependencies.
Manual Setup (Demonstration)
Install dependencies:
composer installSet up environment:
cp .env.example .env # Edit .env with your PostHog project tokenConfigure PostHog in
.env:POSTHOG_PROJECT_TOKEN=your_posthog_project_token POSTHOG_HOST=https://us.i.posthog.com POSTHOG_DISABLED=falseGenerate application key:
php artisan key:generateCreate database and run migrations:
touch database/database.sqlite php artisan migrate --seedStart the development server:
php artisan serveOpen http://localhost:8000 and either:
- Login with default credentials:
admin@example.com/admin - Or click "Sign up here" to create a new account
- Login with default credentials:
PostHog Service
The PostHogService class (app/Services/PostHogService.php) wraps the PostHog PHP SDK and provides:
| Method | Description |
|---|---|
identify($distinctId, $properties) |
Identify a user with properties |
capture($distinctId, $event, $properties) |
Capture custom events |
captureException($exception, $distinctId) |
Capture exceptions with stack traces |
isFeatureEnabled($key, $distinctId, $properties) |
Check feature flag status |
getFeatureFlagPayload($key, $distinctId) |
Get feature flag payload |
All methods check config('posthog.disabled') and return early if PostHog is disabled.
PostHog Integration Points
User Registration (app/Http/Livewire/Auth/Register.php)
New users are identified and tracked on signup:
$posthog->identify($user->email, $user->getPostHogProperties());
$posthog->capture($user->email, 'user_signed_up', [
'signup_method' => 'form',
]);User Login (app/Http/Livewire/Auth/Login.php)
Users are identified on login with their properties:
$posthog->identify($user->email, $user->getPostHogProperties());
$posthog->capture($user->email, 'user_logged_in', [
'login_method' => 'password',
]);User Logout (routes/web.php)
Logout events are tracked:
$posthog->capture($user->email, 'user_logged_out');Page View Tracking
Dashboard and profile views are tracked (app/Http/Livewire/Dashboard.php, app/Http/Livewire/Profile.php):
$posthog->capture($user->email, 'dashboard_viewed', [
'is_staff' => $user->is_staff,
]);
$posthog->capture($user->email, 'profile_viewed');Custom Event Tracking (app/Http/Livewire/BurritoTracker.php)
The burrito tracker demonstrates custom event capture:
$posthog->identify($user->email, $user->getPostHogProperties());
$posthog->capture($user->email, 'burrito_considered', [
'total_considerations' => $this->burritoCount,
]);Feature Flags (app/Http/Livewire/Dashboard.php)
The dashboard demonstrates feature flag checking:
$this->showNewFeature = $posthog->isFeatureEnabled(
'new-dashboard-feature',
$user->email,
$user->getPostHogProperties()
) ?? false;
$this->featureConfig = $posthog->getFeatureFlagPayload(
'new-dashboard-feature',
$user->email
);Error Tracking
Manual exception capture is demonstrated in multiple places:
Livewire Components (app/Http/Livewire/Dashboard.php, app/Http/Livewire/Profile.php):
try {
throw new \Exception('This is a test error for PostHog tracking');
} catch (\Exception $e) {
$errorId = $posthog->captureException($e, $user->email);
$this->successMessage = "Error captured in PostHog! Error ID: {$errorId}";
}API Endpoint (app/Http/Controllers/Api/ErrorTestController.php):
try {
throw new \Exception('Test exception from critical operation');
} catch (\Throwable $e) {
if ($shouldCapture) {
$posthog->identify($user->email, $user->getPostHogProperties());
$eventId = $posthog->captureException($e, $user->email);
return response()->json([
'error' => 'Operation failed',
'error_id' => $eventId,
'message' => "Error captured in PostHog. Reference ID: {$eventId}",
], 500);
}
}The /api/test-error endpoint demonstrates manual exception capture. Use ?capture=true to capture in PostHog, or ?capture=false to skip tracking.
Pages
| Route | Component | PostHog Events |
|---|---|---|
/ |
Login | user_logged_in |
/register |
Register | user_signed_up |
/dashboard |
Dashboard | dashboard_viewed, feature flag checks |
/burrito |
BurritoTracker | burrito_considered |
/profile |
Profile | profile_viewed |
/logout |
(route) | user_logged_out |
Project Structure
basics/laravel/
├── app/
│ ├── Http/
│ │ ├── Controllers/
│ │ │ └── Api/
│ │ │ ├── BurritoController.php # Burrito API endpoint
│ │ │ └── ErrorTestController.php # Error testing endpoint
│ │ └── Livewire/
│ │ ├── Auth/
│ │ │ ├── Login.php # Login component
│ │ │ └── Register.php # Registration component
│ │ ├── BurritoTracker.php # Burrito tracker component
│ │ ├── Dashboard.php # Dashboard with feature flags
│ │ └── Profile.php # User profile component
│ ├── Models/
│ │ └── User.php # User model with PostHog properties
│ └── Services/
│ └── PostHogService.php # PostHog wrapper service
├── database/
│ ├── migrations/ # Database migrations
│ └── seeders/
│ └── DatabaseSeeder.php # Seeds admin user
├── resources/
│ └── views/
│ ├── components/
│ │ └── layouts/
│ │ ├── app.blade.php # Authenticated layout
│ │ └── guest.blade.php # Guest layout
│ ├── errors/
│ │ ├── 404.blade.php # Not found page
│ │ └── 500.blade.php # Server error page
│ └── livewire/
│ ├── auth/
│ │ ├── login.blade.php # Login form
│ │ └── register.blade.php # Registration form
│ ├── burrito-tracker.blade.php # Burrito tracker UI
│ ├── dashboard.blade.php # Dashboard UI
│ └── profile.blade.php # Profile UI
├── routes/
│ ├── web.php # Web routes (auth, pages)
│ └── api.php # API routes
└── config/
└── posthog.php # PostHog configurationDevelopment Commands
# Start development server
php artisan serve
# Run migrations
php artisan migrate
# Seed database
php artisan migrate:fresh --seed
# Clear caches
php artisan optimize:clear.env.example
APP_NAME="PostHog Laravel Example"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost:8000
DB_CONNECTION=sqlite
# DB_DATABASE will use default database/database.sqlite
CACHE_DRIVER=file
CACHE_STORE=file
SESSION_DRIVER=file
SESSION_LIFETIME=120
POSTHOG_PROJECT_TOKEN=your_posthog_project_token_here
POSTHOG_HOST=https://us.i.posthog.com
POSTHOG_DISABLED=false
app/Http/Controllers/Api/BurritoController.php
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\PostHogService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class BurritoController extends Controller
{
public function consider(Request $request, PostHogService $posthog): JsonResponse
{
$user = Auth::user();
// Increment session counter
$burritoCount = session('burrito_count', 0) + 1;
session(['burrito_count' => $burritoCount]);
// PostHog: Track event
$posthog->identify($user->email, $user->getPostHogProperties());
$posthog->capture($user->email, 'burrito_considered', [
'total_considerations' => $burritoCount,
]);
return response()->json([
'success' => true,
'count' => $burritoCount,
]);
}
}
app/Http/Controllers/Api/ErrorTestController.php
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\PostHogService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class ErrorTestController extends Controller
{
public function test(Request $request, PostHogService $posthog): JsonResponse
{
$shouldCapture = $request->query('capture', 'true') === 'true';
$user = Auth::user();
try {
throw new \Exception('Test exception from critical operation');
} catch (\Throwable $e) {
if ($shouldCapture) {
// Capture in PostHog
$posthog->identify($user->email, $user->getPostHogProperties());
$eventId = $posthog->captureException($e, $user->email);
return response()->json([
'error' => 'Operation failed',
'error_id' => $eventId,
'message' => "Error captured in PostHog. Reference ID: {$eventId}",
], 500);
}
return response()->json([
'error' => $e->getMessage(),
], 500);
}
}
}
app/Http/Controllers/Controller.php
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}
app/Http/Livewire/Auth/Login.php
<?php
namespace App\Http\Livewire\Auth;
use App\Services\PostHogService;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class Login extends Component
{
public string $email = '';
public string $password = '';
public bool $remember = false;
protected $rules = [
'email' => 'required|email',
'password' => 'required',
];
public function login(PostHogService $posthog)
{
$this->validate();
if (Auth::attempt(['email' => $this->email, 'password' => $this->password], $this->remember)) {
$user = Auth::user();
// PostHog: Identify and track login
$posthog->identify($user->email, $user->getPostHogProperties());
$posthog->capture($user->email, 'user_logged_in', [
'login_method' => 'password',
]);
session()->regenerate();
return redirect()->intended(route('dashboard'));
}
$this->addError('email', 'Invalid credentials');
}
public function render()
{
return view('livewire.auth.login')
->layout('components.layouts.guest');
}
}
app/Http/Livewire/Auth/Register.php
<?php
namespace App\Http\Livewire\Auth;
use App\Models\User;
use App\Services\PostHogService;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class Register extends Component
{
public string $email = '';
public string $password = '';
public string $password_confirmation = '';
protected $rules = [
'email' => 'required|email|unique:users,email',
'password' => 'required|min:6|confirmed',
];
public function register(PostHogService $posthog)
{
$validated = $this->validate();
$user = User::create([
'email' => $validated['email'],
'password' => bcrypt($validated['password']),
'is_staff' => false,
]);
// PostHog: Identify new user and track signup
$posthog->identify($user->email, $user->getPostHogProperties());
$posthog->capture($user->email, 'user_signed_up', [
'signup_method' => 'form',
]);
Auth::login($user);
session()->flash('success', 'Account created successfully!');
return redirect()->route('dashboard');
}
public function render()
{
return view('livewire.auth.register')
->layout('components.layouts.guest');
}
}
app/Http/Livewire/BurritoTracker.php
<?php
namespace App\Http\Livewire;
use App\Services\PostHogService;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class BurritoTracker extends Component
{
public int $burritoCount = 0;
public function mount()
{
$this->burritoCount = session('burrito_count', 0);
}
public function considerBurrito(PostHogService $posthog)
{
$this->burritoCount++;
session(['burrito_count' => $this->burritoCount]);
// PostHog: Track burrito consideration
$user = Auth::user();
$posthog->identify($user->email, $user->getPostHogProperties());
$posthog->capture($user->email, 'burrito_considered', [
'total_considerations' => $this->burritoCount,
]);
$this->dispatch('burrito-considered');
}
public function render()
{
return view('livewire.burrito-tracker')
->layout('components.layouts.app');
}
}
app/Http/Livewire/Dashboard.php
<?php
namespace App\Http\Livewire;
use App\Services\PostHogService;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class Dashboard extends Component
{
public bool $showNewFeature = false;
public $featureConfig = null;
public ?string $errorMessage = null;
public ?string $successMessage = null;
public function mount(PostHogService $posthog)
{
$user = Auth::user();
// PostHog: Track dashboard view
$posthog->capture($user->email, 'dashboard_viewed', [
'is_staff' => $user->is_staff,
]);
// Check feature flag
$this->showNewFeature = $posthog->isFeatureEnabled(
'new-dashboard-feature',
$user->email,
$user->getPostHogProperties()
) ?? false;
// Get feature flag payload
$this->featureConfig = $posthog->getFeatureFlagPayload(
'new-dashboard-feature',
$user->email
);
}
public function testErrorWithCapture(PostHogService $posthog)
{
$user = Auth::user();
try {
// Simulate an error
throw new \Exception('This is a test error for PostHog tracking');
} catch (\Exception $e) {
// Capture the exception in PostHog
$errorId = $posthog->captureException($e, $user->email);
$this->successMessage = "Error captured in PostHog! Error ID: {$errorId}";
$this->errorMessage = null;
}
}
public function testErrorWithoutCapture()
{
try {
// Simulate an error without capturing
throw new \Exception('This error was NOT sent to PostHog');
} catch (\Exception $e) {
$this->errorMessage = "Error occurred but NOT captured in PostHog: " . $e->getMessage();
$this->successMessage = null;
}
}
public function render()
{
return view('livewire.dashboard')
->layout('components.layouts.app');
}
}
app/Http/Livewire/Profile.php
<?php
namespace App\Http\Livewire;
use App\Services\PostHogService;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class Profile extends Component
{
public ?string $errorMessage = null;
public ?string $successMessage = null;
public function mount(PostHogService $posthog)
{
$user = Auth::user();
// PostHog: Track profile view
$posthog->capture($user->email, 'profile_viewed');
}
public function testErrorWithCapture(PostHogService $posthog)
{
$user = Auth::user();
try {
// Simulate an error
throw new \Exception('This is a test error for PostHog tracking');
} catch (\Exception $e) {
// Capture the exception in PostHog
$errorId = $posthog->captureException($e, $user->email);
$this->successMessage = "Error captured in PostHog! Error ID: {$errorId}";
$this->errorMessage = null;
}
}
public function testErrorWithoutCapture()
{
try {
// Simulate an error without capturing
throw new \Exception('This error was NOT sent to PostHog');
} catch (\Exception $e) {
$this->errorMessage = "Error occurred but NOT captured in PostHog: " . $e->getMessage();
$this->successMessage = null;
}
}
public function render()
{
return view('livewire.profile')
->layout('components.layouts.app');
}
}
app/Models/User.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
use HasFactory, Notifiable;
protected $fillable = [
'email',
'password',
'is_staff',
];
protected $hidden = [
'password',
'remember_token',
];
protected function casts(): array
{
return [
'password' => 'hashed',
'is_staff' => 'boolean',
];
}
/**
* Get PostHog person properties for this user.
*/
public function getPostHogProperties(): array
{
return [
'email' => $this->email,
'is_staff' => $this->is_staff,
'date_joined' => $this->created_at->toISOString(),
];
}
}
app/Services/PostHogService.php
<?php
namespace App\Services;
use PostHog\PostHog;
use Illuminate\Support\Facades\Auth;
class PostHogService
{
protected static $initialized = false;
public function __construct()
{
if (config('posthog.disabled')) {
return;
}
// Initialize PostHog once
if (!self::$initialized) {
PostHog::init(
config('posthog.api_key'),
[
'host' => config('posthog.host'),
'debug' => config('posthog.debug'),
]
);
self::$initialized = true;
}
}
public function identify(string $distinctId, array $properties = []): void
{
if (config('posthog.disabled')) {
return;
}
PostHog::identify([
'distinctId' => $distinctId,
'properties' => $properties,
]);
}
public function capture(string $distinctId, string $event, array $properties = []): void
{
if (config('posthog.disabled')) {
return;
}
PostHog::capture([
'distinctId' => $distinctId,
'event' => $event,
'properties' => $properties,
]);
}
public function captureException(\Throwable $exception, ?string $distinctId = null): ?string
{
if (config('posthog.disabled')) {
return null;
}
$distinctId = $distinctId ?? Auth::user()?->email ?? 'anonymous';
$eventId = uniqid('error_', true);
PostHog::captureException($exception, $distinctId, [
'error_id' => $eventId,
]);
return $eventId;
}
public function isFeatureEnabled(string $key, string $distinctId, array $properties = []): ?bool
{
if (config('posthog.disabled')) {
return false;
}
return PostHog::isFeatureEnabled($key, $distinctId, $properties);
}
public function getFeatureFlagPayload(string $key, string $distinctId)
{
if (config('posthog.disabled')) {
return null;
}
return PostHog::getFeatureFlagPayload($key, $distinctId);
}
}
artisan
#!/usr/bin/env php
<?php
define('LARAVEL_START', microtime(true));
/*
|--------------------------------------------------------------------------
| Register The Auto Loader
|--------------------------------------------------------------------------
*/
require __DIR__.'/vendor/autoload.php';
$app = require_once __DIR__.'/bootstrap/app.php';
/*
|--------------------------------------------------------------------------
| Run The Artisan Application
|--------------------------------------------------------------------------
*/
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
$status = $kernel->handle(
$input = new Symfony\Component\Console\Input\ArgvInput,
new Symfony\Component\Console\Output\ConsoleOutput
);
$kernel->terminate($input, $status);
exit($status);
bootstrap/app.php
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
//
})
->withExceptions(function (Exceptions $exceptions) {
//
})->create();
config/app.php
<?php
return [
'name' => env('APP_NAME', 'PostHog Laravel Example'),
'env' => env('APP_ENV', 'production'),
'debug' => (bool) env('APP_DEBUG', false),
'url' => env('APP_URL', 'http://localhost'),
'timezone' => 'UTC',
'locale' => 'en',
'fallback_locale' => 'en',
'key' => env('APP_KEY'),
'cipher' => 'AES-256-CBC',
'providers' => [
// Laravel Framework Service Providers
Illuminate\Auth\AuthServiceProvider::class,
Illuminate\Broadcasting\BroadcastServiceProvider::class,
Illuminate\Bus\BusServiceProvider::class,
Illuminate\Cache\CacheServiceProvider::class,
Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class,
Illuminate\Cookie\CookieServiceProvider::class,
Illuminate\Database\DatabaseServiceProvider::class,
Illuminate\Encryption\EncryptionServiceProvider::class,
Illuminate\Filesystem\FilesystemServiceProvider::class,
Illuminate\Foundation\Providers\FoundationServiceProvider::class,
Illuminate\Hashing\HashServiceProvider::class,
Illuminate\Mail\MailServiceProvider::class,
Illuminate\Notifications\NotificationServiceProvider::class,
Illuminate\Pagination\PaginationServiceProvider::class,
Illuminate\Pipeline\PipelineServiceProvider::class,
Illuminate\Queue\QueueServiceProvider::class,
Illuminate\Redis\RedisServiceProvider::class,
Illuminate\Auth\Passwords\PasswordResetServiceProvider::class,
Illuminate\Session\SessionServiceProvider::class,
Illuminate\Translation\TranslationServiceProvider::class,
Illuminate\Validation\ValidationServiceProvider::class,
Illuminate\View\ViewServiceProvider::class,
],
'aliases' => [
'App' => Illuminate\Support\Facades\App::class,
'Auth' => Illuminate\Support\Facades\Auth::class,
'Blade' => Illuminate\Support\Facades\Blade::class,
'Cache' => Illuminate\Support\Facades\Cache::class,
'Config' => Illuminate\Support\Facades\Config::class,
'DB' => Illuminate\Support\Facades\DB::class,
'Hash' => Illuminate\Support\Facades\Hash::class,
'Request' => Illuminate\Support\Facades\Request::class,
'Route' => Illuminate\Support\Facades\Route::class,
'Schema' => Illuminate\Support\Facades\Schema::class,
'Session' => Illuminate\Support\Facades\Session::class,
'View' => Illuminate\Support\Facades\View::class,
],
];
config/auth.php
<?php
return [
'defaults' => [
'guard' => 'web',
'passwords' => 'users',
],
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => App\Models\User::class,
],
],
'passwords' => [
'users' => [
'provider' => 'users',
'table' => 'password_reset_tokens',
'expire' => 60,
'throttle' => 60,
],
],
'password_timeout' => 10800,
];
config/database.php
<?php
return [
'default' => env('DB_CONNECTION', 'sqlite'),
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DATABASE_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DATABASE_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'strict' => true,
'engine' => null,
],
],
'migrations' => 'migrations',
];
config/posthog.php
<?php
return [
'api_key' => env('POSTHOG_PROJECT_TOKEN', ''),
'host' => env('POSTHOG_HOST', 'https://us.i.posthog.com'),
'disabled' => env('POSTHOG_DISABLED', false),
'debug' => env('APP_DEBUG', false),
];
config/session.php
<?php
return [
'driver' => env('SESSION_DRIVER', 'file'),
'lifetime' => env('SESSION_LIFETIME', 120),
'expire_on_close' => false,
'encrypt' => false,
'files' => storage_path('framework/sessions'),
'connection' => null,
'table' => 'sessions',
'store' => null,
'lottery' => [2, 100],
'cookie' => env('SESSION_COOKIE', 'laravel_session'),
'path' => '/',
'domain' => env('SESSION_DOMAIN'),
'secure' => env('SESSION_SECURE_COOKIE'),
'http_only' => true,
'same_site' => 'lax',
];
database/migrations/2024_01_01_000000_create_users_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('email')->unique();
$table->string('password');
$table->boolean('is_staff')->default(false);
$table->rememberToken();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('users');
}
};
database/seeders/DatabaseSeeder.php
<?php
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
User::firstOrCreate(
['email' => 'admin@example.com'],
[
'password' => bcrypt('admin'),
'is_staff' => true,
]
);
}
}
IMPLEMENTATION.md
Laravel PostHog Example - Implementation Summary
This document summarizes the implementation of the Laravel PostHog example application, ported from the Flask version.
✅ Completed Implementation
Core Application Structure
Models & Database
- ✅ User model with PostHog properties helper method
- ✅ User migration with
is_stafffield - ✅ Database seeder for default admin user
- ✅ SQLite database configuration
PostHog Integration
- ✅ PostHog configuration file (
config/posthog.php) - ✅ PostHogService class with all core methods:
identify()- User identificationcapture()- Event trackingcaptureException()- Error trackingisFeatureEnabled()- Feature flag checkinggetFeatureFlagPayload()- Feature flag payload retrieval
Authentication (Livewire Components)
- ✅ Login component with PostHog tracking
- ✅ Register component with PostHog tracking
- ✅ Logout route with PostHog tracking
Core Features (Livewire Components)
- ✅ Dashboard - Feature flag demonstration
- ✅ Burrito Tracker - Custom event tracking
- ✅ Profile - Error tracking demonstration
API Controllers
- ✅ BurritoController - API endpoint for burrito tracking
- ✅ ErrorTestController - Manual error capture demonstration
Views & Layouts
- ✅ App layout (authenticated users)
- ✅ Guest layout (unauthenticated users)
- ✅ All Livewire view files with inline styling
- ✅ Error pages (404, 500)
Routes
- ✅ Web routes (authentication, dashboard, burrito, profile, logout)
- ✅ API routes (burrito tracking, error testing)
Configuration
- ✅ Environment example file
- ✅ Composer.json with dependencies
- ✅ Laravel config files (app, auth, database, session)
- ✅ .gitignore
Documentation
- ✅ Comprehensive README
- ✅ Implementation plan (php-plan.md)
📋 Features Implemented
1. User Authentication
- Login with PostHog identification
- Registration with PostHog tracking
- Logout with event capture
- Session management
2. PostHog Analytics
- User identification on login/signup
- Person properties (email, is_staff, date_joined)
- Custom event tracking (burrito considerations)
- Dashboard views tracking
3. Feature Flags
- Feature flag checking (
new-dashboard-feature) - Feature flag payload retrieval
- Conditional UI rendering based on flags
4. Error Tracking
- Manual exception capture
- Error ID generation
- Test endpoint with optional capture (
?capture=true/false)
5. UI/UX
- Responsive layouts
- Flash messages for user feedback
- Livewire reactivity for burrito counter
- Loading states on buttons
🎯 PostHog Integration Points
| Feature | Location | PostHog Method |
|---|---|---|
| User Login | Login.php:23-27 |
identify() + capture() |
| User Signup | Register.php:29-32 |
identify() + capture() |
| User Logout | web.php:25 |
capture() |
| Dashboard View | Dashboard.php:18 |
capture() |
| Feature Flag Check | Dashboard.php:21-25 |
isFeatureEnabled() |
| Feature Flag Payload | Dashboard.php:28-31 |
getFeatureFlagPayload() |
| Burrito Tracking | BurritoTracker.php:22-24 |
identify() + capture() |
| Profile View | Profile.php:14 |
capture() |
| Error Capture | ErrorTestController.php:22-24 |
identify() + captureException() |
📁 File Structure
basics/laravel/
├── app/
│ ├── Http/
│ │ ├── Controllers/
│ │ │ ├── Controller.php
│ │ │ └── Api/
│ │ │ ├── BurritoController.php
│ │ │ └── ErrorTestController.php
│ │ └── Livewire/
│ │ ├── Auth/
│ │ │ ├── Login.php
│ │ │ └── Register.php
│ │ ├── Dashboard.php
│ │ ├── BurritoTracker.php
│ │ └── Profile.php
│ ├── Models/
│ │ └── User.php
│ └── Services/
│ └── PostHogService.php
├── config/
│ ├── app.php
│ ├── auth.php
│ ├── database.php
│ ├── posthog.php
│ └── session.php
├── database/
│ ├── migrations/
│ │ └── 2024_01_01_000000_create_users_table.php
│ └── seeders/
│ └── DatabaseSeeder.php
├── resources/
│ └── views/
│ ├── components/
│ │ └── layouts/
│ │ ├── app.blade.php
│ │ └── guest.blade.php
│ ├── livewire/
│ │ ├── auth/
│ │ │ ├── login.blade.php
│ │ │ └── register.blade.php
│ │ ├── dashboard.blade.php
│ │ ├── burrito-tracker.blade.php
│ │ └── profile.blade.php
│ └── errors/
│ ├── 404.blade.php
│ └── 500.blade.php
├── routes/
│ ├── api.php
│ └── web.php
├── .env.example
├── .gitignore
├── composer.json
├── IMPLEMENTATION.md
└── README.md🔄 Flask to Laravel Mapping
| Flask Component | Laravel Equivalent |
|---|---|
| Flask-Login | Laravel Auth + Livewire |
| Flask-SQLAlchemy | Eloquent ORM |
| Jinja2 Templates | Blade Templates + Livewire |
| Blueprint routes | Route definitions |
| @app.route decorators | Route::get/post |
| session | session() helper |
| flash() | session()->flash() |
| @login_required | Route::middleware('auth') |
| request.form | Livewire properties |
| render_template() | view() or Livewire render() |
| jsonify() | response()->json() |
| SQLAlchemy models | Eloquent models |
🚀 Next Steps for Production
To make this a production-ready application:
- Install via Composer: Run full Laravel installation
- Environment: Generate APP_KEY with
php artisan key:generate - Database: Run migrations with
php artisan migrate --seed - Assets: Set up Vite for asset compilation
- Middleware: Add CSRF protection middleware
- Validation: Add form request classes
- Testing: Implement PHPUnit tests
- Caching: Configure Redis/Memcached
- Queue: Set up queue workers for PostHog events
- Deployment: Configure for production server
📝 Notes
- This implementation uses inline CSS (matching Flask example) instead of Tailwind compilation
- Livewire provides reactivity without separate JavaScript files
- PostHog service is dependency-injected into components/controllers
- Manual error capture pattern matches Flask implementation
- Session-based burrito counter (same as Flask)
- Default admin account: admin@example.com (mailto:admin@example.com) / admin
🎓 Learning Resources
Implementation Date: January 2026 Laravel Version: 11.x Livewire Version: 3.x PostHog PHP SDK: 3.x
public/index.php
<?php
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
// Suppress PHP 8.5 deprecation warnings for development
error_reporting(E_ALL & ~E_DEPRECATED);
// Determine if the application is in maintenance mode...
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
require $maintenance;
}
// Register the Composer autoloader...
require __DIR__.'/../vendor/autoload.php';
// Bootstrap Laravel and handle the request...
(require_once __DIR__.'/../bootstrap/app.php')
->handleRequest(Request::capture());
resources/views/components/layouts/app.blade.php
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? 'PostHog Laravel Example' }}</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
line-height: 1.6;
background-color: #f5f5f5;
color: #333;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
nav {
background: #1d4ed8;
padding: 15px 20px;
margin-bottom: 30px;
}
nav a {
color: white;
text-decoration: none;
margin-right: 20px;
}
nav a:hover {
text-decoration: underline;
}
.nav-right {
float: right;
}
.card {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1, h2, h3 {
margin-bottom: 15px;
color: #1d4ed8;
}
button, .btn {
background: #1d4ed8;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
display: inline-block;
text-decoration: none;
}
button:hover, .btn:hover {
background: #1e40af;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button.danger, .btn-danger {
background: #dc2626;
}
button.danger:hover, .btn-danger:hover {
background: #b91c1c;
}
input {
width: 100%;
padding: 10px;
margin-bottom: 15px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 14px;
}
.message-error {
background: #fee2e2;
color: #dc2626;
padding: 10px 15px;
border-radius: 5px;
margin-bottom: 15px;
}
.message-success {
background: #d1fae5;
color: #059669;
padding: 10px 15px;
border-radius: 5px;
margin-bottom: 15px;
}
.feature-flag {
background: #fef3c7;
border: 2px dashed #f59e0b;
padding: 15px;
border-radius: 8px;
margin: 20px 0;
}
code {
background: #f3f4f6;
padding: 2px 6px;
border-radius: 3px;
font-family: monospace;
}
pre {
background: #1e293b;
color: #e2e8f0;
padding: 16px;
border-radius: 8px;
overflow-x: auto;
font-size: 13px;
}
.count {
font-size: 48px;
font-weight: bold;
color: #1d4ed8;
text-align: center;
padding: 20px;
}
table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #eee;
}
th {
background: #f8fafc;
font-weight: 600;
}
label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
.text-sm {
font-size: 14px;
}
.text-gray {
color: #666;
}
.mb-4 {
margin-bottom: 16px;
}
</style>
@livewireStyles
</head>
<body>
@auth
<nav>
<a href="{{ route('dashboard') }}">Dashboard</a>
<a href="{{ route('burrito') }}">Burrito</a>
<a href="{{ route('profile') }}">Profile</a>
<span class="nav-right">
<span style="margin-right: 15px;">{{ auth()->user()->email }}</span>
<form method="POST" action="{{ route('logout') }}" style="display: inline;">
@csrf
<button type="submit" style="background: none; padding: 0; color: white; text-decoration: underline;">Logout</button>
</form>
</span>
</nav>
@endauth
<div class="container">
@if (session('success'))
<div class="message-success">
{{ session('success') }}
</div>
@endif
@if (session('error'))
<div class="message-error">
{{ session('error') }}
</div>
@endif
{{ $slot }}
</div>
@livewireScripts
</body>
</html>
resources/views/components/layouts/guest.blade.php
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? 'PostHog Laravel Example' }}</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
line-height: 1.6;
background-color: #f5f5f5;
color: #333;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.card {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1, h2, h3 {
margin-bottom: 15px;
color: #1d4ed8;
}
button, .btn {
background: #1d4ed8;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
display: inline-block;
text-decoration: none;
width: 100%;
}
button:hover, .btn:hover {
background: #1e40af;
}
input {
width: 100%;
padding: 10px;
margin-bottom: 15px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 14px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
.error {
color: #dc2626;
font-size: 13px;
margin-top: -10px;
margin-bottom: 10px;
}
.text-sm {
font-size: 14px;
}
.text-gray {
color: #666;
}
a {
color: #1d4ed8;
}
code {
background: #f3f4f6;
padding: 2px 6px;
border-radius: 3px;
font-family: monospace;
font-size: 13px;
}
pre {
background: #1e293b;
color: #e2e8f0;
padding: 16px;
border-radius: 8px;
overflow-x: auto;
font-size: 13px;
margin-top: 10px;
}
ul {
margin-left: 20px;
}
</style>
@livewireStyles
</head>
<body>
<div class="container">
{{ $slot }}
</div>
@livewireScripts
</body>
</html>
resources/views/errors/404.blade.php
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Page Not Found - PostHog Laravel Example</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
line-height: 1.6;
background-color: #f5f5f5;
color: #333;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.card {
background: white;
border-radius: 8px;
padding: 40px;
margin-top: 50px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
text-align: center;
}
h1 {
font-size: 72px;
color: #1d4ed8;
margin-bottom: 15px;
}
h2 {
font-size: 24px;
color: #333;
margin-bottom: 15px;
}
p {
color: #666;
margin-bottom: 25px;
}
.btn {
background: #1d4ed8;
color: white;
border: none;
padding: 12px 24px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
text-decoration: none;
display: inline-block;
}
.btn:hover {
background: #1e40af;
}
</style>
</head>
<body>
<div class="container">
<div class="card">
<h1>404</h1>
<h2>Page Not Found</h2>
<p>The page you're looking for doesn't exist.</p>
<a href="/" class="btn">Go Home</a>
</div>
</div>
</body>
</html>
resources/views/errors/500.blade.php
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Server Error - PostHog Laravel Example</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
line-height: 1.6;
background-color: #f5f5f5;
color: #333;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.card {
background: white;
border-radius: 8px;
padding: 40px;
margin-top: 50px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
text-align: center;
}
h1 {
font-size: 72px;
color: #dc2626;
margin-bottom: 15px;
}
h2 {
font-size: 24px;
color: #333;
margin-bottom: 15px;
}
p {
color: #666;
margin-bottom: 25px;
}
.btn {
background: #1d4ed8;
color: white;
border: none;
padding: 12px 24px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
text-decoration: none;
display: inline-block;
}
.btn:hover {
background: #1e40af;
}
</style>
</head>
<body>
<div class="container">
<div class="card">
<h1>500</h1>
<h2>Internal Server Error</h2>
<p>Something went wrong on our end.</p>
<a href="/" class="btn">Go Home</a>
</div>
</div>
</body>
</html>
resources/views/livewire/auth/login.blade.php
<div>
<div class="card">
<h1>Welcome to PostHog Laravel Example</h1>
<p class="text-gray mb-4">This example demonstrates how to integrate PostHog with a Laravel application.</p>
<form wire:submit="login">
<label for="email">Email</label>
<input
type="email"
id="email"
wire:model="email"
required
>
@error('email') <div class="error">{{ $message }}</div> @enderror
<label for="password">Password</label>
<input
type="password"
id="password"
wire:model="password"
required
>
@error('password') <div class="error">{{ $message }}</div> @enderror
<div style="margin-bottom: 15px;">
<label style="display: inline; font-weight: normal;">
<input type="checkbox" wire:model="remember" style="width: auto; margin-right: 5px;">
Remember me
</label>
</div>
<button type="submit">Login</button>
</form>
<p style="margin-top: 16px;" class="text-sm text-gray">
Don't have an account? <a href="{{ route('register') }}">Sign up here</a>
</p>
<p class="text-sm text-gray">
<strong>Tip:</strong> Default credentials are admin@example.com/admin
</p>
</div>
<div class="card">
<h2>Features Demonstrated</h2>
<ul class="text-gray">
<li>User registration and identification</li>
<li>Event tracking</li>
<li>Feature flags</li>
<li>Error tracking</li>
</ul>
</div>
</div>
resources/views/livewire/auth/register.blade.php
<div>
<div class="card">
<h1>Create an Account</h1>
<p class="text-gray mb-4">Sign up to explore the PostHog Laravel integration example.</p>
<form wire:submit="register">
<label for="email">Email *</label>
<input
type="email"
id="email"
wire:model="email"
required
>
@error('email') <div class="error">{{ $message }}</div> @enderror
<label for="password">Password *</label>
<input
type="password"
id="password"
wire:model="password"
required
>
@error('password') <div class="error">{{ $message }}</div> @enderror
<label for="password_confirmation">Confirm Password *</label>
<input
type="password"
id="password_confirmation"
wire:model="password_confirmation"
required
>
<button type="submit">Sign Up</button>
</form>
<p style="margin-top: 16px;" class="text-sm text-gray">
Already have an account? <a href="{{ route('login') }}">Login here</a>
</p>
</div>
<div class="card">
<h2>PostHog Integration</h2>
<p class="text-gray">When you sign up, the following PostHog events are captured:</p>
<ul class="text-gray" style="margin-top: 10px;">
<li><code>identify()</code> - Associates your email with the user</li>
<li><code>capture()</code> - Sets person properties (email, etc.)</li>
<li><code>user_signed_up</code> event - Tracks the signup action</li>
</ul>
<h3 style="margin-top: 20px;">Code Example</h3>
<pre>// After creating the user
$posthog->identify($user->email, $user->getPostHogProperties());
$posthog->capture($user->email, 'user_signed_up', [
'signup_method' => 'form'
]);</pre>
</div>
</div>
resources/views/livewire/burrito-tracker.blade.php
<div>
<div class="card">
<h1>Burrito Consideration Tracker</h1>
<p class="text-gray mb-4">This page demonstrates custom event tracking with PostHog.</p>
<div class="count">{{ $burritoCount }}</div>
<p style="text-align: center; color: #666; margin-bottom: 20px;">Times you've considered a burrito</p>
<div style="text-align: center;">
<button
wire:click="considerBurrito"
wire:loading.attr="disabled"
>
<span wire:loading.remove>Consider a Burrito</span>
<span wire:loading>Considering...</span>
</button>
</div>
</div>
<div class="card">
<h3>Code Example</h3>
<pre>// Livewire component method
public function considerBurrito(PostHogService $posthog)
{
$this->burritoCount++;
session(['burrito_count' => $this->burritoCount]);
$user = Auth::user();
$posthog->identify($user->email, $user->getPostHogProperties());
$posthog->capture($user->email, 'burrito_considered', [
'total_considerations' => $this->burritoCount,
]);
}</pre>
</div>
</div>
resources/views/livewire/dashboard.blade.php
<div>
<div class="card">
<h1>Dashboard</h1>
<p class="text-gray">Welcome back, {{ auth()->user()->email }}!</p>
</div>
<div class="card">
<h2>Error Tracking Demo</h2>
<p class="text-gray">Test manual exception capture in PostHog. These buttons trigger errors in the context of your logged-in user.</p>
@if($successMessage)
<div style="background: #d4edda; border: 1px solid #c3e6cb; color: #155724; padding: 12px; border-radius: 4px; margin: 15px 0;">
{{ $successMessage }}
</div>
@endif
@if($errorMessage)
<div style="background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; padding: 12px; border-radius: 4px; margin: 15px 0;">
{{ $errorMessage }}
</div>
@endif
<div style="display: flex; gap: 10px; margin-top: 15px;">
<button wire:click="testErrorWithCapture" class="btn" style="background: #dc3545; color: white;">
Capture Error in PostHog
</button>
<button wire:click="testErrorWithoutCapture" class="btn" style="background: #c82333; color: white;">
Skip Capture in PostHog
</button>
</div>
<h3 style="margin-top: 20px;">Code Example</h3>
<pre>try {
// Critical operation that might fail
processPayment();
} catch (\Throwable $e) {
// Manually capture this specific exception
$errorId = $posthog->captureException($e, $user->email);
return response()->json([
'error' => 'Operation failed',
'error_id' => $errorId
], 500);
}</pre>
<p class="text-gray" style="margin-top: 10px;">This demonstrates manual exception capture where you have control over whether errors are sent to PostHog.</p>
</div>
<div class="card">
<h2>Feature Flags</h2>
@if($showNewFeature)
<div class="feature-flag">
<strong>New Feature Enabled!</strong>
<p style="margin-top: 10px;">You're seeing this because the <code>new-dashboard-feature</code> flag is enabled for you.</p>
@if($featureConfig)
<p style="margin-top: 15px;"><strong>Feature Configuration:</strong></p>
<pre>{{ json_encode($featureConfig, JSON_PRETTY_PRINT) }}</pre>
@endif
</div>
@else
<p class="text-gray">The <code>new-dashboard-feature</code> flag is not enabled for your account.</p>
@endif
<h3 style="margin-top: 20px;">Code Example</h3>
<pre>// Check if feature flag is enabled
$showNewFeature = $posthog->isFeatureEnabled(
'new-dashboard-feature',
$user->email,
$user->getPostHogProperties()
);
// Get feature flag payload
$featureConfig = $posthog->getFeatureFlagPayload(
'new-dashboard-feature',
$user->email
);</pre>
</div>
</div>
resources/views/livewire/profile.blade.php
<div>
<div class="card">
<h1>Your Profile</h1>
<p class="text-gray mb-4">This page demonstrates error tracking with PostHog.</p>
<table>
<tr>
<th>Email</th>
<td>{{ auth()->user()->email }}</td>
</tr>
<tr>
<th>Date Joined</th>
<td>{{ auth()->user()->created_at->format('Y-m-d H:i') }}</td>
</tr>
<tr>
<th>Staff Status</th>
<td>{{ auth()->user()->is_staff ? 'Yes' : 'No' }}</td>
</tr>
</table>
</div>
<div class="card">
<h2>Error Tracking Demo</h2>
<p class="text-gray">Test manual exception capture in PostHog. These buttons trigger errors in the context of your logged-in user.</p>
@if($successMessage)
<div style="background: #d4edda; border: 1px solid #c3e6cb; color: #155724; padding: 12px; border-radius: 4px; margin: 15px 0;">
{{ $successMessage }}
</div>
@endif
@if($errorMessage)
<div style="background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; padding: 12px; border-radius: 4px; margin: 15px 0;">
{{ $errorMessage }}
</div>
@endif
<div style="display: flex; gap: 10px; margin-top: 15px;">
<button wire:click="testErrorWithCapture" class="btn" style="background: #dc3545; color: white;">
Capture Error in PostHog
</button>
<button wire:click="testErrorWithoutCapture" class="btn" style="background: #c82333; color: white;">
Skip Capture in PostHog
</button>
</div>
<p class="text-gray" style="margin-top: 15px;">
This demonstrates manual exception capture where you have control over whether errors are sent to PostHog.
</p>
</div>
<div class="card">
<h3>Code Example</h3>
<pre>try {
throw new \Exception('Test exception from critical operation');
} catch (\Throwable $e) {
// Capture exception with user context
$posthog->identify($user->email, $user->getPostHogProperties());
$eventId = $posthog->captureException($e, $user->email);
return response()->json([
'error' => 'Operation failed',
'error_id' => $eventId,
'message' => "Error captured in PostHog. Reference ID: {$eventId}"
], 500);
}</pre>
</div>
</div>
routes/api.php
<?php
use App\Http\Controllers\Api\BurritoController;
use App\Http\Controllers\Api\ErrorTestController;
use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->group(function () {
Route::post('/burrito/consider', [BurritoController::class, 'consider']);
Route::post('/test-error', [ErrorTestController::class, 'test']);
});
routes/web.php
<?php
use App\Http\Livewire\Auth\Login;
use App\Http\Livewire\Auth\Register;
use App\Http\Livewire\BurritoTracker;
use App\Http\Livewire\Dashboard;
use App\Http\Livewire\Profile;
use App\Services\PostHogService;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
// Guest routes
Route::middleware('guest')->group(function () {
Route::get('/', Login::class)->name('login');
Route::get('/register', Register::class)->name('register');
});
// Authenticated routes
Route::middleware('auth')->group(function () {
Route::get('/dashboard', Dashboard::class)->name('dashboard');
Route::get('/burrito', BurritoTracker::class)->name('burrito');
Route::get('/profile', Profile::class)->name('profile');
Route::post('/logout', function (PostHogService $posthog) {
$user = Auth::user();
// PostHog: Track logout
$posthog->capture($user->email, 'user_logged_out');
Auth::logout();
request()->session()->invalidate();
request()->session()->regenerateToken();
return redirect('/');
})->name('logout');
});
references/EXAMPLE-next-app-router.md
PostHog next-app-router Example Project
Repository: https://github.com/PostHog/context-mill Path: example-apps/next-app-router
README.md
PostHog Next.js app router example
This is a Next.js App Router example demonstrating PostHog integration with product analytics, session replay, feature flags, and error tracking.
Features
- Product analytics: Track user events and behaviors
- Session replay: Record and replay user sessions
- Error tracking: Capture and track errors
- User authentication: Demo login system with PostHog user identification
- Server-side & Client-side tracking: Examples of both tracking methods
- Reverse proxy: PostHog ingestion through Next.js rewrites
Getting started
1. Install dependencies
npm install
# or
pnpm install2. Configure environment variables
Create a .env.local file in the root directory:
NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.comGet your PostHog project token from your PostHog project settings.
3. Run the development server
npm run dev
# or
pnpm devOpen http://localhost:3000 with your browser to see the app.
Project structure
src/
├── app/
│ ├── api/
│ │ └── auth/
│ │ └── login/
│ │ └── route.ts # Login API with server-side tracking
│ ├── burrito/
│ │ └── page.tsx # Demo feature page with event tracking
│ ├── profile/
│ │ └── page.tsx # User profile with error tracking demo
│ ├── layout.tsx # Root layout with providers
│ ├── page.tsx # Home/Login page
│ └── globals.css # Global styles
├── components/
│ └── Header.tsx # Navigation header with auth state
├── contexts/
│ └── AuthContext.tsx # Authentication context with PostHog integration
└── lib/
└── posthog-server.ts # Server-side PostHog client
instrumentation-client.ts # Client-side PostHog initializationKey integration points
Client-side initialization (instrumentation-client.ts)
import posthog from "posthog-js"
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!, {
api_host: "/ingest",
ui_host: "https://us.posthog.com",
defaults: '2026-01-30',
capture_exceptions: true,
debug: process.env.NODE_ENV === "development",
});User identification (AuthContext.tsx)
posthog.identify(username, {
username: username,
});Event tracking (burrito/page.tsx)
posthog.capture('burrito_considered', {
total_considerations: count,
username: username,
});Error tracking (profile/page.tsx)
posthog.captureException(error);Server-side tracking (app/api/auth/login/route.ts)
const posthog = getPostHogClient();
posthog.capture({
distinctId: username,
event: 'server_login',
properties: { ... }
});App router differences from pages router
This example uses Next.js App Router instead of Pages Router. Key differences:
- File-based routing: Pages in
src/app/instead ofsrc/pages/ - layout.tsx: Root layout component wraps all pages
- API Routes: Located in
src/app/api/withroute.tsfiles - 'use client': Client components need explicit directive
- useRouter: From
next/navigationinstead ofnext/router - Metadata: Exported from layout/page instead of Head component
- Server Components: Components are server-side by default
Learn more
Deploy on Vercel
The easiest way to deploy your Next.js app is to use the Vercel Platform.
Check out the Next.js deployment documentation for more details.
.env.example
# PostHog Configuration
# Get your PostHog project token from: https://app.posthog.com/project/settings
NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token_here
# NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.cominstrumentation-client.ts
import posthog from "posthog-js"
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!, {
api_host: "/ingest",
ui_host: "https://us.posthog.com",
// Include the defaults option as required by PostHog
defaults: '2026-01-30',
// Enables capturing unhandled exceptions via Error Tracking
capture_exceptions: true,
// Turn on debug in development mode
debug: process.env.NODE_ENV === "development",
});
//IMPORTANT: Never combine this approach with other client-side PostHog initialization approaches, especially components like a PostHogProvider. instrumentation-client.ts is the correct solution for initializating client-side PostHog in Next.js 15.3+ apps.next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
async rewrites() {
return [
{
source: "/ingest/static/:path*",
destination: "https://us-assets.i.posthog.com/static/:path*",
},
{
source: "/ingest/array/:path*",
destination: "https://us-assets.i.posthog.com/array/:path*",
},
{
source: "/ingest/:path*",
destination: "https://us.i.posthog.com/:path*",
},
];
},
// This is required to support PostHog trailing slash API requests
skipTrailingSlashRedirect: true,
};
export default nextConfig;
src/app/api/auth/login/route.ts
import { NextResponse } from 'next/server';
import { getPostHogClient } from '@/lib/posthog-server';
const users = new Map<string, { username: string; burritoConsiderations: number }>();
export async function POST(request: Request) {
const { username, password } = await request.json();
if (!username || !password) {
return NextResponse.json({ error: 'Username and password required' }, { status: 400 });
}
let user = users.get(username);
const isNewUser = !user;
if (!user) {
user = { username, burritoConsiderations: 0 };
users.set(username, user);
}
// Capture server-side login event
const posthog = getPostHogClient();
posthog.capture({
distinctId: username,
event: 'server_login',
properties: {
isNewUser: isNewUser,
source: 'api'
}
});
// Identify user on server side
posthog.identify({
distinctId: username,
properties: {
username: username,
createdAt: isNewUser ? new Date().toISOString() : undefined
}
});
// This handler is short-lived; flush so the enqueued events send before it returns
await posthog.flush();
return NextResponse.json({ success: true, user });
}src/app/burrito/page.tsx
'use client';
import { useState } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
import posthog from 'posthog-js';
export default function BurritoPage() {
const { user, incrementBurritoConsiderations } = useAuth();
const router = useRouter();
const [hasConsidered, setHasConsidered] = useState(false);
// Redirect to home if not logged in
if (!user) {
router.push('/');
return null;
}
const handleConsideration = () => {
incrementBurritoConsiderations();
setHasConsidered(true);
setTimeout(() => setHasConsidered(false), 2000);
// Capture burrito consideration event
posthog.capture('burrito_considered', {
total_considerations: user.burritoConsiderations + 1,
username: user.username,
});
};
return (
<div className="container">
<h1>Burrito consideration zone</h1>
<p>Take a moment to truly consider the potential of burritos.</p>
<div style={{ textAlign: 'center' }}>
<button
onClick={handleConsideration}
className="btn-burrito"
>
I have considered the burrito potential
</button>
{hasConsidered && (
<p className="success">
Thank you for your consideration! Count: {user.burritoConsiderations}
</p>
)}
</div>
<div className="stats">
<h3>Consideration stats</h3>
<p>Total considerations: {user.burritoConsiderations}</p>
</div>
</div>
);
}src/app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";
import { AuthProvider } from "@/contexts/AuthContext";
import Header from "@/components/Header";
export const metadata: Metadata = {
title: "Burrito Consideration App",
description: "Consider the potential of burritos",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>
<AuthProvider>
<Header />
<main>{children}</main>
</AuthProvider>
</body>
</html>
);
}
src/app/page.tsx
'use client';
import { useState } from 'react';
import { useAuth } from '@/contexts/AuthContext';
export default function Home() {
const { user, login } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
try {
const success = await login(username, password);
if (success) {
setUsername('');
setPassword('');
} else {
setError('Please provide both username and password');
}
} catch (err) {
console.error('Login failed:', err);
setError('An error occurred during login');
}
};
if (user) {
return (
<div className="container">
<h1>Welcome back, {user.username}!</h1>
<p>You are logged in. Feel free to explore:</p>
<ul>
<li>Consider the potential of burritos</li>
<li>View your profile and statistics</li>
</ul>
</div>
);
}
return (
<div className="container">
<h1>Welcome to Burrito Consideration App</h1>
<p>Please sign in to begin your burrito journey</p>
<form onSubmit={handleSubmit} className="form">
<div className="form-group">
<label htmlFor="username">Username:</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter any username"
/>
</div>
<div className="form-group">
<label htmlFor="password">Password:</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter any password"
/>
</div>
{error && <p className="error">{error}</p>}
<button type="submit" className="btn-primary">Sign In</button>
</form>
<p className="note">
Note: This is a demo app. Use any username and password to sign in.
</p>
</div>
);
}src/app/profile/page.tsx
'use client';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
import posthog from 'posthog-js';
export default function ProfilePage() {
const { user } = useAuth();
const router = useRouter();
// Redirect to home if not logged in
if (!user) {
router.push('/');
return null;
}
const triggerTestError = () => {
try {
throw new Error('Test error for PostHog error tracking');
} catch (err) {
posthog.captureException(err);
console.error('Captured error:', err);
alert('Error captured and sent to PostHog!');
}
};
return (
<div className="container">
<h1>User Profile</h1>
<div className="stats">
<h2>Your Information</h2>
<p><strong>Username:</strong> {user.username}</p>
<p><strong>Burrito Considerations:</strong> {user.burritoConsiderations}</p>
</div>
<div style={{ marginTop: '2rem' }}>
<button onClick={triggerTestError} className="btn-primary" style={{ backgroundColor: '#dc3545' }}>
Trigger Test Error (for PostHog)
</button>
</div>
<div style={{ marginTop: '2rem' }}>
<h3>Your Burrito Journey</h3>
{user.burritoConsiderations === 0 ? (
<p>You haven't considered any burritos yet. Visit the Burrito Consideration page to start!</p>
) : user.burritoConsiderations === 1 ? (
<p>You've considered the burrito potential once. Keep going!</p>
) : user.burritoConsiderations < 5 ? (
<p>You're getting the hang of burrito consideration!</p>
) : user.burritoConsiderations < 10 ? (
<p>You're becoming a burrito consideration expert!</p>
) : (
<p>You are a true burrito consideration master! 🌯</p>
)}
</div>
</div>
);
}src/components/Header.tsx
'use client';
import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
export default function Header() {
const { user, logout } = useAuth();
return (
<header className="header">
<div className="header-container">
<nav>
<Link href="/">Home</Link>
{user && (
<>
<Link href="/burrito">Burrito Consideration</Link>
<Link href="/profile">Profile</Link>
</>
)}
</nav>
<div className="user-section">
{user ? (
<>
<span>Welcome, {user.username}!</span>
<button onClick={logout} className="btn-logout">
Logout
</button>
</>
) : (
<span>Not logged in</span>
)}
</div>
</div>
</header>
);
}src/contexts/AuthContext.tsx
'use client';
import { createContext, useContext, useState, ReactNode } from 'react';
import posthog from 'posthog-js';
interface User {
username: string;
burritoConsiderations: number;
}
interface AuthContextType {
user: User | null;
login: (username: string, password: string) => Promise<boolean>;
logout: () => void;
incrementBurritoConsiderations: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const users: Map<string, User> = new Map();
export function AuthProvider({ children }: { children: ReactNode }) {
// Use lazy initializer to read from localStorage only once on mount
const [user, setUser] = useState<User | null>(() => {
if (typeof window === 'undefined') return null;
const storedUsername = localStorage.getItem('currentUser');
if (storedUsername) {
const existingUser = users.get(storedUsername);
if (existingUser) {
return existingUser;
}
}
return null;
});
const login = async (username: string, password: string): Promise<boolean> => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (response.ok) {
const { user: userData } = await response.json();
let localUser = users.get(username);
if (!localUser) {
localUser = userData as User;
users.set(username, localUser);
}
setUser(localUser);
localStorage.setItem('currentUser', username);
// Identify user in PostHog using username as distinct ID
posthog.identify(username, {
username: username,
});
// Capture login event
posthog.capture('user_logged_in', {
username: username,
});
return true;
}
return false;
} catch (error) {
console.error('Login error:', error);
return false;
}
};
const logout = () => {
// Capture logout event before resetting
posthog.capture('user_logged_out');
posthog.reset();
setUser(null);
localStorage.removeItem('currentUser');
};
const incrementBurritoConsiderations = () => {
if (user) {
user.burritoConsiderations++;
users.set(user.username, user);
setUser({ ...user });
}
};
return (
<AuthContext.Provider value={{ user, login, logout, incrementBurritoConsiderations }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}src/lib/posthog-server.ts
import { PostHog } from 'posthog-node';
let posthogClient: PostHog | null = null;
export function getPostHogClient() {
if (!posthogClient) {
posthogClient = new PostHog(
process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!,
{
host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
flushAt: 1,
flushInterval: 0
}
);
posthogClient.debug(true);
}
return posthogClient;
}
export async function shutdownPostHog() {
if (posthogClient) {
await posthogClient.shutdown();
}
}references/EXAMPLE-next-pages-router.md
PostHog next-pages-router Example Project
Repository: https://github.com/PostHog/context-mill Path: example-apps/next-pages-router
README.md
PostHog Next.js pages router example
This is a Next.js Pages Router example demonstrating PostHog integration with product analytics, session replay, feature flags, and error tracking.
Features
- Product Analytics: Track user events and behaviors
- Session Replay: Record and replay user sessions
- Error Tracking: Capture and track errors
- User Authentication: Demo login system with PostHog user identification
- Server-side & Client-side Tracking: Examples of both tracking methods
- Reverse Proxy: PostHog ingestion through Next.js rewrites
Getting Started
1. Install Dependencies
npm install
# or
pnpm install2. Configure Environment Variables
Create a .env.local file in the root directory:
NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.comGet your PostHog project token from your PostHog project settings.
3. Run the Development Server
npm run dev
# or
pnpm devOpen http://localhost:3000 with your browser to see the app.
Project Structure
src/
├── components/
│ └── Header.tsx # Navigation header with auth state
├── contexts/
│ └── AuthContext.tsx # Authentication context with PostHog integration
├── lib/
│ └── posthog-server.ts # Server-side PostHog client
├── pages/
│ ├── _app.tsx # App wrapper with Auth provider
│ ├── _document.tsx # Document wrapper
│ ├── index.tsx # Home/Login page
│ ├── burrito.tsx # Demo feature page with event tracking
│ ├── profile.tsx # User profile with error tracking demo
│ └── api/
│ └── auth/
│ └── login.ts # Login API with server-side tracking
└── styles/
└── globals.css # Global styles
instrumentation-client.ts # Client-side PostHog initializationKey Integration Points
Client-side initialization (instrumentation-client.ts)
import posthog from "posthog-js"
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!, {
api_host: "/ingest",
ui_host: "https://us.posthog.com",
defaults: '2026-01-30',
capture_exceptions: true,
debug: process.env.NODE_ENV === "development",
});User identification (AuthContext.tsx)
posthog.identify(username, {
username: username,
});Event tracking (burrito.tsx)
posthog.capture('burrito_considered', {
total_considerations: count,
username: username,
});Error tracking (profile.tsx)
posthog.captureException(error);Server-side tracking (api/auth/login.ts)
const posthog = getPostHogClient();
posthog.capture({
distinctId: username,
event: 'server_login',
properties: { ... }
});Pages router differences from app router
This example uses Next.js Pages Router instead of App Router. Key differences:
- File-based routing: Pages in
src/pages/instead ofsrc/app/ - _app.tsx: Custom App component wraps all pages
- API Routes: Located in
src/pages/api/ - No 'use client': All pages are client-side by default
- useRouter: From
next/routerinstead ofnext/navigation - Head component: Using
next/headfor metadata instead ofmetadataexport
Learn More
Deploy on Vercel
The easiest way to deploy your Next.js app is to use the Vercel Platform.
Check out the Next.js deployment documentation for more details.
instrumentation-client.ts
import posthog from "posthog-js"
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!, {
api_host: "/ingest",
ui_host: "https://us.posthog.com",
// Include the defaults option as required by PostHog
defaults: '2026-01-30',
// Enables capturing unhandled exceptions via Error Tracking
capture_exceptions: true,
// Turn on debug in development mode
debug: process.env.NODE_ENV === "development",
});
//IMPORTANT: Never combine this approach with other client-side PostHog initialization approaches, especially components like a PostHogProvider. instrumentation-client.ts is the correct solution for initializating client-side PostHog in Next.js 15.3+ apps.next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
reactStrictMode: true,
async rewrites() {
return [
{
source: "/ingest/static/:path*",
destination: "https://us-assets.i.posthog.com/static/:path*",
},
{
source: "/ingest/array/:path*",
destination: "https://us-assets.i.posthog.com/array/:path*",
},
{
source: "/ingest/:path*",
destination: "https://us.i.posthog.com/:path*",
},
];
},
// This is required to support PostHog trailing slash API requests
skipTrailingSlashRedirect: true,
};
export default nextConfig;
src/components/Header.tsx
import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
export default function Header() {
const { user, logout } = useAuth();
return (
<header className="header">
<div className="header-container">
<nav>
<Link href="/">Home</Link>
{user && (
<>
<Link href="/burrito">Burrito Consideration</Link>
<Link href="/profile">Profile</Link>
</>
)}
</nav>
<div className="user-section">
{user ? (
<>
<span>Welcome, {user.username}!</span>
<button onClick={logout} className="btn-logout">
Logout
</button>
</>
) : (
<span>Not logged in</span>
)}
</div>
</div>
</header>
);
}
src/contexts/AuthContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react';
import posthog from 'posthog-js';
interface User {
username: string;
burritoConsiderations: number;
}
interface AuthContextType {
user: User | null;
login: (username: string, password: string) => Promise<boolean>;
logout: () => void;
incrementBurritoConsiderations: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const users: Map<string, User> = new Map();
export function AuthProvider({ children }: { children: ReactNode }) {
// Use lazy initializer to read from localStorage only once on mount
const [user, setUser] = useState<User | null>(() => {
if (typeof window === 'undefined') return null;
const storedUsername = localStorage.getItem('currentUser');
if (storedUsername) {
const existingUser = users.get(storedUsername);
if (existingUser) {
return existingUser;
}
}
return null;
});
const login = async (username: string, password: string): Promise<boolean> => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (response.ok) {
const { user: userData } = await response.json();
// Get or create user in local map
let localUser = users.get(username);
if (!localUser) {
localUser = userData as User;
users.set(username, localUser);
}
setUser(localUser);
localStorage.setItem('currentUser', username);
// Identify user in PostHog using username as distinct ID
posthog.identify(username, {
username: username,
});
// Capture login event
posthog.capture('user_logged_in', {
username: username,
});
return true;
}
return false;
} catch (error) {
console.error('Login error:', error);
return false;
}
};
const logout = () => {
// Capture logout event before resetting
posthog.capture('user_logged_out');
posthog.reset();
setUser(null);
localStorage.removeItem('currentUser');
};
const incrementBurritoConsiderations = () => {
if (user) {
user.burritoConsiderations++;
users.set(user.username, user);
setUser({ ...user });
}
};
return (
<AuthContext.Provider value={{ user, login, logout, incrementBurritoConsiderations }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
src/lib/posthog-server.ts
import { PostHog } from 'posthog-node';
let posthogClient: PostHog | null = null;
export function getPostHogClient() {
if (!posthogClient) {
posthogClient = new PostHog(
process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!,
{
host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
flushAt: 1,
flushInterval: 0
}
);
}
return posthogClient;
}
export async function shutdownPostHog() {
if (posthogClient) {
await posthogClient.shutdown();
}
}
src/pages/_app.tsx
import "@/styles/globals.css";
import type { AppProps } from "next/app";
import { AuthProvider } from "@/contexts/AuthContext";
export default function App({ Component, pageProps }: AppProps) {
return (
<AuthProvider>
<Component {...pageProps} />
</AuthProvider>
);
}
src/pages/_document.tsx
import { Html, Head, Main, NextScript } from "next/document";
export default function Document() {
return (
<Html lang="en">
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
src/pages/api/auth/login.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { getPostHogClient } from '@/lib/posthog-server';
const users = new Map<string, { username: string; burritoConsiderations: number }>();
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password required' });
}
let user = users.get(username);
const isNewUser = !user;
if (!user) {
user = { username, burritoConsiderations: 0 };
users.set(username, user);
}
// Capture server-side login event
const posthog = getPostHogClient();
posthog.capture({
distinctId: username,
event: 'server_login',
properties: {
isNewUser: isNewUser,
source: 'api'
}
});
// Identify user on server side
posthog.identify({
distinctId: username,
properties: {
username: username,
createdAt: isNewUser ? new Date().toISOString() : undefined
}
});
// This handler is short-lived; flush so the enqueued events send before it returns
await posthog.flush();
return res.status(200).json({ success: true, user });
}
src/pages/api/hello.ts
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
type Data = {
name: string;
};
export default function handler(
req: NextApiRequest,
res: NextApiResponse<Data>,
) {
res.status(200).json({ name: "John Doe" });
}
src/pages/burrito.tsx
import { useState } from 'react';
import Head from 'next/head';
import { useRouter } from 'next/router';
import posthog from 'posthog-js';
import { useAuth } from '@/contexts/AuthContext';
import Header from '@/components/Header';
export default function BurritoPage() {
const { user, incrementBurritoConsiderations } = useAuth();
const router = useRouter();
const [hasConsidered, setHasConsidered] = useState(false);
// Redirect to home if not logged in
if (!user) {
router.push('/');
return null;
}
const handleConsideration = () => {
incrementBurritoConsiderations();
setHasConsidered(true);
setTimeout(() => setHasConsidered(false), 2000);
// Capture burrito consideration event
posthog.capture('burrito_considered', {
total_considerations: user.burritoConsiderations + 1,
username: user.username,
});
};
return (
<>
<Head>
<title>Burrito Consideration - Burrito Consideration App</title>
<meta name="description" content="Consider the potential of burritos" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<Header />
<main>
<div className="container">
<h1>Burrito consideration zone</h1>
<p>Take a moment to truly consider the potential of burritos.</p>
<div style={{ textAlign: 'center' }}>
<button
onClick={handleConsideration}
className="btn-burrito"
>
I have considered the burrito potential
</button>
{hasConsidered && (
<p className="success">
Thank you for your consideration! Count: {user.burritoConsiderations}
</p>
)}
</div>
<div className="stats">
<h3>Consideration stats</h3>
<p>Total considerations: {user.burritoConsiderations}</p>
</div>
</div>
</main>
</>
);
}
src/pages/index.tsx
import { useState } from 'react';
import Head from 'next/head';
import { useAuth } from '@/contexts/AuthContext';
import Header from '@/components/Header';
export default function Home() {
const { user, login } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
try {
const success = await login(username, password);
if (success) {
setUsername('');
setPassword('');
} else {
setError('Please provide both username and password');
}
} catch (err) {
console.error('Login failed:', err);
setError('An error occurred during login');
}
};
return (
<>
<Head>
<title>Burrito Consideration App</title>
<meta name="description" content="Consider the potential of burritos" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<Header />
<main>
{user ? (
<div className="container">
<h1>Welcome back, {user.username}!</h1>
<p>You are logged in. Feel free to explore:</p>
<ul>
<li>Consider the potential of burritos</li>
<li>View your profile and statistics</li>
</ul>
</div>
) : (
<div className="container">
<h1>Welcome to Burrito Consideration App</h1>
<p>Please sign in to begin your burrito journey</p>
<form onSubmit={handleSubmit} className="form">
<div className="form-group">
<label htmlFor="username">Username:</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter any username"
/>
</div>
<div className="form-group">
<label htmlFor="password">Password:</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter any password"
/>
</div>
{error && <p className="error">{error}</p>}
<button type="submit" className="btn-primary">Sign In</button>
</form>
<p className="note">
Note: This is a demo app. Use any username and password to sign in.
</p>
</div>
)}
</main>
</>
);
}
src/pages/profile.tsx
import Head from 'next/head';
import { useRouter } from 'next/router';
import posthog from 'posthog-js';
import { useAuth } from '@/contexts/AuthContext';
import Header from '@/components/Header';
export default function ProfilePage() {
const { user } = useAuth();
const router = useRouter();
// Redirect to home if not logged in
if (!user) {
router.push('/');
return null;
}
const triggerTestError = () => {
try {
throw new Error('Test error for PostHog error tracking');
} catch (err) {
posthog.captureException(err);
console.error('Captured error:', err);
alert('Error captured and sent to PostHog!');
}
};
return (
<>
<Head>
<title>Profile - Burrito Consideration App</title>
<meta name="description" content="Your burrito consideration profile" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<Header />
<main>
<div className="container">
<h1>User Profile</h1>
<div className="stats">
<h2>Your Information</h2>
<p><strong>Username:</strong> {user.username}</p>
<p><strong>Burrito Considerations:</strong> {user.burritoConsiderations}</p>
</div>
<div style={{ marginTop: '2rem' }}>
<button onClick={triggerTestError} className="btn-primary" style={{ backgroundColor: '#dc3545' }}>
Trigger Test Error (for PostHog)
</button>
</div>
<div style={{ marginTop: '2rem' }}>
<h3>Your Burrito Journey</h3>
{user.burritoConsiderations === 0 ? (
<p>You haven't considered any burritos yet. Visit the Burrito Consideration page to start!</p>
) : user.burritoConsiderations === 1 ? (
<p>You've considered the burrito potential once. Keep going!</p>
) : user.burritoConsiderations < 5 ? (
<p>You're getting the hang of burrito consideration!</p>
) : user.burritoConsiderations < 10 ? (
<p>You're becoming a burrito consideration expert!</p>
) : (
<p>You are a true burrito consideration master! 🌯</p>
)}
</div>
</div>
</main>
</>
);
}
references/EXAMPLE-nuxt-3-6.md
PostHog nuxt-3-6 Example Project
Repository: https://github.com/PostHog/context-mill Path: example-apps/nuxt-3-6
README.md
PostHog Nuxt 3.6 example
This is a Nuxt 3.6 example demonstrating PostHog integration with product analytics, session replay, feature flags, and error tracking.
Nuxt 3.0 - 3.6 does not support the @posthog/nuxt package. You must use the posthog-js and posthog-node packages directly instead. This example also does not cover automatic source map uploads, only available through the @posthog/nuxt package.
Nuxt 2.x is also distinctly different, follow this guide instead.
Features
- Product Analytics: Track user events and behaviors
- Session Replay: Record and replay user sessions
- Error Tracking: Capture and track errors
- User Authentication: Demo login system with PostHog user identification
- Server-side & Client-side Tracking: Examples of both tracking methods
- SSR Support: Server-side rendering with Nuxt 3.6
Getting Started
1. Install Dependencies
npm install
# or
pnpm install2. Configure Environment Variables
Create a .env file in the root directory:
NUXT_PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
NUXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.comGet your PostHog project token from your PostHog project settings.
3. Run the Development Server
npm run dev
# or
pnpm devOpen http://localhost:3000 with your browser to see the app.
Project Structure
├── assets/
│ └── css/
│ └── main.css # Global styles
├── components/
│ └── Header.vue # Navigation header with auth state
├── composables/
│ └── useAuth.ts # Authentication composable
├── pages/
│ ├── index.vue # Home/Login page
│ ├── burrito.vue # Demo feature page with event tracking
│ └── profile.vue # User profile with error tracking demo
├── plugins/
│ └── posthog.client.ts # Client-side PostHog plugin
├── server/
│ ├── api/
│ │ ├── auth/
│ │ │ └── login.post.ts # Login API with server-side tracking
│ │ └── burrito/
│ │ └── consider.post.ts # Burrito API with server-side tracking
│ └── utils/
│ └── users.ts # In-memory user storage utilities
├── types/
│ └── nuxt-app.d.ts # TypeScript declarations for PostHog
├── app.vue # Root component with error handling
└── nuxt.config.ts # Nuxt configurationKey Integration Points
Client-side initialization (plugins/posthog.client.ts)
import posthog from 'posthog-js'
import type { PostHog, PostHogInterface } from 'posthog-js'
export default defineNuxtPlugin((nuxtApp) => {
const runtimeConfig = useRuntimeConfig()
const posthogClient = posthog.init(runtimeConfig.public.posthog.publicKey, {
api_host: runtimeConfig.public.posthog.host,
defaults: runtimeConfig.public.posthog.posthogDefaults as any,
loaded: (posthog: PostHogInterface) => {
if (import.meta.env.MODE === 'development') posthog.debug()
},
})
nuxtApp.hook('vue:error', (error) => {
posthogClient.captureException(error)
})
return {
provide: {
posthog: posthogClient as PostHog,
},
}
})The session and distinct ID are automatically passed to the backend via the X-POSTHOG-SESSION-ID and X-POSTHOG-DISTINCT-ID headers when tracing_headers is configured in the PostHog initialization.
Important: do not identify users on the server-side.
User identification (pages/index.vue)
The user is identified when the user logs in on the client-side.
const { $posthog: posthog } = useNuxtApp()
const handleSubmit = async () => {
const success = await auth.login(username.value, password.value)
if (success) {
// Identifying the user once on login/sign up is enough.
posthog?.identify(username.value)
// Capture login event
posthog?.capture('user_logged_in')
}
}The session and distinct ID are automatically passed to the backend via the X-POSTHOG-SESSION-ID and X-POSTHOG-DISTINCT-ID headers because we set the tracing_headers option in the PostHog initialization.
Important: do not identify users on the server-side.
Server-side API routes (server/api/auth/login.post.ts, server/api/burrito/consider.post.ts)
Server-side API routes create a PostHog Node client for each request and extract session and user context from request headers:
import { PostHog } from 'posthog-node'
import { getHeader } from 'h3'
export default defineEventHandler(async (event) => {
const runtimeConfig = useRuntimeConfig()
// Relies on tracing_headers being set in the client-side SDK
const sessionId = getHeader(event, 'x-posthog-session-id')
const distinctId = getHeader(event, 'x-posthog-distinct-id')
const posthog = new PostHog(
runtimeConfig.public.posthog.publicKey,
{
host: runtimeConfig.public.posthog.host,
}
)
await posthog.withContext(
{ sessionId: sessionId ?? undefined, distinctId: distinctId ?? undefined },
async () => {
posthog.capture({
event: 'server_login',
distinctId: distinctId ?? username,
})
}
)
// Always shutdown to ensure all events are flushed
await posthog.shutdown()
})Key Points:
- Creates a new PostHog Node client for each request
- Extracts
sessionIdanddistinctIdfrom request headers usinggetHeader()fromh3 - Uses
withContext()to associate server-side events with the correct session/user - Properly shuts down the client after each request to ensure events are flushed
Event tracking (pages/burrito.vue)
const { $posthog: posthog } = useNuxtApp()
const handleConsideration = () => {
if (user.value) {
auth.incrementBurritoConsiderations()
posthog?.capture('burrito_considered', {
total_considerations: user.value?.burritoConsiderations + 1,
username: user.value?.username,
})
}
}Error tracking (app.vue, plugins/posthog.client.ts, pages/profile.vue)
Errors are captured in three ways:
- Vue error hook - The
vue:errorhook inplugins/posthog.client.tsautomatically captures Vue errors:
nuxtApp.hook('vue:error', (error) => {
posthogClient.captureException(error)
})- Error boundary - The
onErrorCapturedinapp.vuecaptures component errors:
onErrorCaptured((error) => {
posthog?.captureException(error)
return false // Let the error propagate
})- Manual error capture in components (pages/profile.vue):
const triggerTestError = () => {
try {
throw new Error('Test error for PostHog error tracking')
} catch (err) {
posthog?.captureException(err as Error)
}
}Server-side tracking (server/api/auth/login.post.ts, server/api/burrito/consider.post.ts)
Server-side events use a PostHog Node client created per request:
const posthog = new PostHog(
runtimeConfig.public.posthog.publicKey,
{
host: runtimeConfig.public.posthog.host,
}
)
await posthog.withContext(
{ sessionId: sessionId ?? undefined, distinctId: distinctId ?? undefined },
async () => {
posthog.capture({
event: 'server_login',
distinctId: distinctId ?? username,
})
}
)
await posthog.shutdown()Key Points:
- The PostHog Node client is created per request in each API route
- Events are automatically associated with the correct user/session via
withContext() - The
distinctIdandsessionIdare extracted from request headers and used to maintain context between client and server - Always call
shutdown()to ensure events are flushed
Accessing PostHog in components
PostHog is accessed via useNuxtApp():
const { $posthog: posthog } = useNuxtApp()
posthog?.capture('event_name', { property: 'value' })TypeScript types are provided via types/nuxt-app.d.ts:
import type { PostHog } from 'posthog-js'
declare module '#app' {
interface NuxtApp {
$posthog: PostHog
}
}Learn More
.env.example
NUXT_PUBLIC_POSTHOG_PROJECT_TOKEN=
NUXT_PUBLIC_POSTHOG_HOST=app.vue
<template>
<div>
<NuxtRouteAnnouncer />
<Header />
<main>
<NuxtPage />
</main>
</div>
</template>
components/Header.vue
<template>
<header class="header">
<div class="header-container">
<nav>
<NuxtLink to="/">Home</NuxtLink>
<template v-if="user">
<NuxtLink to="/burrito">Burrito Consideration</NuxtLink>
<NuxtLink to="/profile">Profile</NuxtLink>
</template>
</nav>
<div class="user-section">
<template v-if="user">
<span>Welcome, {{ user.username }}!</span>
<button @click="handleLogout" class="btn-logout">
Logout
</button>
</template>
<template v-else>
<span>Not logged in</span>
</template>
</div>
</div>
</header>
</template>
<script setup lang="ts">
const auth = useAuth()
const user = computed(() => auth.user.value)
const { $posthog: posthog } = useNuxtApp()
const handleLogout = () => {
posthog?.capture('user_logged_out')
posthog?.reset()
auth.logout()
}
</script>
composables/useAuth.ts
interface User {
username: string
burritoConsiderations: number
}
const users = new Map<string, User>()
export const useAuth = () => {
const user = useState<User | null>('auth-user', () => {
if (typeof window !== 'undefined') {
const storedUsername = localStorage.getItem('currentUser')
if (storedUsername) {
const existingUser = users.get(storedUsername)
if (existingUser) {
return existingUser
}
}
}
return null
})
const login = async (username: string, password: string): Promise<boolean> => {
if (!username || !password) {
return false
}
try {
const response = await $fetch('/api/auth/login', {
method: 'POST',
body: { username, password },
})
if (response.success && response.user) {
// Update client-side state
user.value = response.user
users.set(username, response.user)
if (typeof window !== 'undefined') {
localStorage.setItem('currentUser', username)
}
return true
}
return false
} catch (err) {
console.error('Login error:', err)
return false
}
}
const logout = () => {
user.value = null
if (typeof window !== 'undefined') {
localStorage.removeItem('currentUser')
}
}
const setUser = (newUser: User) => {
user.value = newUser
users.set(newUser.username, newUser)
}
const incrementBurritoConsiderations = () => {
if (user.value) {
user.value.burritoConsiderations++
users.set(user.value.username, user.value)
// Trigger reactivity by creating a new object
user.value = { ...user.value }
}
}
return {
user,
login,
logout,
setUser,
incrementBurritoConsiderations
}
}
nuxt.config.ts
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
css: ['~/assets/css/main.css'],
runtimeConfig: {
public: {
posthog: {
publicKey: process.env.NUXT_PUBLIC_POSTHOG_PROJECT_TOKEN,
host: process.env.NUXT_PUBLIC_POSTHOG_HOST,
posthogDefaults: '2026-01-30',
},
},
},
})
pages/burrito.vue
<template>
<div class="container">
<h1>Burrito consideration zone</h1>
<p>Take a moment to truly consider the potential of burritos.</p>
<div style="text-align: center">
<button
@click="handleConsideration"
class="btn-burrito"
>
I have considered the burrito potential
</button>
<p v-if="hasConsidered" class="success">
Thank you for your consideration! Count: {{ user?.burritoConsiderations }}
</p>
</div>
<div class="stats">
<h3>Consideration stats</h3>
<p>Total considerations: {{ user?.burritoConsiderations }}</p>
</div>
</div>
</template>
<script setup lang="ts">
const auth = useAuth()
const user = computed(() => auth.user.value)
const router = useRouter()
const hasConsidered = ref(false)
const { $posthog } = useNuxtApp()
// Redirect to home if not logged in
watchEffect(() => {
if (!user.value) {
router.push('/')
}
})
const handleConsideration = async () => {
if (!user.value) return
try {
const response = await $fetch('/api/burrito/consider', {
method: 'POST',
body: { username: user.value.username },
})
if (response.success && response.user) {
auth.setUser(response.user)
hasConsidered.value = true
// Client-side tracking (in addition to server-side tracking)
$posthog?.capture('burrito_considered', {
total_considerations: response.user.burritoConsiderations,
username: response.user.username,
})
setTimeout(() => {
hasConsidered.value = false
}, 2000)
}
} catch (err) {
console.error('Error considering burrito:', err)
}
}
</script>
pages/index.vue
<template>
<div class="container">
<template v-if="user">
<h1>Welcome back, {{ user.username }}!</h1>
<p>You are logged in. Feel free to explore:</p>
<ul>
<li>Consider the potential of burritos</li>
<li>View your profile and statistics</li>
</ul>
</template>
<template v-else>
<h1>Welcome to Burrito Consideration App</h1>
<p>Please sign in to begin your burrito journey</p>
<form @submit.prevent="handleSubmit" class="form">
<div class="form-group">
<label for="username">Username:</label>
<input
type="text"
id="username"
v-model="username"
placeholder="Enter any username"
/>
</div>
<div class="form-group">
<label for="password">Password:</label>
<input
type="password"
id="password"
v-model="password"
placeholder="Enter any password"
/>
</div>
<p v-if="error" class="error">{{ error }}</p>
<button type="submit" class="btn-primary">Sign In</button>
</form>
<p class="note">
Note: This is a demo app. Use any username and password to sign in.
</p>
</template>
</div>
</template>
<script setup lang="ts">
const auth = useAuth()
const user = computed(() => auth.user.value)
const username = ref('')
const password = ref('')
const error = ref('')
const { $posthog: posthog } = useNuxtApp()
const handleSubmit = async () => {
error.value = ''
const success = await auth.login(username.value, password.value)
if (success) {
// Identifying the user once on login/sign up is enough.
posthog?.identify(username.value)
// Capture login event
posthog?.capture('user_logged_in')
username.value = ''
password.value = ''
} else {
error.value = 'Please provide both username and password'
}
}
</script>
pages/profile.vue
<template>
<div class="container">
<h1>User Profile</h1>
<div class="stats">
<h2>Your Information</h2>
<p><strong>Username:</strong> {{ user?.username }}</p>
<p><strong>Burrito Considerations:</strong> {{ user?.burritoConsiderations }}</p>
</div>
<div style="margin-top: 2rem">
<button @click="triggerTestError" class="btn-primary" style="background-color: #dc3545">
Trigger Test Error (for PostHog)
</button>
</div>
<div style="margin-top: 2rem">
<h3>Your Burrito Journey</h3>
<template v-if="user">
<p v-if="user.burritoConsiderations === 0">
You haven't considered any burritos yet. Visit the Burrito Consideration page to start!
</p>
<p v-else-if="user.burritoConsiderations === 1">
You've considered the burrito potential once. Keep going!
</p>
<p v-else-if="user.burritoConsiderations < 5">
You're getting the hang of burrito consideration!
</p>
<p v-else-if="user.burritoConsiderations < 10">
You're becoming a burrito consideration expert!
</p>
<p v-else>
You are a true burrito consideration master! 🌯
</p>
</template>
</div>
</div>
</template>
<script setup lang="ts">
const auth = useAuth()
const user = computed(() => auth.user.value)
const router = useRouter()
const { $posthog: posthog } = useNuxtApp()
// Redirect to home if not logged in
watchEffect(() => {
if (!user.value) {
router.push('/')
}
})
const triggerTestError = () => {
try {
throw new Error('Test error for PostHog error tracking')
} catch (err) {
console.error('Captured error:', err)
posthog?.captureException(err as Error)
}
}
</script>
plugins/posthog.client.ts
import { defineNuxtPlugin, useRuntimeConfig } from '#imports'
import posthog from 'posthog-js'
import type { PostHog, PostHogInterface } from 'posthog-js'
export default defineNuxtPlugin((nuxtApp) => {
const runtimeConfig = useRuntimeConfig()
const posthogClient = posthog.init(runtimeConfig.public.posthog.publicKey, {
api_host: runtimeConfig.public.posthog.host,
defaults: runtimeConfig.public.posthog.posthogDefaults as any,
// Automatically add X-POSTHOG-SESSION-ID and X-POSTHOG-DISTINCT-ID headers
// to same-origin requests so server-side events join the same session.
tracing_headers: [window.location.hostname],
loaded: (posthog: PostHogInterface) => {
if (import.meta.env.MODE === 'development') posthog.debug()
},
})
nuxtApp.hook('vue:error', (error) => {
posthogClient.captureException(error)
})
return {
provide: {
posthog: posthogClient as PostHog,
},
}
})
public/robots.txt
User-Agent: *
Disallow:
server/api/auth/login.post.ts
import { getOrCreateUser } from '~/server/utils/users'
import { PostHog } from 'posthog-node'
import { useRuntimeConfig } from '#imports'
import { getHeader } from 'h3'
export default defineEventHandler(async (event) => {
if (event.node.req.method !== 'POST') {
throw createError({
statusCode: 405,
statusMessage: 'Method Not Allowed'
})
}
const body = await readBody(event)
const { username, password } = body
if (!username || !password) {
throw createError({
statusCode: 400,
statusMessage: 'Username and password required'
})
}
// Fake auth - just get or create user
const user = getOrCreateUser(username)
const runtimeConfig = useRuntimeConfig()
// Relies on tracing_headers being set in the client-side SDK
const sessionId = getHeader(event, 'x-posthog-session-id')
const distinctId = getHeader(event, 'x-posthog-distinct-id')
const posthog = new PostHog(
runtimeConfig.public.posthog.publicKey,
{
host: runtimeConfig.public.posthog.host,
}
)
await posthog.withContext(
{ sessionId: sessionId ?? undefined, distinctId: distinctId ?? undefined },
async () => {
posthog.capture({
event: 'server_login',
distinctId: distinctId ?? username,
})
}
)
// Always shutdown to ensure all events are flushed
await posthog.shutdown()
return {
success: true,
user: { ...user }
}
})
server/api/burrito/consider.post.ts
import { users, incrementBurritoConsiderations } from '~/server/utils/users'
import { PostHog } from 'posthog-node'
import { useRuntimeConfig } from '#imports'
import { getHeader } from 'h3'
export default defineEventHandler(async (event) => {
if (event.node.req.method !== 'POST') {
throw createError({
statusCode: 405,
statusMessage: 'Method Not Allowed'
})
}
const body = await readBody(event)
const { username } = body
if (!username) {
throw createError({
statusCode: 400,
statusMessage: 'Username required'
})
}
if (!users.has(username)) {
throw createError({
statusCode: 404,
statusMessage: 'User not found'
})
}
// Increment burrito considerations (fake, in-memory)
const user = incrementBurritoConsiderations(username)
const runtimeConfig = useRuntimeConfig()
// Relies on tracing_headers being set in the client-side SDK
const sessionId = getHeader(event, 'x-posthog-session-id')
const distinctId = getHeader(event, 'x-posthog-distinct-id')
const posthog = new PostHog(
runtimeConfig.public.posthog.publicKey,
{
host: runtimeConfig.public.posthog.host,
}
)
await posthog.withContext(
{ sessionId: sessionId ?? undefined, distinctId: distinctId ?? undefined },
async () => {
posthog.capture({
event: 'burrito_considered',
distinctId: distinctId ?? username,
})
}
)
// Always shutdown to ensure all events are flushed
await posthog.shutdown()
return {
success: true,
user: { ...user }
}
})
server/utils/users.ts
interface User {
username: string
burritoConsiderations: number
}
// Shared in-memory storage for users (fake, no database)
export const users = new Map<string, User>()
export function getOrCreateUser(username: string): User {
let user = users.get(username)
if (!user) {
user = {
username,
burritoConsiderations: 0
}
users.set(username, user)
}
return user
}
export function incrementBurritoConsiderations(username: string): User {
const user = users.get(username)
if (!user) {
throw new Error('User not found')
}
user.burritoConsiderations++
users.set(username, user)
return { ...user }
}
types/nuxt-app.d.ts
import type { PostHog } from 'posthog-js'
declare module '#app' {
interface NuxtApp {
$posthog: PostHog
}
}
export {}
references/EXAMPLE-nuxt-4.md
PostHog nuxt-4 Example Project
Repository: https://github.com/PostHog/context-mill Path: example-apps/nuxt-4
README.md
PostHog Nuxt 4 example
This is a Nuxt 4 example demonstrating PostHog integration with product analytics, session replay, feature flags, and error tracking.
Nuxt 4 supports the @posthog/nuxt package, which provides automatic PostHog integration with built-in error tracking, source map uploads, and simplified configuration. This is the recommended approach for Nuxt 4+.
For Nuxt 3.0 - 3.6, you must use the posthog-js and posthog-node packages directly instead. See the Nuxt 3.6 example (../nuxt-3-6) for that approach.
Features
- Product Analytics: Track user events and behaviors
- Session Replay: Record and replay user sessions
- Error Tracking: Automatic error capture on both client and server
- Source Maps: Automatic source map uploads when building for production
- User Authentication: Demo login system with PostHog user identification
- Server-side & Client-side Tracking: Examples of both tracking methods
- SSR Support: Server-side rendering with Nuxt 4
Getting Started
1. Install Dependencies
npm install
# or
pnpm install2. Configure Environment Variables
Create a .env file in the root directory:
NUXT_PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
NUXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
# Optional: For source map uploads
PROJECT_ID=your_project_id
PERSONAL_API_KEY=your_personal_api_keyGet your PostHog project token from your PostHog project settings.
For source map uploads, get your project ID from PostHog environment variables and your personal API key from PostHog user API keys (requires organization:read and error_tracking:write scopes).
3. Run the Development Server
npm run dev
# or
pnpm devOpen http://localhost:3000 with your browser to see the app.
Project Structure
├── app/
│ ├── components/
│ │ └── AppHeader.vue # Navigation header with auth state
│ ├── composables/
│ │ └── useAuth.ts # Authentication composable
│ ├── middleware/
│ │ └── auth.ts # Authentication middleware
│ ├── pages/
│ │ ├── index.vue # Home/Login page
│ │ ├── burrito.vue # Demo feature page with event tracking
│ │ └── profile.vue # User profile with error tracking demo
│ ├── utils/
│ │ └── formValidation.ts # Form validation utilities
│ └── app.vue # Root component
├── assets/
│ └── css/
│ └── main.css # Global styles
├── server/
│ ├── api/
│ │ ├── auth/
│ │ │ └── login.post.ts # Login API with server-side tracking
│ │ └── burrito/
│ │ └── consider.post.ts # Burrito consideration API with server-side tracking
│ └── utils/
│ ├── posthog.ts # Server-side PostHog utility
│ └── users.ts # In-memory user storage utilities
├── nuxt.config.ts # Nuxt configuration with PostHog module
└── package.jsonKey Integration Points
Module Configuration (nuxt.config.ts)
Nuxt 4 uses the @posthog/nuxt module for automatic PostHog integration:
export default defineNuxtConfig({
modules: ['@posthog/nuxt'],
runtimeConfig: {
public: {
posthog: {
publicKey: process.env.NUXT_PUBLIC_POSTHOG_PROJECT_TOKEN || '',
host: process.env.NUXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com',
},
},
},
posthogConfig: {
publicKey: process.env.NUXT_PUBLIC_POSTHOG_PROJECT_TOKEN || '',
host: process.env.NUXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com',
clientConfig: {
capture_exceptions: true, // Enables automatic exception capture on the client side (Vue)
tracing_headers: ['localhost', 'yourdomain.com'], // Add your domain here
},
serverConfig: {
enableExceptionAutocapture: true, // Enables automatic exception capture on the server side (Nitro)
},
sourcemaps: {
enabled: true,
envId: process.env.PROJECT_ID || '',
personalApiKey: process.env.PERSONAL_API_KEY || '',
project: 'my-application',
version: '1.0.0',
},
},
})Key Points:
- The
@posthog/nuxtmodule handles PostHog initialization automatically - Client-side error tracking is enabled via
capture_exceptions: true - Server-side error tracking is enabled via
enableExceptionAutocapture: true - Source map uploads are configured for better error tracking
- The
tracing_headersoption automatically addsX-POSTHOG-SESSION-IDandX-POSTHOG-DISTINCT-IDheaders to requests
Important: do not identify users on the server-side.
User identification (app/pages/index.vue)
The user is identified when the user logs in on the client-side.
const posthog = usePostHog()
const handleSubmit = async () => {
const success = await auth.login(formData.username, formData.password)
if (success) {
// Identifying the user once on login/sign up is enough.
posthog?.identify(formData.username)
// Capture login event
posthog?.capture('user_logged_in')
}
}The session and distinct ID are automatically passed to the backend via the X-POSTHOG-SESSION-ID and X-POSTHOG-DISTINCT-ID headers because we set the tracing_headers option in the PostHog configuration.
Important: do not identify users on the server-side.
Server-side API routes (server/api/auth/login.post.ts)
Server-side API routes use the useServerPostHog() utility to get a PostHog Node client and extract session and user context from request headers:
import { useServerPostHog } from '../../utils/posthog'
import { getOrCreateUser, users } from '../../utils/users'
export default defineEventHandler(async (event) => {
const body = await readBody<{ username: string; password: string }>(event)
const { username, password } = body || {}
if (!username || !password) {
throw createError({
statusCode: 400,
message: 'Username and password required',
})
}
const user = getOrCreateUser(username)
const isNewUser = !users.has(username)
const sessionId = getHeader(event, 'x-posthog-session-id')
const distinctId = getHeader(event, 'x-posthog-distinct-id')
// Capture server-side login event
const posthog = useServerPostHog()
posthog.capture({
distinctId: distinctId,
event: 'server_login',
properties: {
$session_id: sessionId,
username: username,
isNewUser: isNewUser,
source: 'api',
},
})
return {
success: true,
user,
}
})Key Points:
- Uses
useServerPostHog()utility to get a shared PostHog Node client instance - Extracts
sessionIdanddistinctIdfrom request headers usinggetHeader()(auto-imported from h3) - The PostHog client is reused across requests (singleton pattern)
- h3 functions like
defineEventHandler,readBody,createError,getHeaderare auto-imported in server routes
Event tracking (app/pages/burrito.vue)
The burrito consideration page demonstrates both client-side and server-side event tracking:
const posthog = usePostHog()
const handleConsideration = async () => {
if (!user.value) return
try {
// Call server-side API route
const response = await $fetch('/api/burrito/consider', {
method: 'POST',
body: { username: user.value.username },
})
if (response.success && response.user) {
auth.setUser(response.user)
hasConsidered.value = true
// Client-side tracking (in addition to server-side tracking)
posthog?.capture('burrito_considered', {
total_considerations: response.user.burritoConsiderations,
username: response.user.username,
})
setTimeout(() => {
hasConsidered.value = false
}, 2000)
}
} catch (err) {
console.error('Error considering burrito:', err)
}
}The server-side route (server/api/burrito/consider.post.ts) also captures the event, demonstrating dual tracking.
Error tracking
Errors are captured automatically in multiple ways:
Automatic client-side capture - The
@posthog/nuxtmodule automatically captures Vue errors whencapture_exceptions: trueis set inposthogConfig.clientConfig.Automatic server-side capture - The module automatically captures Nitro errors when
enableExceptionAutocapture: trueis set inposthogConfig.serverConfig.Manual error capture in components (app/pages/profile.vue):
const posthog = usePostHog()
const triggerTestError = () => {
try {
throw new Error('Test error for PostHog error tracking')
} catch (err) {
posthog?.captureException(err)
}
}Server-side tracking (server/api/auth/login.post.ts)
Server-side events use the shared PostHog Node client. Note that h3 functions are auto-imported in Nuxt server routes:
import { useServerPostHog } from '../../utils/posthog'
import { getOrCreateUser, users } from '../../utils/users'
export default defineEventHandler(async (event) => {
const body = await readBody<{ username: string; password: string }>(event)
const { username, password } = body || {}
// ... validation logic ...
// Extract headers using getHeader (auto-imported from h3)
const sessionId = getHeader(event, 'x-posthog-session-id')
const distinctId = getHeader(event, 'x-posthog-distinct-id')
// Capture server-side event
const posthog = useServerPostHog()
posthog.capture({
distinctId: distinctId,
event: 'server_login',
properties: {
$session_id: sessionId,
username: username,
isNewUser: isNewUser,
source: 'api',
},
})
return { success: true, user }
})Key Points:
- The PostHog Node client is shared across requests via
useServerPostHog()utility getHeader()is auto-imported from h3 in Nuxt server routes (no need to import from 'h3')- h3 functions like
defineEventHandler,readBody,createErrorare also auto-imported - The
distinctIdandsessionIdare extracted from request headers and used to maintain context between client and server - No need to manually shutdown the client (it's managed by the module)
Accessing PostHog in components
PostHog is accessed via the usePostHog() composable provided by @posthog/nuxt:
const posthog = usePostHog()
posthog?.capture('event_name', { property: 'value' })The composable is automatically typed and available throughout your Nuxt application.
Server-side PostHog utility (server/utils/posthog.ts)
The server utility provides a shared PostHog Node client instance:
import { PostHog } from 'posthog-node'
let client: PostHog | null = null
export function useServerPostHog(): PostHog {
if (!client) {
const config = useRuntimeConfig()
const posthogConfig = config.public.posthog
client = new PostHog(posthogConfig.publicKey, {
host: posthogConfig.host,
})
}
return client
}This ensures a single PostHog client instance is reused across all server requests, improving performance.
Differences from Nuxt 3.6
- Module-based: Uses
@posthog/nuxtmodule instead of manual plugin setup - Automatic error tracking: Built-in error capture on both client and server
- Source map uploads: Automatic source map uploads for better error tracking
- Simplified API: Uses
usePostHog()composable instead ofuseNuxtApp().$posthog - Shared server client: Reuses PostHog Node client across requests instead of creating per-request
- Automatic imports: In Nuxt 4 server routes, h3 functions (
defineEventHandler,readBody,createError,getHeader, etc.) are auto-imported - no need to import them explicitly
Learn More
.env.example
NUXT_PUBLIC_POSTHOG_PROJECT_TOKEN=
NUXT_PUBLIC_POSTHOG_HOST=
PROJECT_ID=
PERSONAL_API_KEY=app/app.vue
<template>
<div style="min-height: 100vh; display: flex; flex-direction: column; background: #f5f5f5; width: 100%;">
<AppHeader />
<main style="flex: 1;">
<NuxtPage />
</main>
</div>
</template>
app/components/AppHeader.vue
<template>
<header class="header">
<div class="header-container">
<nav>
<NuxtLink to="/">Home</NuxtLink>
<template v-if="user">
<NuxtLink to="/burrito">Burrito Consideration</NuxtLink>
<NuxtLink to="/profile">Profile</NuxtLink>
</template>
</nav>
<div class="user-section">
<span v-if="user">Welcome, {{ user.username }}!</span>
<span v-else>Not logged in</span>
<button v-if="user" @click="handleLogout" class="btn-logout">Logout</button>
</div>
</div>
</header>
</template>
<script setup lang="ts">
const posthog = usePostHog()
const auth = useAuth()
const user = computed(() => auth.user.value)
const handleLogout = async () => {
auth.logout()
posthog?.capture('user_logged_out')
posthog?.reset()
await navigateTo('/')
}
</script>
app/composables/useAuth.ts
interface User {
username: string
burritoConsiderations: number
}
const users: Map<string, User> = new Map()
export function useAuth() {
const user = useState<User | null>('auth-user', () => {
if (process.client) {
const storedUsername = localStorage.getItem('currentUser')
if (storedUsername) {
const existingUser = users.get(storedUsername)
if (existingUser) {
return existingUser
}
}
}
return null
})
const login = async (username: string, password: string): Promise<boolean> => {
try {
const response = await $fetch<{ success: boolean; user: User }>('/api/auth/login', {
method: 'POST',
body: { username, password },
})
if (response.success) {
let localUser = users.get(username)
if (!localUser) {
localUser = response.user
users.set(username, localUser)
}
user.value = localUser
if (process.client) {
localStorage.setItem('currentUser', username)
}
return true
}
return false
} catch (error) {
console.error('Login error:', error)
return false
}
}
const logout = () => {
user.value = null
if (process.client) {
localStorage.removeItem('currentUser')
}
}
const incrementBurritoConsiderations = () => {
if (user.value) {
user.value.burritoConsiderations++
users.set(user.value.username, user.value)
// Trigger reactivity
user.value = { ...user.value }
}
}
const setUser = (newUser: User) => {
user.value = newUser
users.set(newUser.username, newUser)
}
return {
user,
login,
logout,
incrementBurritoConsiderations,
setUser,
}
}
app/middleware/auth.ts
export default defineNuxtRouteMiddleware((to, from) => {
const auth = useAuth()
const user = auth.user.value
// If user is not logged in, redirect to home/login page
if (!user) {
return navigateTo('/')
}
})
app/pages/burrito.vue
<template>
<div class="container">
<h1>Burrito consideration zone</h1>
<p>Take a moment to truly consider the potential of burritos.</p>
<div style="text-align: center">
<button @click="handleConsideration" class="btn-burrito">
I have considered the burrito potential
</button>
<p v-if="hasConsidered" class="success">
Thank you for your consideration! Count: {{ user?.burritoConsiderations }}
</p>
</div>
<div class="stats">
<h3>Consideration stats</h3>
<p>Total considerations: {{ user?.burritoConsiderations }}</p>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
middleware: 'auth'
})
const auth = useAuth()
const user = computed(() => auth.user.value)
const posthog = usePostHog()
const hasConsidered = ref(false)
const handleConsideration = async () => {
if (!user.value) return
try {
const response = await $fetch('/api/burrito/consider', {
method: 'POST',
body: { username: user.value.username },
})
if (response.success && response.user) {
auth.setUser(response.user)
hasConsidered.value = true
// Client-side tracking (in addition to server-side tracking)
posthog?.capture('burrito_considered', {
total_considerations: response.user.burritoConsiderations,
username: response.user.username,
})
setTimeout(() => {
hasConsidered.value = false
}, 2000)
}
} catch (err) {
console.error('Error considering burrito:', err)
}
}
</script>
app/pages/index.vue
<template>
<div class="container">
<h1 v-if="user">Welcome back, {{ user.username }}!</h1>
<h1 v-else>Welcome to Burrito Consideration App</h1>
<div v-if="user">
<p>You are logged in. Feel free to explore:</p>
<ul>
<li>Consider the potential of burritos</li>
<li>View your profile and statistics</li>
</ul>
</div>
<div v-else>
<p>Please sign in to begin your burrito journey</p>
<form @submit.prevent="handleSubmit" class="form" novalidate>
<div class="form-group">
<label for="username">Username:</label>
<input
id="username"
v-model="formData.username"
type="text"
placeholder="Enter any username"
:class="{ 'error-input': errors.username }"
@blur="validateField('username')"
@input="clearError('username')"
/>
<p v-if="errors.username" class="field-error">{{ errors.username }}</p>
</div>
<div class="form-group">
<label for="password">Password:</label>
<input
id="password"
v-model="formData.password"
type="password"
placeholder="Enter any password"
:class="{ 'error-input': errors.password }"
@blur="validateField('password')"
@input="clearError('password')"
/>
<p v-if="errors.password" class="field-error">{{ errors.password }}</p>
</div>
<p v-if="error" class="error">{{ error }}</p>
<button type="submit" class="btn-primary" :disabled="isSubmitting">
{{ isSubmitting ? 'Signing in...' : 'Sign In' }}
</button>
</form>
<p class="note">
Note: This is a demo app. Use any username and password to sign in.
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { loginSchema, validateForm, type LoginFormData } from '../utils/formValidation'
const auth = useAuth()
const user = computed(() => auth.user.value)
const posthog = usePostHog()
const formData = reactive<LoginFormData>({
username: '',
password: '',
})
const errors = reactive<Partial<Record<keyof LoginFormData, string>>>({})
const error = ref('')
const isSubmitting = ref(false)
const validateField = (field: keyof LoginFormData) => {
const fieldSchema = loginSchema.shape[field]
if (!fieldSchema) return
const result = fieldSchema.safeParse(formData[field])
if (!result.success) {
errors[field] = result.error.errors[0]?.message || 'Invalid value'
} else {
delete errors[field]
}
}
const clearError = (field: keyof LoginFormData) => {
delete errors[field]
}
const handleSubmit = async () => {
// Clear previous errors
error.value = ''
Object.keys(errors).forEach((key) => {
delete errors[key as keyof LoginFormData]
})
// Validate entire form
const validation = validateForm(loginSchema, formData)
if (!validation.success) {
Object.assign(errors, validation.errors)
return
}
isSubmitting.value = true
try {
const success = await auth.login(formData.username, formData.password)
if (success) {
// Identifying the user once on login/sign up is enough.
posthog?.identify(formData.username)
// Capture login event
posthog?.capture('user_logged_in')
formData.username = ''
formData.password = ''
await navigateTo('/')
} else {
error.value = 'Login failed. Please check your credentials and try again.'
}
} catch (err) {
console.error('Login failed:', err)
error.value = 'An error occurred during login. Please try again.'
} finally {
isSubmitting.value = false
}
}
</script>
app/pages/profile.vue
<template>
<div class="container">
<h1>User Profile</h1>
<div class="stats">
<h2>Your Information</h2>
<p><strong>Username:</strong> {{ user?.username }}</p>
<p><strong>Burrito Considerations:</strong> {{ user?.burritoConsiderations }}</p>
</div>
<div style="margin-top: 2rem">
<button @click="triggerTestError" class="btn-primary" style="background-color: #dc3545">
Trigger Test Error (for PostHog)
</button>
</div>
<div style="margin-top: 2rem">
<h3>Your Burrito Journey</h3>
<p v-if="user?.burritoConsiderations === 0">
You haven't considered any burritos yet. Visit the Burrito Consideration page to start!
</p>
<p v-else-if="user?.burritoConsiderations === 1">
You've considered the burrito potential once. Keep going!
</p>
<p v-else-if="user && user.burritoConsiderations < 5">
You're getting the hang of burrito consideration!
</p>
<p v-else-if="user && user.burritoConsiderations < 10">
You're becoming a burrito consideration expert!
</p>
<p v-else>You are a true burrito consideration master! 🌯</p>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
middleware: 'auth'
})
const auth = useAuth()
const user = computed(() => auth.user.value)
const posthog = usePostHog()
const triggerTestError = () => {
try {
throw new Error('Test error for PostHog error tracking')
} catch (err) {
console.error('Captured error:', err)
posthog?.captureException(err)
}
}
</script>
app/utils/formValidation.ts
import { z } from 'zod'
export const loginSchema = z.object({
username: z
.string()
.min(1, 'Username is required')
.min(3, 'Username must be at least 3 characters')
.max(50, 'Username must be less than 50 characters'),
password: z
.string()
.min(1, 'Password is required')
.min(3, 'Password must be at least 3 characters'),
})
export type LoginFormData = z.infer<typeof loginSchema>
export function validateForm<T>(schema: z.ZodSchema<T>, data: unknown): {
success: boolean
data?: T
errors?: Record<string, string>
} {
const result = schema.safeParse(data)
if (result.success) {
return { success: true, data: result.data }
}
const errors: Record<string, string> = {}
result.error.errors.forEach((error) => {
const path = error.path.join('.')
errors[path] = error.message
})
return { success: false, errors }
}
nuxt.config.ts
import { fileURLToPath } from 'node:url'
import { resolve, dirname } from 'node:path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
css: [resolve(__dirname, 'assets/css/main.css')],
modules: ['@posthog/nuxt'],
runtimeConfig: {
public: {
posthog: {
publicKey: process.env.NUXT_PUBLIC_POSTHOG_PROJECT_TOKEN || '',
host: process.env.NUXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com',
},
},
},
posthogConfig: {
publicKey: process.env.NUXT_PUBLIC_POSTHOG_PROJECT_TOKEN || '', // Find it in project settings https://app.posthog.com/settings/project
host: process.env.NUXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com', // Optional: defaults to https://us.i.posthog.com. Use https://eu.i.posthog.com for EU region
clientConfig: {
capture_exceptions: true, // Enables automatic exception capture on the client side (Vue)
tracing_headers: [ 'localhost', 'yourdomain.com' ], // Add your domain here
},
serverConfig: {
enableExceptionAutocapture: true, // Enables automatic exception capture on the server side (Nitro)
},
sourcemaps: {
enabled: true,
envId: process.env.PROJECT_ID || '', // Your project ID from PostHog settings https://app.posthog.com/settings/environment#variables
personalApiKey: process.env.PERSONAL_API_KEY || '', // Your personal API key from PostHog settings https://app.posthog.com/settings/user-api-keys (requires organization:read and error_tracking:write scopes)
project: 'my-application', // Optional: defaults to git repository name
version: '1.0.0', // Optional: defaults to current git commit
},
},
})
public/robots.txt
User-Agent: *
Disallow:
server/api/auth/login.post.ts
import { useServerPostHog } from '../../utils/posthog'
import { getOrCreateUser, users } from '../../utils/users'
export default defineEventHandler(async (event) => {
const body = await readBody<{ username: string; password: string }>(event)
const { username, password } = body || {}
if (!username || !password) {
throw createError({
statusCode: 400,
message: 'Username and password required',
})
}
const user = getOrCreateUser(username)
const isNewUser = !users.has(username)
const sessionId = getHeader(event, 'x-posthog-session-id')
const distinctId = getHeader(event, 'x-posthog-distinct-id')
// Capture server-side login event
const posthog = useServerPostHog()
posthog.capture({
distinctId: distinctId,
event: 'server_login',
properties: {
$session_id: sessionId,
username: username,
isNewUser: isNewUser,
source: 'api',
},
})
// This handler is short-lived; flush so the enqueued event sends before it returns
await posthog.flush()
return {
success: true,
user,
}
})
server/api/burrito/consider.post.ts
import { useServerPostHog } from '../../utils/posthog'
import { users, incrementBurritoConsiderations } from '../../utils/users'
import { defineEventHandler, readBody, createError, getHeader } from 'h3'
export default defineEventHandler(async (event) => {
const body = await readBody<{ username: string }>(event)
const username = body?.username
if (!username) {
throw createError({
statusCode: 400,
message: 'Username required',
})
}
if (!users.has(username)) {
throw createError({
statusCode: 404,
message: 'User not found',
})
}
// Increment burrito considerations (fake, in-memory)
const user = incrementBurritoConsiderations(username)
const sessionId = getHeader(event, 'x-posthog-session-id')
const distinctId = getHeader(event, 'x-posthog-distinct-id')
// Capture server-side burrito consideration event
const posthog = useServerPostHog()
posthog.capture({
distinctId: distinctId,
event: 'burrito_considered',
properties: {
$session_id: sessionId,
username: username,
total_considerations: user.burritoConsiderations,
source: 'api',
},
})
// This handler is short-lived; flush so the enqueued event sends before it returns
await posthog.flush()
return {
success: true,
user: { ...user },
}
})
server/utils/posthog.ts
import { PostHog } from 'posthog-node'
let client: PostHog | null = null
export function useServerPostHog(): PostHog {
if (!client) {
const config = useRuntimeConfig()
// The @posthog/nuxt module exposes config at runtimeConfig.public.posthog
const posthogConfig = config.public.posthog
client = new PostHog(posthogConfig.publicKey, {
host: posthogConfig.host,
flushAt: 1,
flushInterval: 0,
})
}
return client
}
server/utils/users.ts
// Shared in-memory storage for users (fake, no database)
export const users = new Map<string, { username: string; burritoConsiderations: number }>()
export function getOrCreateUser(username: string): { username: string; burritoConsiderations: number } {
let user = users.get(username)
if (!user) {
user = { username, burritoConsiderations: 0 }
users.set(username, user)
}
return user
}
export function incrementBurritoConsiderations(username: string): { username: string; burritoConsiderations: number } {
const user = users.get(username)
if (!user) {
throw new Error('User not found')
}
user.burritoConsiderations++
users.set(username, user)
return { ...user }
}
references/EXAMPLE-php.md
PostHog php Example Project
Repository: https://github.com/PostHog/context-mill Path: example-apps/php
README.md
PostHog PHP Example - CLI Todo App
A simple command-line todo application built with plain PHP (no framework) demonstrating PostHog integration for CLIs, scripts, data pipelines, and non-web PHP applications.
Purpose
This example serves as:
- Verification that the context-mill wizard works for plain PHP projects
- Reference implementation of PostHog best practices for non-framework PHP code
- Working example you can run and modify
Features Demonstrated
- SDK initialization - Uses
PostHog::init(...)once with environment-based configuration - Event tracking - Captures user actions with
distinctIdand properties - User identification - Associates properties with users via
PostHog::identify(...) - Error tracking - Enables automatic PHP error tracking and manually captures handled exceptions
- Proper flushing - Calls
PostHog::flush()before CLI exit
Quick Start
1. Install Dependencies
composer install2. Configure PostHog
# Copy environment template
cp .env.example .env
# Edit .env and add your PostHog project token
# POSTHOG_PROJECT_TOKEN=phc_your_project_token_here
# POSTHOG_HOST=https://us.i.posthog.com3. Run the App
# Add a todo
php todo.php add "Buy groceries"
# List all todos
php todo.php list
# Complete a todo
php todo.php complete 1
# Delete a todo
php todo.php delete 1
# Show statistics
php todo.php statsWhat Gets Tracked
The app tracks these events in PostHog:
| Event | Properties | Purpose |
|---|---|---|
todo_added |
todo_id, todo_length, total_todos |
When user adds a new todo |
todos_viewed |
total_todos, completed_todos |
When user lists todos |
todo_completed |
todo_id, time_to_complete_hours |
When user completes a todo |
todo_deleted |
todo_id, was_completed |
When user deletes a todo |
stats_viewed |
total_todos, completed_todos, pending_todos |
When user views stats |
$exception |
exception details and command context | When handled errors occur |
Code Structure
basics/php/
├── todo.php # Main CLI application
├── composer.json # PHP dependencies
├── .env.example # Environment variable template
├── .gitignore # Git ignore rules
└── README.md # This fileKey Implementation Patterns
1. Initialize Once
PostHog::init($projectToken, [
'host' => $host,
'error_tracking' => [
'enabled' => true,
],
]);2. Event Tracking Pattern
PostHog::capture([
'distinctId' => 'user_123',
'event' => 'event_name',
'properties' => ['key' => 'value'],
]);3. Identifying Users
PostHog::identify([
'distinctId' => 'user_123',
'properties' => ['app_language' => 'php'],
]);4. Exception Tracking
try {
riskyOperation();
} catch (Throwable $e) {
PostHog::captureException($e, 'user_123', [
'command' => 'example_command',
]);
}5. Flush Before CLI Exit
PostHog::flush();Running Without PostHog
The app works fine without PostHog configured - it simply won't track analytics. You'll see a warning message but the app continues to function normally.
Next Steps
- Modify
todo.phpto experiment with PostHog tracking - Add new commands and track their usage
- Explore feature flags:
PostHog::isFeatureEnabled('flag-name', 'user_id') - Check your PostHog dashboard to see tracked events
Learn More
.env.example
# PostHog configuration
POSTHOG_PROJECT_TOKEN=phc_your_project_token_here
POSTHOG_HOST=https://us.i.posthog.com
todo.php
<?php
declare(strict_types=1);
// Simple CLI Todo App with PostHog Analytics
//
// A minimal plain PHP CLI application demonstrating PostHog integration
// for non-framework PHP projects (CLIs, scripts, data pipelines, etc.).
require __DIR__ . '/vendor/autoload.php';
use PostHog\PostHog;
const DATA_FILE = '.todo_app_php.json';
function loadEnvFile(string $path): void
{
if (!file_exists($path)) {
return;
}
foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) {
continue;
}
[$key, $value] = explode('=', $line, 2);
$key = trim($key);
$value = trim($value, " \t\n\r\0\x0B\"'");
if ($key !== '' && getenv($key) === false) {
putenv($key . '=' . $value);
$_ENV[$key] = $value;
}
}
}
function dataFilePath(): string
{
$home = getenv('HOME') ?: sys_get_temp_dir();
return rtrim($home, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . DATA_FILE;
}
function initializePostHog(): bool
{
loadEnvFile(__DIR__ . '/.env');
$projectToken = getenv('POSTHOG_PROJECT_TOKEN');
if (!$projectToken || str_starts_with($projectToken, 'phc_your_')) {
echo "WARNING: PostHog not configured (POSTHOG_PROJECT_TOKEN not set)\n";
echo " App will work but analytics won't be tracked\n";
return false;
}
PostHog::init($projectToken, [
'host' => getenv('POSTHOG_HOST') ?: 'https://us.i.posthog.com',
'error_tracking' => [
'enabled' => true,
'context_provider' => static function (array $payload): array {
return [
'distinctId' => getUserId(),
'properties' => [
'app' => 'php_todo_cli',
'runtime' => PHP_VERSION,
'$exception_source' => $payload['source'] ?? null,
],
];
},
],
]);
return true;
}
function getUserId(): string
{
$path = dataFilePath();
if (file_exists($path)) {
$data = json_decode((string) file_get_contents($path), true);
if (is_array($data) && isset($data['user_id'])) {
return (string) $data['user_id'];
}
}
return 'user_' . bin2hex(random_bytes(4));
}
function loadTodos(): array
{
$path = dataFilePath();
if (!file_exists($path)) {
return ['user_id' => getUserId(), 'todos' => []];
}
$data = json_decode((string) file_get_contents($path), true);
if (!is_array($data)) {
return ['user_id' => getUserId(), 'todos' => []];
}
$data['todos'] = $data['todos'] ?? [];
$data['user_id'] = $data['user_id'] ?? getUserId();
return $data;
}
function saveTodos(array $data): void
{
file_put_contents(dataFilePath(), json_encode($data, JSON_PRETTY_PRINT) . PHP_EOL);
}
function identifyUser(bool $posthogEnabled): void
{
if (!$posthogEnabled) {
return;
}
PostHog::identify([
'distinctId' => getUserId(),
'properties' => [
'app_language' => 'php',
'app_type' => 'cli',
],
]);
}
function trackEvent(bool $posthogEnabled, string $eventName, array $properties = []): void
{
if (!$posthogEnabled) {
return;
}
PostHog::capture([
'distinctId' => getUserId(),
'event' => $eventName,
'properties' => $properties,
]);
}
function cmdAdd(string $text, bool $posthogEnabled): void
{
$data = loadTodos();
$todo = [
'id' => count($data['todos']) + 1,
'text' => $text,
'completed' => false,
'created_at' => date(DATE_ATOM),
];
$data['todos'][] = $todo;
saveTodos($data);
echo "Added todo #{$todo['id']}: {$todo['text']}\n";
trackEvent($posthogEnabled, 'todo_added', [
'todo_id' => $todo['id'],
'todo_length' => strlen($todo['text']),
'total_todos' => count($data['todos']),
]);
}
function cmdList(bool $posthogEnabled): void
{
$data = loadTodos();
if (count($data['todos']) === 0) {
echo "No todos yet! Add one with: php todo.php add 'Your task'\n";
return;
}
echo "\nYour Todos (" . count($data['todos']) . " total):\n\n";
foreach ($data['todos'] as $todo) {
$status = $todo['completed'] ? 'X' : ' ';
echo " [{$status}] #{$todo['id']}: {$todo['text']}\n";
}
echo "\n";
trackEvent($posthogEnabled, 'todos_viewed', [
'total_todos' => count($data['todos']),
'completed_todos' => count(array_filter($data['todos'], static fn (array $todo): bool => (bool) $todo['completed'])),
]);
}
function cmdComplete(int $id, bool $posthogEnabled): void
{
$data = loadTodos();
foreach ($data['todos'] as &$todo) {
if ((int) $todo['id'] !== $id) {
continue;
}
if ($todo['completed']) {
echo "Todo #{$id} is already completed\n";
return;
}
$todo['completed'] = true;
$todo['completed_at'] = date(DATE_ATOM);
saveTodos($data);
echo "Completed todo #{$todo['id']}: {$todo['text']}\n";
$timeToComplete = (strtotime($todo['completed_at']) - strtotime($todo['created_at'])) / 3600;
trackEvent($posthogEnabled, 'todo_completed', [
'todo_id' => $todo['id'],
'time_to_complete_hours' => $timeToComplete,
]);
return;
}
echo "ERROR: Todo #{$id} not found\n";
}
function cmdDelete(int $id, bool $posthogEnabled): void
{
$data = loadTodos();
foreach ($data['todos'] as $index => $todo) {
if ((int) $todo['id'] !== $id) {
continue;
}
unset($data['todos'][$index]);
$data['todos'] = array_values($data['todos']);
saveTodos($data);
echo "Deleted todo #{$id}\n";
trackEvent($posthogEnabled, 'todo_deleted', [
'todo_id' => $todo['id'],
'was_completed' => $todo['completed'],
]);
return;
}
echo "ERROR: Todo #{$id} not found\n";
}
function cmdStats(bool $posthogEnabled): void
{
$data = loadTodos();
$total = count($data['todos']);
$completed = count(array_filter($data['todos'], static fn (array $todo): bool => (bool) $todo['completed']));
$pending = $total - $completed;
$rate = $total > 0 ? number_format($completed / $total * 100, 1) : '0.0';
echo "\nStats:\n\n";
echo " Total todos: {$total}\n";
echo " Completed: {$completed}\n";
echo " Pending: {$pending}\n";
echo " Completion rate: {$rate}%\n\n";
trackEvent($posthogEnabled, 'stats_viewed', [
'total_todos' => $total,
'completed_todos' => $completed,
'pending_todos' => $pending,
]);
}
function printUsage(): void
{
echo <<<USAGE
Simple todo app with PostHog analytics
Usage:
php todo.php add "Todo text" Add a new todo
php todo.php list List all todos
php todo.php complete <id> Mark todo as completed
php todo.php delete <id> Delete a todo
php todo.php stats Show statistics
USAGE;
}
$posthogEnabled = false;
try {
$posthogEnabled = initializePostHog();
identifyUser($posthogEnabled);
$command = $argv[1] ?? null;
if (!$command) {
printUsage();
exit(0);
}
switch ($command) {
case 'add':
$text = $argv[2] ?? null;
if (!$text) {
echo "ERROR: Please provide todo text\n";
echo "Usage: php todo.php add \"Your task\"\n";
exit(1);
}
cmdAdd($text, $posthogEnabled);
break;
case 'list':
cmdList($posthogEnabled);
break;
case 'complete':
$id = (int) ($argv[2] ?? 0);
if ($id <= 0) {
echo "ERROR: Please provide a valid todo ID\n";
echo "Usage: php todo.php complete <id>\n";
exit(1);
}
cmdComplete($id, $posthogEnabled);
break;
case 'delete':
$id = (int) ($argv[2] ?? 0);
if ($id <= 0) {
echo "ERROR: Please provide a valid todo ID\n";
echo "Usage: php todo.php delete <id>\n";
exit(1);
}
cmdDelete($id, $posthogEnabled);
break;
case 'stats':
cmdStats($posthogEnabled);
break;
default:
echo "ERROR: Unknown command '{$command}'\n";
printUsage();
exit(1);
}
} catch (Throwable $e) {
echo "ERROR: {$e->getMessage()}\n";
if ($posthogEnabled) {
PostHog::captureException($e, getUserId(), [
'command' => $argv[1] ?? null,
'app' => 'php_todo_cli',
]);
}
exit(1);
} finally {
if ($posthogEnabled) {
PostHog::flush();
}
}
references/EXAMPLE-python.md
PostHog python Example Project
Repository: https://github.com/PostHog/context-mill Path: example-apps/python
README.md
PostHog Python Example - CLI Todo App
A simple command-line todo application built with plain Python (no frameworks) demonstrating PostHog integration for CLIs, scripts, data pipelines, and non-web Python applications.
Purpose
This example serves as:
- Verification that the context-mill wizard works for plain Python projects
- Reference implementation of PostHog best practices for non-framework Python code
- Working example you can run and modify
Features Demonstrated
- Instance-based API - Uses
Posthog(...)class instead of module-level API - Exception autocapture - Automatic tracking of unhandled exceptions
- Proper shutdown - Uses
shutdown()to flush events before exit - Event tracking - Captures user actions with
distinct_idand properties - User identification - Sets properties on users via
identify(), and updates them later withset()andsetOnce() - Error handling - Manual exception capture for handled errors
Quick Start
1. Install Dependencies
# Create virtual environment (recommended)
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install dependencies
pip install -r requirements.txt2. Configure PostHog
# Copy environment template
cp .env.example .env
# Edit .env and add your PostHog project token
# POSTHOG_PROJECT_TOKEN=phc_your_project_token_here
# POSTHOG_HOST=https://us.i.posthog.com3. Run the App
# Add a todo
python todo.py add "Buy groceries"
# List all todos
python todo.py list
# Complete a todo
python todo.py complete 1
# Delete a todo
python todo.py delete 1
# Show statistics
python todo.py statsWhat Gets Tracked
The app tracks these events in PostHog:
| Event | Properties | Purpose |
|---|---|---|
todo_added |
todo_id, todo_length, total_todos |
When user adds a new todo |
todos_viewed |
total_todos, completed_todos |
When user lists todos |
todo_completed |
todo_id, time_to_complete_hours |
When user completes a todo |
todo_deleted |
todo_id, was_completed |
When user deletes a todo |
stats_viewed |
total_todos, completed_todos, pending_todos |
When user views stats |
Code Structure
basics/python/
├── todo.py # Main CLI application
├── requirements.txt # Python dependencies
├── .env.example # Environment variable template
├── .gitignore # Git ignore rules
└── README.md # This fileKey Implementation Patterns
1. Instance-Based Initialization
from posthog import Posthog
posthog = Posthog(
api_key,
host='https://us.i.posthog.com',
enable_exception_autocapture=True # Automatically capture exceptions
)2. Event Tracking Pattern
# Track events with distinct_id
posthog_client.capture(
distinct_id="user_123",
event="event_name",
properties={"key": "value"}
)3. Proper Shutdown
try:
# Your application code
pass
finally:
# Always call shutdown() to flush events and close connections
posthog.shutdown()4. Identifying Users
# Set person properties on a user profile
posthog_client.set(
distinct_id="user_123",
properties={"email": "user@example.com", "plan": "pro"}
)5. Exception Handling
try:
# Code that might fail
risky_operation()
except Exception as e:
# Manually capture handled errors you want to track
posthog_client.capture_exception(e, distinct_id="user_123")Running Without PostHog
The app works fine without PostHog configured - it simply won't track analytics. You'll see a warning message but the app continues to function normally.
Next Steps
- Modify
todo.pyto experiment with PostHog tracking - Add new commands and track their usage
- Explore feature flags:
posthog.feature_enabled('flag-name', user_id) - Check your PostHog dashboard to see tracked events
Learn More
.env.example
# PostHog Configuration
POSTHOG_PROJECT_TOKEN=phc_your_project_token_here
POSTHOG_HOST=https://us.i.posthog.com
# Optional: Enable debug mode to see PostHog requests
# POSTHOG_DEBUG=true
requirements.txt
posthog>=3.0.0
python-dotenv>=1.0.0
todo.py
#!/usr/bin/env python3
"""Simple CLI Todo App with PostHog Analytics
A minimal plain Python CLI application demonstrating PostHog integration
for non-framework Python projects (CLIs, scripts, data pipelines, etc.).
"""
import argparse
import json
import os
import sys
from datetime import datetime
from pathlib import Path
from dotenv import load_dotenv
from posthog import Posthog
# Load environment variables
load_dotenv()
# Data file location
DATA_FILE = Path.home() / ".todo_app.json"
def initialize_posthog():
"""Initialize PostHog with instance-based API.
Returns PostHog instance or None if project token not configured.
"""
project_token = os.getenv('POSTHOG_PROJECT_TOKEN')
if not project_token:
print("WARNING: PostHog not configured (POSTHOG_PROJECT_TOKEN not set)")
print(" App will work but analytics won't be tracked")
return None
# Create PostHog instance with opinionated defaults
posthog = Posthog(
project_token,
host=os.getenv('POSTHOG_HOST', 'https://us.i.posthog.com'),
debug=os.getenv('POSTHOG_DEBUG', 'False').lower() == 'true',
enable_exception_autocapture=True # Auto-capture unhandled exceptions
)
return posthog
def get_user_id():
"""Get or create a user ID for this installation.
Uses a UUID stored in the data file to represent this user.
In a real app, this would be your actual user ID.
"""
import uuid
if DATA_FILE.exists():
data = json.loads(DATA_FILE.read_text())
if 'user_id' in data:
return data['user_id']
# Create new user ID
return f"user_{uuid.uuid4().hex[:8]}"
def load_todos():
"""Load todos from disk."""
if not DATA_FILE.exists():
return {"user_id": get_user_id(), "todos": []}
return json.loads(DATA_FILE.read_text())
def save_todos(data):
"""Save todos to disk."""
DATA_FILE.write_text(json.dumps(data, indent=2))
def track_event(posthog, event_name, properties=None):
"""Track an event with PostHog.
Uses the real PostHog Python SDK API.
"""
if not posthog:
return
posthog.capture(
distinct_id=get_user_id(),
event=event_name,
properties=properties or {}
)
def cmd_add(args, posthog):
"""Add a new todo item."""
data = load_todos()
todo = {
"id": len(data["todos"]) + 1,
"text": args.text,
"completed": False,
"created_at": datetime.now().isoformat()
}
data["todos"].append(todo)
save_todos(data)
print(f"Added todo #{todo['id']}: {todo['text']}")
# Track the event
track_event(posthog, "todo_added", {
"todo_id": todo["id"],
"todo_length": len(todo["text"]),
"total_todos": len(data["todos"])
})
def cmd_list(args, posthog):
"""List all todos."""
data = load_todos()
if not data["todos"]:
print("No todos yet! Add one with: todo add 'Your task'")
return
print(f"\nYour Todos ({len(data['todos'])} total):\n")
for todo in data["todos"]:
status = "X" if todo["completed"] else " "
print(f" [{status}] #{todo['id']}: {todo['text']}")
print()
# Track the event
track_event(posthog, "todos_viewed", {
"total_todos": len(data["todos"]),
"completed_todos": sum(1 for t in data["todos"] if t["completed"])
})
def cmd_complete(args, posthog):
"""Mark a todo as completed."""
data = load_todos()
todo = next((t for t in data["todos"] if t["id"] == args.id), None)
if not todo:
print(f"ERROR: Todo #{args.id} not found")
return
if todo["completed"]:
print(f"Todo #{args.id} is already completed")
return
todo["completed"] = True
todo["completed_at"] = datetime.now().isoformat()
save_todos(data)
print(f"Completed todo #{todo['id']}: {todo['text']}")
# Track the event
track_event(posthog, "todo_completed", {
"todo_id": todo["id"],
"time_to_complete_hours": (
datetime.fromisoformat(todo["completed_at"]) -
datetime.fromisoformat(todo["created_at"])
).total_seconds() / 3600
})
def cmd_delete(args, posthog):
"""Delete a todo."""
data = load_todos()
todo = next((t for t in data["todos"] if t["id"] == args.id), None)
if not todo:
print(f"ERROR: Todo #{args.id} not found")
return
data["todos"].remove(todo)
save_todos(data)
print(f"Deleted todo #{args.id}")
# Track the event
track_event(posthog, "todo_deleted", {
"todo_id": todo["id"],
"was_completed": todo["completed"]
})
def cmd_stats(args, posthog):
"""Show usage statistics."""
data = load_todos()
total = len(data["todos"])
completed = sum(1 for t in data["todos"] if t["completed"])
pending = total - completed
print(f"\nStats:\n")
print(f" Total todos: {total}")
print(f" Completed: {completed}")
print(f" Pending: {pending}")
print(f" Completion rate: {(completed/total*100) if total > 0 else 0:.1f}%")
print()
# Track the event
track_event(posthog, "stats_viewed", {
"total_todos": total,
"completed_todos": completed,
"pending_todos": pending
})
def main():
"""Main CLI entry point."""
parser = argparse.ArgumentParser(
description="Simple todo app with PostHog analytics"
)
subparsers = parser.add_subparsers(dest="command", help="Available commands")
# Add command
add_parser = subparsers.add_parser("add", help="Add a new todo")
add_parser.add_argument("text", help="Todo text")
# List command
subparsers.add_parser("list", help="List all todos")
# Complete command
complete_parser = subparsers.add_parser("complete", help="Mark todo as completed")
complete_parser.add_argument("id", type=int, help="Todo ID")
# Delete command
delete_parser = subparsers.add_parser("delete", help="Delete a todo")
delete_parser.add_argument("id", type=int, help="Todo ID")
# Stats command
subparsers.add_parser("stats", help="Show statistics")
args = parser.parse_args()
if not args.command:
parser.print_help()
return
# Initialize PostHog
posthog = initialize_posthog()
try:
# Route to appropriate command
if args.command == "add":
cmd_add(args, posthog)
elif args.command == "list":
cmd_list(args, posthog)
elif args.command == "complete":
cmd_complete(args, posthog)
elif args.command == "delete":
cmd_delete(args, posthog)
elif args.command == "stats":
cmd_stats(args, posthog)
except Exception as e:
print(f"ERROR: {e}")
# Manually capture handled errors
if posthog:
posthog.capture_exception(e, get_user_id())
sys.exit(1)
finally:
# IMPORTANT: Always shutdown PostHog to flush events
if posthog:
posthog.shutdown()
if __name__ == "__main__":
main()
references/EXAMPLE-react-native.md
PostHog react-native Example Project
Repository: https://github.com/PostHog/context-mill Path: example-apps/react-native
README.md
PostHog React Native example
This is a bare React Native example (no Expo) demonstrating PostHog integration with product analytics, user identification, autocapture, and error tracking.
Features
- Product analytics: Track user events and behaviors
- Autocapture: Automatic touch event and screen view tracking
- Error tracking: Capture and track errors manually
- User authentication: Demo login system with PostHog user identification
- Session persistence: AsyncStorage for maintaining user sessions across app restarts
- Native navigation: React Navigation v7 with native stack navigator
Prerequisites
For iOS Development
You need a Mac with the following installed:
Xcode (from the Mac App Store)
- Open App Store and search for "Xcode"
- Install it (~12GB download)
- After installing, open Xcode once to accept the license agreement
Xcode Command Line Tools
xcode-select --installCocoaPods (iOS dependency manager)
brew install cocoapodsOr without Homebrew:
sudo gem install cocoapods
For Android Development
Android Studio (the Android IDE)
brew install --cask android-studioOr download from: https://developer.android.com/studio
First-time Android Studio Setup
- Open Android Studio
- Complete the setup wizard (downloads Android SDK automatically)
- Go to Settings → Languages & Frameworks → Android SDK
- Ensure "Android SDK Platform 34" (or latest) is installed
Create an Android Emulator
- In Android Studio: Tools → Device Manager
- Click Create Device
- Select a phone (e.g., "Pixel 7")
- Download a system image (e.g., API 34)
- Finish and click the Play button to launch
Environment Variables (add to
~/.zshrcor~/.bashrc)# Android SDK export ANDROID_HOME=$HOME/Library/Android/sdk export PATH=$PATH:$ANDROID_HOME/emulator export PATH=$PATH:$ANDROID_HOME/platform-tools # Java from Android Studio (required for Gradle) export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" export PATH=$JAVA_HOME/bin:$PATHThen run
source ~/.zshrcto apply.Create local.properties file (if SDK location is not detected) Create
android/local.propertieswith:sdk.dir=$HOME/Library/Android/sdkClear Gradle cache (required when jumping between different versions of Gradle)
rm -rf ~/.gradle/caches/modules-2/files-2.1/org.gradle.toolchains/foojay-resolver
Getting started
1. Install dependencies
npm install2. Configure environment variables
Create a .env file:
cp .env.example .envEdit .env and add your PostHog project token:
POSTHOG_PROJECT_TOKEN=phc_your_project_token_here
POSTHOG_HOST=https://us.i.posthog.comGet your PostHog project token from your PostHog project settings.
Note: The app will still run without a PostHog project token - analytics will simply be disabled.
3. Run on iOS
Install iOS dependencies (first time only):
cd ios && pod install && cd ..Run the app:
npm run iosNote: First build takes 5-10 minutes. Subsequent builds are much faster.
4. Run on Android
Make sure an Android emulator is running (from Android Studio Device Manager), then:
npm run androidNote: First build takes 3-5 minutes.
Troubleshooting
iOS Issues
"No `Podfile' found"
- Make sure you're in the
iosdirectory:cd ios && pod install
Build fails with signing errors
- Open
ios/BurritoApp.xcworkspacein Xcode - Select the project → Signing & Capabilities
- Select your development team
Simulator not launching
- Open Xcode → Open Developer Tool → Simulator
- Or run:
open -a Simulator
Android Issues
"SDK location not found"
- Ensure
ANDROID_HOMEis set in your shell profile - Run
source ~/.zshrcafter adding it
"No connected devices"
- Launch an emulator from Android Studio Device Manager
- Or connect a physical device with USB debugging enabled
Gradle build fails
- Try:
cd android && ./gradlew clean && cd .. - Then:
npm run android
Project structure
src/
├── config/
│ └── posthog.ts # PostHog client configuration
├── contexts/
│ └── AuthContext.tsx # Authentication context with PostHog integration
├── navigation/
│ └── RootNavigator.tsx # React Navigation stack navigator
├── screens/
│ ├── HomeScreen.tsx # Home/login screen
│ ├── BurritoScreen.tsx # Demo feature screen with event tracking
│ └── ProfileScreen.tsx # User profile with error tracking demo
├── services/
│ └── storage.ts # AsyncStorage wrapper for persistence
├── styles/
│ └── theme.ts # Shared style constants
└── types/
└── env.d.ts # Type declarations for environment variables
App.tsx # Root component with PostHogProvider
index.js # App entry point
.env # Environment variables (create from .env.example)
ios/ # Native iOS project (Xcode)
android/ # Native Android project (Android Studio)Key integration points
PostHog client setup (config/posthog.ts)
The PostHog client is configured with V4 SDK options. If no project token is provided, analytics are disabled gracefully:
import PostHog from 'posthog-react-native'
import Config from 'react-native-config'
const projectToken = Config.POSTHOG_PROJECT_TOKEN
const isPostHogConfigured = projectToken && projectToken !== 'phc_your_project_token_here'
export const posthog = new PostHog(projectToken || 'placeholder_key', {
host: Config.POSTHOG_HOST || 'https://us.i.posthog.com',
disabled: !isPostHogConfigured, // Disable if no project token
captureAppLifecycleEvents: true,
debug: __DEV__,
flushAt: 20,
flushInterval: 10000,
preloadFeatureFlags: true,
})Provider setup with React Navigation v7 (App.tsx)
For React Navigation v7, PostHogProvider must be placed inside NavigationContainer, and screen tracking must be done manually:
import { NavigationContainer, NavigationContainerRef } from '@react-navigation/native'
import { PostHogProvider } from 'posthog-react-native'
import { posthog } from './src/config/posthog'
export default function App() {
const navigationRef = useRef<NavigationContainerRef<RootStackParamList>>(null)
const routeNameRef = useRef<string | undefined>()
return (
<NavigationContainer
ref={navigationRef}
onReady={() => {
routeNameRef.current = navigationRef.current?.getCurrentRoute()?.name
}}
onStateChange={() => {
// Manual screen tracking for React Navigation v7
const previousRouteName = routeNameRef.current
const currentRouteName = navigationRef.current?.getCurrentRoute()?.name
if (previousRouteName !== currentRouteName && currentRouteName) {
posthog.screen(currentRouteName, {
previous_screen: previousRouteName,
})
}
routeNameRef.current = currentRouteName
}}
>
<PostHogProvider
client={posthog}
autocapture={{
captureScreens: false, // Disabled for React Navigation v7
captureTouches: true, // Enable touch event autocapture
propsToCapture: ['testID'],
}}
>
<AuthProvider>
<RootNavigator />
</AuthProvider>
</PostHogProvider>
</NavigationContainer>
)
}Autocapture
PostHog autocapture automatically tracks:
- Touch events: When users interact with the screen
- App lifecycle events: Application Installed, Updated, Opened, Became Active, Backgrounded
Use testID prop on components to help identify them in analytics:
<TouchableOpacity testID="consider-burrito-button" onPress={handlePress}>
<Text>Consider Burrito</Text>
</TouchableOpacity>User identification (contexts/AuthContext.tsx)
Use $set and $set_once for person properties:
import { usePostHog } from 'posthog-react-native'
const posthog = usePostHog()
// On login - identify with person properties
posthog.identify(username, {
$set: {
username: username,
},
$set_once: {
first_login_date: new Date().toISOString(),
},
})
// Capture login event
posthog.capture('user_logged_in', {
username: username,
is_new_user: isNewUser,
})
// On logout - reset clears distinct ID and anonymous ID
posthog.capture('user_logged_out')
posthog.reset()Event tracking (screens/BurritoScreen.tsx)
Capture custom events with properties:
import { usePostHog } from 'posthog-react-native'
const posthog = usePostHog()
// We recommend using a [object] [verb] format for event names
posthog.capture('burrito_considered', {
total_considerations: user.burritoConsiderations + 1,
username: user.username,
})Error tracking (screens/ProfileScreen.tsx)
Capture exceptions using captureException:
import { usePostHog } from 'posthog-react-native'
const posthog = usePostHog()
try {
throw new Error('Test error for PostHog error tracking')
} catch (err) {
posthog.captureException(err)
}Session persistence (services/storage.ts)
AsyncStorage replaces localStorage for persisting user sessions:
import AsyncStorage from '@react-native-async-storage/async-storage'
export const storage = {
getCurrentUser: async (): Promise<string | null> => {
return await AsyncStorage.getItem('currentUser')
},
setCurrentUser: async (username: string): Promise<void> => {
await AsyncStorage.setItem('currentUser', username)
},
saveUser: async (user: User): Promise<void> => {
const users = await storage.getUsers()
users[user.username] = user
await AsyncStorage.setItem('users', JSON.stringify(users))
},
}Learn more
- PostHog documentation
- PostHog React Native integration
- PostHog React Native autocapture
- PostHog React Native screen tracking
- React Native documentation
- React Native environment setup
- React Navigation documentation
tests/App.test.tsx
/**
* @format
*/
import React from 'react';
import ReactTestRenderer from 'react-test-renderer';
import App from '../App';
test('renders correctly', async () => {
await ReactTestRenderer.act(() => {
ReactTestRenderer.create(<App />);
});
});
.env.example
POSTHOG_PROJECT_TOKEN=phc_your_project_token_here
POSTHOG_HOST=https://us.i.posthog.com
.prettierrc.js
module.exports = {
arrowParens: 'avoid',
singleQuote: true,
trailingComma: 'all',
};
App.tsx
import React, { useRef } from 'react'
import { StatusBar } from 'react-native'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import {
NavigationContainer,
NavigationContainerRef,
} from '@react-navigation/native'
import { PostHogProvider } from 'posthog-react-native'
import { AuthProvider } from './src/contexts/AuthContext'
import { RootNavigator, RootStackParamList } from './src/navigation/RootNavigator'
import { posthog } from './src/config/posthog'
import { colors } from './src/styles/theme'
/**
* Burrito Consideration App
*
* A demo React Native application showcasing PostHog analytics integration.
*
* Features:
* - User authentication (demo mode - accepts any credentials)
* - Burrito consideration counter with event tracking
* - User profile with statistics
* - Error tracking demonstration
*
* @see https://posthog.com/docs/libraries/react-native
*/
export default function App() {
const navigationRef = useRef<NavigationContainerRef<RootStackParamList>>(null)
const routeNameRef = useRef<string | undefined>()
return (
<SafeAreaProvider>
<StatusBar
barStyle="light-content"
backgroundColor={colors.headerBackground}
/>
<NavigationContainer
ref={navigationRef}
onReady={() => {
// Store the initial route name
routeNameRef.current = navigationRef.current?.getCurrentRoute()?.name
}}
onStateChange={() => {
// Track screen views manually for React Navigation v7
const previousRouteName = routeNameRef.current
const currentRouteName = navigationRef.current?.getCurrentRoute()?.name
if (previousRouteName !== currentRouteName && currentRouteName) {
// Capture screen view event
posthog.screen(currentRouteName, {
previous_screen: previousRouteName,
})
}
// Update the stored route name
routeNameRef.current = currentRouteName
}}
>
{/*
PostHogProvider is placed INSIDE NavigationContainer for React Navigation v7.
For React Navigation v7, we disable automatic screen capture and handle it
manually via onStateChange above. Touch event autocapture is still enabled.
@see https://posthog.com/docs/libraries/react-native#with-react-navigationnative-and-autocapture
*/}
<PostHogProvider
client={posthog}
autocapture={{
// Disable automatic screen capture for React Navigation v7
// We handle screen tracking manually via NavigationContainer.onStateChange
captureScreens: false,
// Enable touch event autocapture
captureTouches: true,
// Limit which props are captured for touch events
propsToCapture: ['testID'],
// Maximum number of elements captured in touch event hierarchy
maxElementsCaptured: 20,
}}
>
<AuthProvider>
<RootNavigator />
</AuthProvider>
</PostHogProvider>
</NavigationContainer>
</SafeAreaProvider>
)
}
babel.config.js
module.exports = {
presets: ['module:@react-native/babel-preset'],
};
Gemfile
source 'https://rubygems.org'
# You may use http://rbenv.org/ or https://rvm.io/ to install and use this version
ruby ">= 2.6.10"
# Exclude problematic versions of cocoapods and activesupport that causes build failures.
gem 'cocoapods', '>= 1.13', '!= 1.15.0', '!= 1.15.1'
gem 'activesupport', '>= 6.1.7.5', '!= 7.1.0'
gem 'xcodeproj', '< 1.26.0'
gem 'concurrent-ruby', '< 1.3.4'
# Ruby 3.4.0 has removed some libraries from the standard library.
gem 'bigdecimal'
gem 'logger'
gem 'benchmark'
gem 'mutex_m'
index.js
/**
* @format
*/
import { AppRegistry } from 'react-native';
import App from './App';
import { name as appName } from './app.json';
AppRegistry.registerComponent(appName, () => App);
jest.config.js
module.exports = {
preset: 'react-native',
};
metro.config.js
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
/**
* Metro configuration
* https://reactnative.dev/docs/metro
*
* @type {import('@react-native/metro-config').MetroConfig}
*/
const config = {};
module.exports = mergeConfig(getDefaultConfig(__dirname), config);
src/config/posthog.ts
import PostHog from 'posthog-react-native'
import Config from 'react-native-config'
// Environment variables are embedded at build time via react-native-config
// Ensure .env file exists with POSTHOG_PROJECT_TOKEN and POSTHOG_HOST
const projectToken = Config.POSTHOG_PROJECT_TOKEN
const host = Config.POSTHOG_HOST || 'https://us.i.posthog.com'
const isPostHogConfigured = projectToken && projectToken !== 'phc_your_project_token_here'
if (!isPostHogConfigured) {
console.warn(
'PostHog project token not configured. Analytics will be disabled. ' +
'Set POSTHOG_PROJECT_TOKEN in your .env file to enable analytics.'
)
}
/**
* PostHog client instance for bare React Native
*
* Configuration loaded from .env via react-native-config (embedded at build time).
* Required peer dependencies: @react-native-async-storage/async-storage,
* react-native-device-info, react-native-localize
*
* @see https://posthog.com/docs/libraries/react-native
*/
export const posthog = new PostHog(projectToken || 'placeholder_key', {
// PostHog API host (usually 'https://us.i.posthog.com' or 'https://eu.i.posthog.com')
host,
// Enable PostHog only when a project token is configured
disabled: !isPostHogConfigured,
// Capture app lifecycle events:
// - Application Installed, Application Updated
// - Application Opened, Application Became Active, Application Backgrounded
captureAppLifecycleEvents: true,
// Enable debug mode in development for verbose logging
debug: __DEV__,
// Batching: queue events and flush periodically to optimize battery usage
flushAt: 20, // Number of events to queue before sending
flushInterval: 10000, // Interval in ms between periodic flushes
maxBatchSize: 100, // Maximum events per batch
maxQueueSize: 1000, // Maximum queued events (oldest dropped when full)
// Feature flags
preloadFeatureFlags: true, // Load flags on initialization
sendFeatureFlagEvent: true, // Track getFeatureFlag calls for experiments
featureFlagsRequestTimeoutMs: 10000, // Timeout for flag requests (prevents blocking)
// Network settings
requestTimeout: 10000, // General request timeout in ms
fetchRetryCount: 3, // Number of retry attempts for failed requests
fetchRetryDelay: 3000, // Delay between retries in ms
})
// Export helper to check if PostHog is enabled
export const isPostHogEnabled = isPostHogConfigured
src/contexts/AuthContext.tsx
import React, {
createContext,
useContext,
useState,
useEffect,
ReactNode,
useCallback,
} from 'react'
import { usePostHog } from 'posthog-react-native'
import { storage, User } from '../services/storage'
interface AuthContextType {
user: User | null
isLoading: boolean
login: (username: string, password: string) => Promise<boolean>
logout: () => Promise<void>
incrementBurritoConsiderations: () => Promise<void>
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
interface AuthProviderProps {
children: ReactNode
}
/**
* Authentication Provider with PostHog integration
*
* Manages user authentication state and integrates with PostHog for:
* - User identification (posthog.identify)
* - Login/logout event tracking
* - Session reset on logout
*
* @see https://posthog.com/docs/libraries/react-native#identifying-users
*/
export function AuthProvider({ children }: AuthProviderProps) {
const posthog = usePostHog()
const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
// Restore session on app launch
useEffect(() => {
restoreSession()
}, [])
const restoreSession = async () => {
try {
const storedUsername = await storage.getCurrentUser()
if (storedUsername) {
const existingUser = await storage.getUser(storedUsername)
if (existingUser) {
setUser(existingUser)
// Re-identify user in PostHog on session restore
// This ensures events are correctly attributed after app restart
posthog.identify(storedUsername, {
$set: {
username: storedUsername,
},
})
}
}
} catch (error) {
console.error('Failed to restore session:', error)
} finally {
setIsLoading(false)
}
}
const login = useCallback(
async (username: string, password: string): Promise<boolean> => {
// Simple validation (demo app accepts any username/password)
if (!username.trim() || !password.trim()) {
return false
}
try {
// Check if user exists or create new
const existingUser = await storage.getUser(username)
const isNewUser = !existingUser
const userData: User = existingUser || {
username,
burritoConsiderations: 0,
}
// Save user data
await storage.saveUser(userData)
await storage.setCurrentUser(username)
setUser(userData)
// PostHog identify - use username as distinct ID
// $set updates properties every time, $set_once only sets if not already set
// @see https://posthog.com/docs/libraries/react-native#identifying-users
posthog.identify(username, {
$set: {
username: username,
},
$set_once: {
first_login_date: new Date().toISOString(),
},
})
// Capture login event with properties
// @see https://posthog.com/docs/libraries/react-native#capturing-events
posthog.capture('user_logged_in', {
username: username,
is_new_user: isNewUser,
})
return true
} catch (error) {
console.error('Login error:', error)
return false
}
},
[posthog],
)
const logout = useCallback(async () => {
// Capture logout event before reset
posthog.capture('user_logged_out')
// Reset PostHog - clears the current user's distinct ID and anonymous ID
// This should be called when the user logs out
// @see https://posthog.com/docs/libraries/react-native#reset-after-logout
posthog.reset()
await storage.removeCurrentUser()
setUser(null)
}, [posthog])
const incrementBurritoConsiderations = useCallback(async () => {
if (user) {
const updatedUser: User = {
...user,
burritoConsiderations: user.burritoConsiderations + 1,
}
setUser(updatedUser)
await storage.saveUser(updatedUser)
}
}, [user])
return (
<AuthContext.Provider
value={{
user,
isLoading,
login,
logout,
incrementBurritoConsiderations,
}}
>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}
src/navigation/RootNavigator.tsx
import React from 'react'
import { ActivityIndicator, View, StyleSheet } from 'react-native'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import { useAuth } from '../contexts/AuthContext'
import { colors } from '../styles/theme'
import HomeScreen from '../screens/HomeScreen'
import BurritoScreen from '../screens/BurritoScreen'
import ProfileScreen from '../screens/ProfileScreen'
// Type definitions for navigation
export type RootStackParamList = {
Home: undefined
Burrito: undefined
Profile: undefined
}
const Stack = createNativeStackNavigator<RootStackParamList>()
export function RootNavigator() {
const { isLoading } = useAuth()
// Show loading indicator while restoring session
if (isLoading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
)
}
return (
<Stack.Navigator
screenOptions={{
headerStyle: {
backgroundColor: colors.headerBackground,
},
headerTintColor: colors.headerText,
headerTitleStyle: {
fontWeight: 'bold',
},
headerBackTitleVisible: false,
animation: 'slide_from_right',
}}
>
<Stack.Screen
name="Home"
component={HomeScreen}
options={{
title: 'Burrito App',
}}
/>
<Stack.Screen
name="Burrito"
component={BurritoScreen}
options={{
title: 'Burrito Consideration',
}}
/>
<Stack.Screen
name="Profile"
component={ProfileScreen}
options={{
title: 'Profile',
}}
/>
</Stack.Navigator>
)
}
const styles = StyleSheet.create({
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.background,
},
})
src/screens/BurritoScreen.tsx
import React, { useState, useEffect } from 'react'
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'
import { useNavigation } from '@react-navigation/native'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { usePostHog } from 'posthog-react-native'
import { useAuth } from '../contexts/AuthContext'
import { RootStackParamList } from '../navigation/RootNavigator'
import {
colors,
spacing,
typography,
borderRadius,
shadows,
} from '../styles/theme'
type BurritoScreenNavigationProp = NativeStackNavigationProp<
RootStackParamList,
'Burrito'
>
/**
* Burrito Consideration Screen
*
* Demonstrates PostHog event tracking with custom properties.
* Each time the user considers a burrito, an event is captured.
*
* @see https://posthog.com/docs/libraries/react-native#capturing-events
*/
export default function BurritoScreen() {
const { user, incrementBurritoConsiderations } = useAuth()
const navigation = useNavigation<BurritoScreenNavigationProp>()
const posthog = usePostHog()
const [hasConsidered, setHasConsidered] = useState(false)
// Redirect to home if not logged in
useEffect(() => {
if (!user) {
navigation.navigate('Home')
}
}, [user, navigation])
if (!user) {
return null
}
const handleConsideration = async () => {
const newCount = user.burritoConsiderations + 1
// Update state first for immediate feedback
await incrementBurritoConsiderations()
setHasConsidered(true)
// Hide success message after 2 seconds
setTimeout(() => setHasConsidered(false), 2000)
// Capture custom event in PostHog with properties
// We recommend using a [object] [verb] format for event names
// @see https://posthog.com/docs/libraries/react-native#capturing-events
posthog.capture('burrito_considered', {
total_considerations: newCount,
username: user.username,
})
}
return (
<View style={styles.container}>
<View style={styles.card}>
<Text style={styles.title}>Burrito Consideration Zone</Text>
<Text style={styles.text}>
Take a moment to truly consider the potential of burritos.
</Text>
{/*
testID is captured by PostHog autocapture for touch events
This helps identify the button in analytics
@see https://posthog.com/docs/libraries/react-native#autocapture
*/}
<TouchableOpacity
style={styles.burritoButton}
onPress={handleConsideration}
activeOpacity={0.8}
testID="consider-burrito-button"
>
<Text style={styles.burritoButtonText}>Consider Burrito</Text>
</TouchableOpacity>
{hasConsidered && (
<View style={styles.successContainer}>
<Text style={styles.success}>
Thank you for your consideration!
</Text>
<Text style={styles.successCount}>
Count: {user.burritoConsiderations}
</Text>
</View>
)}
<View style={styles.stats}>
<Text style={styles.statsTitle}>Consideration Stats</Text>
<Text style={styles.statsText}>
Total considerations: {user.burritoConsiderations}
</Text>
</View>
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
padding: spacing.md,
},
card: {
backgroundColor: colors.cardBackground,
borderRadius: borderRadius.md,
padding: spacing.lg,
...shadows.md,
},
title: {
fontSize: typography.sizes.xl,
fontWeight: typography.weights.bold,
color: colors.text,
marginBottom: spacing.sm,
},
text: {
fontSize: typography.sizes.md,
color: colors.text,
marginBottom: spacing.lg,
lineHeight: 24,
},
burritoButton: {
backgroundColor: colors.burrito,
borderRadius: borderRadius.sm,
padding: spacing.lg,
alignItems: 'center',
marginVertical: spacing.md,
...shadows.sm,
},
burritoButtonText: {
color: colors.white,
fontSize: typography.sizes.lg,
fontWeight: typography.weights.bold,
},
successContainer: {
alignItems: 'center',
marginVertical: spacing.sm,
},
success: {
color: colors.success,
fontSize: typography.sizes.md,
fontWeight: typography.weights.medium,
},
successCount: {
color: colors.success,
fontSize: typography.sizes.lg,
fontWeight: typography.weights.bold,
marginTop: spacing.xs,
},
stats: {
backgroundColor: colors.statsBackground,
padding: spacing.md,
borderRadius: borderRadius.sm,
marginTop: spacing.lg,
},
statsTitle: {
fontSize: typography.sizes.lg,
fontWeight: typography.weights.semibold,
color: colors.text,
marginBottom: spacing.xs,
},
statsText: {
fontSize: typography.sizes.md,
color: colors.text,
},
})
src/screens/HomeScreen.tsx
import React, { useState } from 'react'
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ScrollView,
KeyboardAvoidingView,
Platform,
} from 'react-native'
import { useNavigation } from '@react-navigation/native'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { useAuth } from '../contexts/AuthContext'
import { RootStackParamList } from '../navigation/RootNavigator'
import {
colors,
spacing,
typography,
borderRadius,
shadows,
} from '../styles/theme'
type HomeScreenNavigationProp = NativeStackNavigationProp<
RootStackParamList,
'Home'
>
export default function HomeScreen() {
const { user, login, logout } = useAuth()
const navigation = useNavigation<HomeScreenNavigationProp>()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const handleSubmit = async () => {
setError('')
if (!username.trim() || !password.trim()) {
setError('Please provide both username and password')
return
}
setIsSubmitting(true)
try {
const success = await login(username, password)
if (success) {
setUsername('')
setPassword('')
} else {
setError('An error occurred during login')
}
} catch {
setError('An error occurred during login')
} finally {
setIsSubmitting(false)
}
}
// Logged in view
if (user) {
return (
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
>
<View style={styles.card}>
<Text style={styles.title}>Welcome back, {user.username}!</Text>
<Text style={styles.text}>
You are logged in. Feel free to explore:
</Text>
<View style={styles.buttonGroup}>
<TouchableOpacity
style={[styles.button, styles.burritoButton]}
onPress={() => navigation.navigate('Burrito')}
activeOpacity={0.8}
>
<Text style={styles.buttonText}>Consider Burritos</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.primaryButton]}
onPress={() => navigation.navigate('Profile')}
activeOpacity={0.8}
>
<Text style={styles.buttonText}>View Profile</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.logoutButton]}
onPress={logout}
activeOpacity={0.8}
>
<Text style={styles.buttonText}>Logout</Text>
</TouchableOpacity>
</View>
</View>
</ScrollView>
)
}
// Login view
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
<View style={styles.card}>
<Text style={styles.title}>Welcome to Burrito Consideration App</Text>
<Text style={styles.text}>
Please sign in to begin your burrito journey
</Text>
<View style={styles.form}>
<Text style={styles.label}>Username:</Text>
<TextInput
style={styles.input}
value={username}
onChangeText={setUsername}
placeholder="Enter any username"
placeholderTextColor={colors.textLight}
autoCapitalize="none"
autoCorrect={false}
autoComplete="username"
editable={!isSubmitting}
/>
<Text style={styles.label}>Password:</Text>
<TextInput
style={styles.input}
value={password}
onChangeText={setPassword}
placeholder="Enter any password"
placeholderTextColor={colors.textLight}
secureTextEntry
autoComplete="password"
editable={!isSubmitting}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<TouchableOpacity
style={[
styles.button,
styles.primaryButton,
isSubmitting && styles.buttonDisabled,
]}
onPress={handleSubmit}
disabled={isSubmitting}
activeOpacity={0.8}
>
<Text style={styles.buttonText}>
{isSubmitting ? 'Signing In...' : 'Sign In'}
</Text>
</TouchableOpacity>
</View>
<Text style={styles.note}>
Note: This is a demo app. Use any username and password to sign in.
</Text>
</View>
</ScrollView>
</KeyboardAvoidingView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
scrollView: {
flex: 1,
backgroundColor: colors.background,
},
scrollContent: {
flexGrow: 1,
padding: spacing.md,
justifyContent: 'center',
},
card: {
backgroundColor: colors.cardBackground,
borderRadius: borderRadius.md,
padding: spacing.lg,
...shadows.md,
},
title: {
fontSize: typography.sizes.xl,
fontWeight: typography.weights.bold,
color: colors.text,
marginBottom: spacing.sm,
},
text: {
fontSize: typography.sizes.md,
color: colors.text,
marginBottom: spacing.md,
lineHeight: 24,
},
form: {
marginTop: spacing.md,
},
label: {
fontSize: typography.sizes.md,
fontWeight: typography.weights.medium,
color: colors.text,
marginBottom: spacing.xs,
},
input: {
backgroundColor: colors.inputBackground,
borderWidth: 1,
borderColor: colors.border,
borderRadius: borderRadius.sm,
padding: spacing.sm,
fontSize: typography.sizes.md,
color: colors.text,
marginBottom: spacing.md,
},
buttonGroup: {
marginTop: spacing.md,
gap: spacing.sm,
},
button: {
borderRadius: borderRadius.sm,
padding: spacing.md,
alignItems: 'center',
marginTop: spacing.sm,
},
primaryButton: {
backgroundColor: colors.primary,
},
burritoButton: {
backgroundColor: colors.burrito,
},
logoutButton: {
backgroundColor: colors.danger,
},
buttonDisabled: {
opacity: 0.6,
},
buttonText: {
color: colors.white,
fontSize: typography.sizes.md,
fontWeight: typography.weights.semibold,
},
error: {
color: colors.danger,
marginBottom: spacing.sm,
fontSize: typography.sizes.sm,
},
note: {
marginTop: spacing.lg,
color: colors.textSecondary,
fontSize: typography.sizes.sm,
textAlign: 'center',
lineHeight: 20,
},
})
src/screens/ProfileScreen.tsx
import React, { useEffect } from 'react'
import { View, Text, TouchableOpacity, StyleSheet, Alert } from 'react-native'
import { useNavigation } from '@react-navigation/native'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { usePostHog } from 'posthog-react-native'
import { useAuth } from '../contexts/AuthContext'
import { RootStackParamList } from '../navigation/RootNavigator'
import {
colors,
spacing,
typography,
borderRadius,
shadows,
} from '../styles/theme'
type ProfileScreenNavigationProp = NativeStackNavigationProp<
RootStackParamList,
'Profile'
>
/**
* Profile Screen
*
* Displays user information and demonstrates PostHog error tracking.
* The test error button shows how to capture exceptions manually.
*
* @see https://posthog.com/docs/libraries/react-native#error-tracking
*/
export default function ProfileScreen() {
const { user } = useAuth()
const navigation = useNavigation<ProfileScreenNavigationProp>()
const posthog = usePostHog()
// Redirect to home if not logged in
useEffect(() => {
if (!user) {
navigation.navigate('Home')
}
}, [user, navigation])
if (!user) {
return null
}
/**
* Triggers a test error and captures it in PostHog
*
* This demonstrates manual exception capture via captureException.
* In production, you would typically set up automatic exception capture
* or use the before_send callback for customization.
*
* @see https://posthog.com/docs/libraries/react-native#error-tracking
*/
const triggerTestError = () => {
try {
throw new Error('Test error for PostHog error tracking')
} catch (err) {
const error = err as Error
posthog.captureException(error, {
username: user.username,
screen: 'Profile',
})
console.error('Captured error:', error)
Alert.alert(
'Error Captured',
'The test error has been sent to PostHog!',
[{ text: 'OK' }],
)
}
}
const getJourneyMessage = () => {
const count = user.burritoConsiderations
if (count === 0) {
return "You haven't considered any burritos yet. Visit the Burrito Consideration page to start!"
} else if (count === 1) {
return "You've considered the burrito potential once. Keep going!"
} else if (count < 5) {
return "You're getting the hang of burrito consideration!"
} else if (count < 10) {
return "You're becoming a burrito consideration expert!"
} else {
return 'You are a true burrito consideration master!'
}
}
return (
<View style={styles.container}>
<View style={styles.card}>
<Text style={styles.title}>User Profile</Text>
<View style={styles.stats}>
<Text style={styles.statsTitle}>Your Information</Text>
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>Username:</Text>
<Text style={styles.infoValue}>{user.username}</Text>
</View>
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>Burrito Considerations:</Text>
<Text style={styles.infoValue}>{user.burritoConsiderations}</Text>
</View>
</View>
{/*
testID is captured by PostHog autocapture for touch events
@see https://posthog.com/docs/libraries/react-native#autocapture
*/}
<TouchableOpacity
style={styles.errorButton}
onPress={triggerTestError}
activeOpacity={0.8}
testID="trigger-error-button"
>
<Text style={styles.buttonText}>Trigger Test Error (for PostHog)</Text>
</TouchableOpacity>
<View style={styles.journey}>
<Text style={styles.journeyTitle}>Your Burrito Journey</Text>
<Text style={styles.journeyText}>{getJourneyMessage()}</Text>
</View>
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
padding: spacing.md,
},
card: {
backgroundColor: colors.cardBackground,
borderRadius: borderRadius.md,
padding: spacing.lg,
...shadows.md,
},
title: {
fontSize: typography.sizes.xl,
fontWeight: typography.weights.bold,
color: colors.text,
marginBottom: spacing.md,
},
stats: {
backgroundColor: colors.statsBackground,
padding: spacing.md,
borderRadius: borderRadius.sm,
},
statsTitle: {
fontSize: typography.sizes.lg,
fontWeight: typography.weights.semibold,
color: colors.text,
marginBottom: spacing.sm,
},
infoRow: {
flexDirection: 'row',
marginBottom: spacing.xs,
},
infoLabel: {
fontSize: typography.sizes.md,
fontWeight: typography.weights.bold,
color: colors.text,
marginRight: spacing.xs,
},
infoValue: {
fontSize: typography.sizes.md,
color: colors.text,
},
errorButton: {
backgroundColor: colors.danger,
borderRadius: borderRadius.sm,
padding: spacing.md,
alignItems: 'center',
marginTop: spacing.lg,
},
buttonText: {
color: colors.white,
fontSize: typography.sizes.md,
fontWeight: typography.weights.semibold,
},
journey: {
marginTop: spacing.lg,
},
journeyTitle: {
fontSize: typography.sizes.lg,
fontWeight: typography.weights.semibold,
color: colors.text,
marginBottom: spacing.sm,
},
journeyText: {
fontSize: typography.sizes.md,
color: colors.text,
lineHeight: 24,
},
})
src/services/storage.ts
import AsyncStorage from '@react-native-async-storage/async-storage'
const CURRENT_USER_KEY = 'currentUser'
const USERS_KEY = 'users'
export interface User {
username: string
burritoConsiderations: number
}
/**
* Storage service for persisting user data
* Uses AsyncStorage (React Native's async key-value storage)
*/
export const storage = {
/**
* Get the currently logged in user's username
*/
getCurrentUser: async (): Promise<string | null> => {
try {
return await AsyncStorage.getItem(CURRENT_USER_KEY)
} catch (error) {
console.error('Error getting current user:', error)
return null
}
},
/**
* Set the currently logged in user's username
*/
setCurrentUser: async (username: string): Promise<void> => {
try {
await AsyncStorage.setItem(CURRENT_USER_KEY, username)
} catch (error) {
console.error('Error setting current user:', error)
}
},
/**
* Remove the current user (logout)
*/
removeCurrentUser: async (): Promise<void> => {
try {
await AsyncStorage.removeItem(CURRENT_USER_KEY)
} catch (error) {
console.error('Error removing current user:', error)
}
},
/**
* Get all stored users
*/
getUsers: async (): Promise<Record<string, User>> => {
try {
const data = await AsyncStorage.getItem(USERS_KEY)
return data ? JSON.parse(data) : {}
} catch (error) {
console.error('Error getting users:', error)
return {}
}
},
/**
* Get a specific user by username
*/
getUser: async (username: string): Promise<User | null> => {
try {
const users = await storage.getUsers()
return users[username] || null
} catch (error) {
console.error('Error getting user:', error)
return null
}
},
/**
* Save a user to storage
*/
saveUser: async (user: User): Promise<void> => {
try {
const users = await storage.getUsers()
users[user.username] = user
await AsyncStorage.setItem(USERS_KEY, JSON.stringify(users))
} catch (error) {
console.error('Error saving user:', error)
}
},
/**
* Clear all stored data (for testing/debugging)
*/
clearAll: async (): Promise<void> => {
try {
await AsyncStorage.multiRemove([CURRENT_USER_KEY, USERS_KEY])
} catch (error) {
console.error('Error clearing storage:', error)
}
},
}
src/styles/theme.ts
/**
* Theme constants for consistent styling across the app
* Matches the color scheme from the TanStack Start web version
*/
export const colors = {
// Primary colors
primary: '#0070f3',
primaryDark: '#0051cc',
// Status colors
success: '#28a745',
successDark: '#218838',
danger: '#dc3545',
dangerDark: '#c82333',
// Feature colors
burrito: '#e07c24',
burritoDark: '#c96a1a',
// Neutral colors
background: '#f5f5f5',
white: '#ffffff',
text: '#333333',
textSecondary: '#666666',
textLight: '#999999',
border: '#dddddd',
borderLight: '#eeeeee',
// Component-specific
statsBackground: '#f8f9fa',
headerBackground: '#333333',
headerText: '#ffffff',
inputBackground: '#ffffff',
cardBackground: '#ffffff',
}
export const spacing = {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
xxl: 48,
}
export const typography = {
sizes: {
xs: 12,
sm: 14,
md: 16,
lg: 18,
xl: 24,
xxl: 32,
},
weights: {
normal: '400' as const,
medium: '500' as const,
semibold: '600' as const,
bold: '700' as const,
},
}
export const borderRadius = {
sm: 4,
md: 8,
lg: 12,
full: 9999,
}
export const shadows = {
sm: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
md: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
lg: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 5,
},
}
src/types/env.d.ts
declare module 'react-native-config' {
export interface NativeConfig {
POSTHOG_PROJECT_TOKEN?: string
POSTHOG_HOST?: string
}
export const Config: NativeConfig
export default Config
}
references/EXAMPLE-react-react-router-6.md
PostHog react-react-router-6 Example Project
Repository: https://github.com/PostHog/context-mill Path: example-apps/react-react-router-6
README.md
PostHog React Router 6 example
This is a React Router 6 example demonstrating PostHog integration with product analytics, session replay, feature flags, and error tracking.
Features
- Product Analytics: Track user events and behaviors
- Session Replay: Record and replay user sessions
- Error Tracking: Capture and track errors
- User Authentication: Demo login system with PostHog user identification
- Client-side Tracking: Examples of client-side tracking methods
Getting Started
1. Install Dependencies
npm install
# or
pnpm install2. Configure Environment Variables
Create a .env file in the root directory:
VITE_PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.comGet your PostHog project token from your PostHog project settings.
3. Run the Development Server
npm run dev
# or
pnpm devOpen http://localhost:5173 with your browser to see the app.
Project Structure
src/
├── components/
│ └── Header.jsx # Navigation header with auth state
├── contexts/
│ └── AuthContext.jsx # Authentication context with PostHog integration
├── routes/
│ ├── Root.jsx # Root route component
│ ├── Home.jsx # Home/Login page
│ ├── Burrito.jsx # Demo feature page with event tracking
│ └── Profile.jsx # User profile with error tracking demo
├── main.jsx # App entry point with PostHog initialization
└── globals.css # Global stylesKey Integration Points
Client-side initialization (main.jsx)
import posthog from "posthog-js"
import { PostHogProvider } from "@posthog/react"
posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN, {
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
defaults: '2026-01-30',
});
<PostHogProvider client={posthog}>
<RouterProvider router={router} />
</PostHogProvider>User identification (AuthContext.jsx)
The user is identified when the user logs in on the client-side.
posthog.identify(username);
posthog.capture('user_logged_in');The session and distinct ID can be passed to the backend by including the X-POSTHOG-SESSION-ID and X-POSTHOG-DISTINCT-ID headers.
You should use these headers in the backend to identify events.
Important: do not identify users on the server-side.
Event tracking (Burrito.jsx)
posthog?.capture('burrito_considered', {
total_considerations: updatedUser.burritoConsiderations,
username: user.username,
});Error tracking
Note: The app can be wrapped with PostHogErrorBoundary from @posthog/react (imported in main.jsx) to automatically capture unhandled React errors. Manual error capture can be added to components using posthog?.captureException(err).
Learn More
.env.example
VITE_PUBLIC_POSTHOG_PROJECT_TOKEN=
VITE_PUBLIC_POSTHOG_HOST=
PROJECT_ID=index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>react-react-router-6</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
src/components/Header.jsx
import { Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { usePostHog } from '@posthog/react';
export default function Header() {
const { user, logout } = useAuth();
const posthog = usePostHog();
const handleLogout = () => {
if (user) {
posthog.capture('user_logged_out', {
username: user.username,
distinct_id: user.username,
});
}
logout();
};
return (
<header className="header">
<div className="header-container">
<nav>
<Link to="/">Home</Link>
{user && (
<>
<Link to="/burrito">Burrito Consideration</Link>
<Link to="/profile">Profile</Link>
</>
)}
</nav>
<div className="user-section">
{user ? (
<>
<span>Welcome, {user.username}!</span>
<button onClick={handleLogout} className="btn-logout">
Logout
</button>
</>
) : (
<span>Not logged in</span>
)}
</div>
</div>
</header>
);
}
src/contexts/AuthContext.jsx
import { createContext, useContext, useState } from 'react';
const AuthContext = createContext(undefined);
const users = new Map();
export function AuthProvider({ children }) {
const [user, setUser] = useState(() => {
if (typeof window === 'undefined') return null;
const storedUsername = localStorage.getItem('currentUser');
if (storedUsername) {
const existingUser = users.get(storedUsername);
if (existingUser) {
return existingUser;
}
}
return null;
});
const login = async (username, password) => {
if (!username || !password) {
return false;
}
let localUser = users.get(username);
if (!localUser) {
localUser = {
username,
burritoConsiderations: 0
};
users.set(username, localUser);
}
setUser(localUser);
localStorage.setItem('currentUser', username);
return true;
};
const logout = () => {
setUser(null);
localStorage.removeItem('currentUser');
};
const setUserState = (newUser) => {
setUser(newUser);
users.set(newUser.username, newUser);
};
return (
<AuthContext.Provider value={{ user, login, logout, setUser: setUserState }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
src/main.jsx
import './globals.css'
import { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Root from './routes/Root';
import Home from './routes/Home';
import Burrito from './routes/Burrito';
import Profile from './routes/Profile';
import posthog from 'posthog-js';
import { PostHogErrorBoundary, PostHogProvider } from '@posthog/react'
// Initialize PostHog
posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN, {
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
defaults: '2026-01-30',
});
const root = document.getElementById("root");
if (!root) throw new Error("Root element not found");
ReactDOM.createRoot(root).render(
<StrictMode>
<PostHogProvider client={posthog}>
<PostHogErrorBoundary>
<BrowserRouter>
<Routes>
<Route path="/" element={<Root />}>
<Route index element={<Home />} />
<Route path="burrito" element={<Burrito />} />
<Route path="profile" element={<Profile />} />
</Route>
</Routes>
</BrowserRouter>
</PostHogErrorBoundary>
</PostHogProvider>
</StrictMode>,
);
src/routes/Burrito.jsx
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { usePostHog } from '@posthog/react';
export default function BurritoPage() {
const { user, setUser } = useAuth();
const navigate = useNavigate();
const [hasConsidered, setHasConsidered] = useState(false);
const posthog = usePostHog()
useEffect(() => {
if (!user) {
navigate('/');
}
}, [user, navigate]);
if (!user) {
return null;
}
const handleConsideration = () => {
const updatedUser = {
...user,
burritoConsiderations: user.burritoConsiderations + 1
};
setUser(updatedUser);
setHasConsidered(true);
setTimeout(() => setHasConsidered(false), 2000);
posthog.capture('burrito_considered', {
total_considerations: updatedUser.burritoConsiderations,
username: user.username,
distinct_id: user.username,
});
};
return (
<div className="container">
<h1>Burrito consideration zone</h1>
<p>Take a moment to truly consider the potential of burritos.</p>
<div style={{ textAlign: 'center' }}>
<button
onClick={handleConsideration}
className="btn-burrito"
>
I have considered the burrito potential
</button>
{hasConsidered && (
<p className="success">
Thank you for your consideration! Count: {user.burritoConsiderations}
</p>
)}
</div>
<div className="stats">
<h3>Consideration stats</h3>
<p>Total considerations: {user.burritoConsiderations}</p>
</div>
</div>
);
}
src/routes/Home.jsx
import { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { usePostHog } from '@posthog/react';
export default function Home() {
const { user, login } = useAuth();
const posthog = usePostHog();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
const success = await login(username, password);
if (success) {
posthog.capture('user_logged_in', {
username: username,
distinct_id: username,
});
setUsername('');
setPassword('');
} else {
setError('Please provide both username and password');
}
};
if (user) {
return (
<div className="container">
<h1>Welcome back, {user.username}!</h1>
<p>You are logged in. Feel free to explore:</p>
<ul>
<li>Consider the potential of burritos</li>
<li>View your profile and statistics</li>
</ul>
</div>
);
}
return (
<div className="container">
<h1>Welcome to Burrito Consideration App</h1>
<p>Please sign in to begin your burrito journey</p>
<form onSubmit={handleSubmit} className="form">
<div className="form-group">
<label htmlFor="username">Username:</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter any username"
/>
</div>
<div className="form-group">
<label htmlFor="password">Password:</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter any password"
/>
</div>
{error && <p className="error">{error}</p>}
<button type="submit" className="btn-primary">Sign In</button>
</form>
<p className="note">
Note: This is a demo app. Use any username and password to sign in.
</p>
</div>
);
}
src/routes/Profile.jsx
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
export default function ProfilePage() {
const { user } = useAuth();
const navigate = useNavigate();
useEffect(() => {
if (!user) {
navigate('/');
}
}, [user, navigate]);
if (!user) {
return null;
}
return (
<div className="container">
<h1>User Profile</h1>
<div className="stats">
<h2>Your Information</h2>
<p><strong>Username:</strong> {user.username}</p>
<p><strong>Burrito Considerations:</strong> {user.burritoConsiderations}</p>
</div>
<div style={{ marginTop: '2rem' }}>
<h3>Your Burrito Journey</h3>
{user.burritoConsiderations === 0 ? (
<p>You haven't considered any burritos yet. Visit the Burrito Consideration page to start!</p>
) : user.burritoConsiderations === 1 ? (
<p>You've considered the burrito potential once. Keep going!</p>
) : user.burritoConsiderations < 5 ? (
<p>You're getting the hang of burrito consideration!</p>
) : user.burritoConsiderations < 10 ? (
<p>You're becoming a burrito consideration expert!</p>
) : (
<p>You are a true burrito consideration master! 🌯</p>
)}
</div>
</div>
);
}
src/routes/Root.jsx
import { Outlet } from "react-router-dom";
import Header from "../components/Header";
import { AuthProvider } from "../contexts/AuthContext";
export default function Root() {
return (
<AuthProvider>
<Header />
<main>
<Outlet />
</main>
</AuthProvider>
);
}
vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})
references/EXAMPLE-react-react-router-7-data.md
PostHog react-react-router-7-data Example Project
Repository: https://github.com/PostHog/context-mill Path: example-apps/react-react-router-7-data
README.md
PostHog React Router 7 Data Mode example
This is a React Router 7 Data Mode example demonstrating PostHog integration with product analytics, session replay, feature flags, and error tracking.
Features
- Product Analytics: Track user events and behaviors
- Session Replay: Record and replay user sessions
- Error Tracking: Capture and track errors
- User Authentication: Demo login system with PostHog user identification
- Client-side Tracking: Examples of client-side tracking methods
- Data Mode: React Router 7 data mode with client-side routing
Getting Started
1. Install Dependencies
npm install
# or
pnpm install2. Configure Environment Variables
Create a .env file in the root directory:
VITE_PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.comGet your PostHog project token from your PostHog project settings.
3. Run the Development Server
npm run dev
# or
pnpm devOpen http://localhost:5173 with your browser to see the app.
Project Structure
app/
├── components/
│ └── Header.tsx # Navigation header with auth state
├── contexts/
│ └── AuthContext.tsx # Authentication context with PostHog integration
├── routes/
│ ├── home.tsx # Home/Login page
│ ├── burrito.tsx # Demo feature page with event tracking
│ └── profile.tsx # User profile with error tracking demo
├── root.tsx # Root route with error boundary
└── routes.tsx # Route configuration
index.tsx # App entry point with PostHog initializationKey Integration Points
Client-side initialization (index.tsx)
import posthog from 'posthog-js';
import { PostHogProvider } from '@posthog/react'
posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN, {
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
defaults: '2026-01-30',
});
<PostHogProvider client={posthog}>
<RouterProvider router={router} />
</PostHogProvider>User identification (AuthContext.tsx)
The user is identified when the user logs in on the client-side.
posthog.identify(username);
posthog.capture('user_logged_in');The session and distinct ID can be passed to the backend by including the X-POSTHOG-SESSION-ID and X-POSTHOG-DISTINCT-ID headers.
You should use these headers in the backend to identify events.
Important: do not identify users on the server-side.
Event tracking (burrito.tsx)
posthog?.capture('burrito_considered', {
total_considerations: updatedUser.burritoConsiderations,
username: user.username,
});Error tracking (root.tsx, profile.tsx)
Errors are captured in two ways:
- Error boundary - The
RootErrorBoundaryinroot.tsxautomatically captures unhandled React Router errors:
export function RootErrorBoundary() {
const error = useRouteError();
const posthog = usePostHog();
if (error) {
posthog.captureException(error);
}
// ... error UI
}- Manual error capture in components (profile.tsx):
posthog?.captureException(err);Learn More
.env.example
VITE_PUBLIC_POSTHOG_PROJECT_TOKEN=
VITE_PUBLIC_POSTHOG_HOST=
PROJECT_ID=.react-router/types/+future.ts
// Generated by React Router
import "react-router";
declare module "react-router" {
interface Future {
v8_middleware: false
}
}.react-router/types/+routes.ts
// Generated by React Router
import "react-router"
declare module "react-router" {
interface Register {
pages: Pages
routeFiles: RouteFiles
routeModules: RouteModules
}
}
type Pages = {
"/": {
params: {};
};
};
type RouteFiles = {
"root.tsx": {
id: "root";
page: "/";
};
"routes/home.tsx": {
id: "routes/home";
page: "/";
};
};
type RouteModules = {
"root": typeof import("./app/root.tsx");
"routes/home": typeof import("./app/routes/home.tsx");
};.react-router/types/app/+types/root.ts
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../root.js")
type Info = GetInfo<{
file: "root.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../root.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}.react-router/types/app/routes/+types/home.ts
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../home.js")
type Info = GetInfo<{
file: "routes/home.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/home";
module: typeof import("../home.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}app/components/Header.tsx
import { Link } from 'react-router';
import { useAuth } from '../contexts/AuthContext';
import { usePostHog } from '@posthog/react';
export default function Header() {
const { user, logout } = useAuth();
const posthog = usePostHog();
const handleLogout = () => {
posthog?.capture('user_logged_out');
posthog?.reset();
logout();
};
return (
<header className="header">
<div className="header-container">
<nav>
<Link to="/">Home</Link>
{user && (
<>
<Link to="/burrito">Burrito Consideration</Link>
<Link to="/profile">Profile</Link>
</>
)}
</nav>
<div className="user-section">
{user ? (
<>
<span>Welcome, {user.username}!</span>
<button onClick={handleLogout} className="btn-logout">
Logout
</button>
</>
) : (
<span>Not logged in</span>
)}
</div>
</div>
</header>
);
}
app/contexts/AuthContext.tsx
import { usePostHog } from '@posthog/react';
import { createContext, useContext, useState, type ReactNode } from 'react';
interface User {
username: string;
burritoConsiderations: number;
}
interface AuthContextType {
user: User | null;
login: (username: string, password: string) => Promise<boolean>;
logout: () => void;
setUser: (user: User) => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const users: Map<string, User> = new Map();
export function AuthProvider({ children }: { children: ReactNode }) {
const posthog = usePostHog();
const [user, setUser] = useState<User | null>(() => {
if (typeof window === 'undefined') return null;
const storedUsername = localStorage.getItem('currentUser');
if (storedUsername) {
const existingUser = users.get(storedUsername);
if (existingUser) {
return existingUser;
}
}
return null;
});
const login = async (username: string, password: string): Promise<boolean> => {
// Client-side only fake auth - no server calls
if (!username || !password) {
return false;
}
let localUser = users.get(username);
if (!localUser) {
localUser = {
username,
burritoConsiderations: 0
};
users.set(username, localUser);
}
setUser(localUser);
localStorage.setItem('currentUser', username);
// Identifying the user once on login/sign up is enough.
posthog.identify(username);
posthog.capture('user_logged_in');
return true;
};
const logout = () => {
setUser(null);
localStorage.removeItem('currentUser');
};
const setUserState = (newUser: User) => {
setUser(newUser);
users.set(newUser.username, newUser);
};
return (
<AuthContext.Provider value={{ user, login, logout, setUser: setUserState }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
app/root.tsx
import { Outlet, useRouteError, isRouteErrorResponse } from "react-router";
import Header from "./components/Header";
import { AuthProvider } from "./contexts/AuthContext";
import "./globals.css";
import { usePostHog } from "@posthog/react";
export default function Root() {
return (
<AuthProvider>
<Header />
<main>
<Outlet />
</main>
</AuthProvider>
);
}
export function RootErrorBoundary() {
const error = useRouteError();
const posthog = usePostHog();
if (error) {
posthog.captureException(error);
}
if (isRouteErrorResponse(error)) {
return (
<>
<h1>
{error.status} {error.statusText}
</h1>
<p>{error.data}</p>
</>
);
} else if (error instanceof Error) {
return (
<div>
<h1>Error</h1>
<p>{error.message}</p>
<p>The stack trace is:</p>
<pre>{error.stack}</pre>
</div>
);
} else {
return <h1>Unknown Error</h1>;
}
}
app/routes.tsx
import React from "react";
import type { RouteObject } from "react-router";
import Root, { RootErrorBoundary } from "./root";
import Home from "./routes/home";
import Burrito from "./routes/burrito";
import Profile from "./routes/profile";
export const routes: RouteObject[] = [
{
path: "/",
element: <Root />,
ErrorBoundary: RootErrorBoundary,
children: [
{
index: true,
element: <Home />,
},
{
path: "burrito",
element: <Burrito />,
},
{
path: "profile",
element: <Profile />,
},
],
},
];
app/routes/burrito.tsx
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router';
import { useAuth } from '../contexts/AuthContext';
import { usePostHog } from '@posthog/react';
export default function BurritoPage() {
const { user, setUser } = useAuth();
const navigate = useNavigate();
const posthog = usePostHog();
const [hasConsidered, setHasConsidered] = useState(false);
useEffect(() => {
if (!user) {
navigate('/');
}
}, [user, navigate]);
if (!user) {
return null;
}
const handleConsideration = () => {
// Client-side only - no server calls
const updatedUser = {
...user,
burritoConsiderations: user.burritoConsiderations + 1
};
setUser(updatedUser);
setHasConsidered(true);
setTimeout(() => setHasConsidered(false), 2000);
// Capture burrito consideration event
posthog?.capture('burrito_considered', {
total_considerations: updatedUser.burritoConsiderations,
username: user.username,
});
};
return (
<div className="container">
<h1>Burrito consideration zone</h1>
<p>Take a moment to truly consider the potential of burritos.</p>
<div style={{ textAlign: 'center' }}>
<button
onClick={handleConsideration}
className="btn-burrito"
>
I have considered the burrito potential
</button>
{hasConsidered && (
<p className="success">
Thank you for your consideration! Count: {user.burritoConsiderations}
</p>
)}
</div>
<div className="stats">
<h3>Consideration stats</h3>
<p>Total considerations: {user.burritoConsiderations}</p>
</div>
</div>
);
}
app/routes/home.tsx
import { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
export default function Home() {
const { user, login } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
const success = await login(username, password);
if (success) {
setUsername('');
setPassword('');
} else {
setError('Please provide both username and password');
}
};
if (user) {
return (
<div className="container">
<h1>Welcome back, {user.username}!</h1>
<p>You are logged in. Feel free to explore:</p>
<ul>
<li>Consider the potential of burritos</li>
<li>View your profile and statistics</li>
</ul>
</div>
);
}
return (
<div className="container">
<h1>Welcome to Burrito Consideration App</h1>
<p>Please sign in to begin your burrito journey</p>
<form onSubmit={handleSubmit} className="form">
<div className="form-group">
<label htmlFor="username">Username:</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter any username"
/>
</div>
<div className="form-group">
<label htmlFor="password">Password:</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter any password"
/>
</div>
{error && <p className="error">{error}</p>}
<button type="submit" className="btn-primary">Sign In</button>
</form>
<p className="note">
Note: This is a demo app. Use any username and password to sign in.
</p>
</div>
);
}
app/routes/profile.tsx
import { useEffect } from 'react';
import { useNavigate } from 'react-router';
import { useAuth } from '../contexts/AuthContext';
import { usePostHog } from '@posthog/react';
export default function ProfilePage() {
const { user } = useAuth();
const navigate = useNavigate();
const posthog = usePostHog();
useEffect(() => {
if (!user) {
navigate('/');
}
}, [user, navigate]);
if (!user) {
return null;
}
const triggerTestError = () => {
try {
throw new Error('Test error for PostHog error tracking');
} catch (err) {
posthog?.captureException(err);
console.error('Captured error:', err);
alert('Error captured and sent to PostHog!');
}
};
return (
<div className="container">
<h1>User Profile</h1>
<div className="stats">
<h2>Your Information</h2>
<p><strong>Username:</strong> {user.username}</p>
<p><strong>Burrito Considerations:</strong> {user.burritoConsiderations}</p>
</div>
<div style={{ marginTop: '2rem' }}>
<button onClick={triggerTestError} className="btn-primary" style={{ backgroundColor: '#dc3545' }}>
Trigger Test Error (for PostHog)
</button>
</div>
<div style={{ marginTop: '2rem' }}>
<h3>Your Burrito Journey</h3>
{user.burritoConsiderations === 0 ? (
<p>You haven't considered any burritos yet. Visit the Burrito Consideration page to start!</p>
) : user.burritoConsiderations === 1 ? (
<p>You've considered the burrito potential once. Keep going!</p>
) : user.burritoConsiderations < 5 ? (
<p>You're getting the hang of burrito consideration!</p>
) : user.burritoConsiderations < 10 ? (
<p>You're becoming a burrito consideration expert!</p>
) : (
<p>You are a true burrito consideration master! 🌯</p>
)}
</div>
</div>
);
}
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React Router 7 Data Mode</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>
index.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { createBrowserRouter, RouterProvider } from "react-router";
import Root, { RootErrorBoundary } from "./app/root";
import Home from "./app/routes/home";
import Burrito from "./app/routes/burrito";
import Profile from "./app/routes/profile";
import posthog from 'posthog-js';
import { PostHogProvider } from '@posthog/react'
posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN, {
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
defaults: '2026-01-30',
});
const router = createBrowserRouter([
{
path: "/",
Component: Root,
ErrorBoundary: RootErrorBoundary,
children: [
{
index: true,
Component: Home,
},
{
path: "burrito",
Component: Burrito,
},
{
path: "profile",
Component: Profile,
},
],
},
]);
createRoot(document.getElementById("root")!).render(
<StrictMode>
<PostHogProvider client={posthog}>
<RouterProvider router={router} />
</PostHogProvider>
</StrictMode>
);
vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [react(), tsconfigPaths()],
});
references/EXAMPLE-react-react-router-7-declarative.md
PostHog react-react-router-7-declarative Example Project
Repository: https://github.com/PostHog/context-mill Path: example-apps/react-react-router-7-declarative
README.md
PostHog React Router 7 Declarative example
This is a React Router 7 Declarative example demonstrating PostHog integration with product analytics, session replay, feature flags, and error tracking.
Features
- Product Analytics: Track user events and behaviors
- Session Replay: Record and replay user sessions
- Error Tracking: Capture and track errors
- User Authentication: Demo login system with PostHog user identification
- Client-side Tracking: Examples of client-side tracking methods
- Declarative Routing: React Router 7 declarative routing configuration
Getting Started
1. Install Dependencies
npm install
# or
pnpm install2. Configure Environment Variables
Create a .env file in the root directory:
VITE_PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.comGet your PostHog project token from your PostHog project settings.
3. Run the Development Server
npm run dev
# or
pnpm devOpen http://localhost:5173 with your browser to see the app.
Project Structure
src/
├── components/
│ └── Header.tsx # Navigation header with auth state
├── contexts/
│ └── AuthContext.tsx # Authentication context with PostHog integration
├── routes/
│ ├── Root.tsx # Root route component
│ ├── Home.tsx # Home/Login page
│ ├── Burrito.tsx # Demo feature page with event tracking
│ └── Profile.tsx # User profile with error tracking demo
├── main.tsx # App entry point with PostHog initialization
└── globals.css # Global stylesKey Integration Points
Client-side initialization (main.tsx)
import posthog from "posthog-js"
import { PostHogProvider } from "@posthog/react"
posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN, {
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
defaults: '2026-01-30',
});
<PostHogProvider client={posthog}>
<RouterProvider router={router} />
</PostHogProvider>User identification (AuthContext.tsx)
The user is identified when the user logs in on the client-side.
posthog.identify(username);
posthog.capture('user_logged_in');The session and distinct ID can be passed to the backend by including the X-POSTHOG-SESSION-ID and X-POSTHOG-DISTINCT-ID headers.
You should use these headers in the backend to identify events.
Important: Identify the user once on the client-side to consolidate the new user ID and the automatically generated anonymous ID. Don't identify again on the server-side.
Event tracking (Burrito.tsx)
posthog?.capture('burrito_considered', {
total_considerations: updatedUser.burritoConsiderations,
username: user.username,
});Error tracking (PostHogErrorBoundary)
The app is wrapped with PostHogErrorBoundary from @posthog/react in main.tsx to automatically capture unhandled React errors:
<PostHogProvider client={posthog}>
<PostHogErrorBoundary>
{/* app content */}
</PostHogErrorBoundary>
</PostHogProvider>Manual error capture can also be added to components using posthog?.captureException(err).
Learn More
.env.example
VITE_PUBLIC_POSTHOG_PROJECT_TOKEN=
VITE_PUBLIC_POSTHOG_HOST=
PROJECT_ID=index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>react-react-router-7-declarative</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
src/App.tsx
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
function App() {
const [count, setCount] = useState(0)
return (
<>
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
}
export default App
src/components/Header.tsx
import { Link } from 'react-router';
import { useAuth } from '../contexts/AuthContext';
import { usePostHog } from '@posthog/react';
export default function Header() {
const { user, logout } = useAuth();
const posthog = usePostHog();
const handleLogout = () => {
posthog?.reset();
logout();
};
return (
<header className="header">
<div className="header-container">
<nav>
<Link to="/">Home</Link>
{user && (
<>
<Link to="/burrito">Burrito Consideration</Link>
<Link to="/profile">Profile</Link>
</>
)}
</nav>
<div className="user-section">
{user ? (
<>
<span>Welcome, {user.username}!</span>
<button onClick={handleLogout} className="btn-logout">
Logout
</button>
</>
) : (
<span>Not logged in</span>
)}
</div>
</div>
</header>
);
}
src/contexts/AuthContext.tsx
import { usePostHog } from '@posthog/react';
import { createContext, useContext, useState, type ReactNode } from 'react';
interface User {
username: string;
burritoConsiderations: number;
}
interface AuthContextType {
user: User | null;
login: (username: string, password: string) => Promise<boolean>;
logout: () => void;
setUser: (user: User) => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const users: Map<string, User> = new Map();
export function AuthProvider({ children }: { children: ReactNode }) {
const posthog = usePostHog();
const [user, setUser] = useState<User | null>(() => {
if (typeof window === 'undefined') return null;
const storedUsername = localStorage.getItem('currentUser');
if (storedUsername) {
const existingUser = users.get(storedUsername);
if (existingUser) {
return existingUser;
}
}
return null;
});
const login = async (username: string, password: string): Promise<boolean> => {
// Client-side only fake auth - no server calls
if (!username || !password) {
return false;
}
let localUser = users.get(username);
if (!localUser) {
localUser = {
username,
burritoConsiderations: 0
};
users.set(username, localUser);
}
setUser(localUser);
localStorage.setItem('currentUser', username);
// Identifying the user once on login/sign up is enough.
posthog.identify(username);
posthog.capture('user_logged_in');
return true;
};
const logout = () => {
setUser(null);
localStorage.removeItem('currentUser');
};
const setUserState = (newUser: User) => {
setUser(newUser);
users.set(newUser.username, newUser);
};
return (
<AuthContext.Provider value={{ user, login, logout, setUser: setUserState }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
src/main.tsx
import './globals.css'
import { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter, Routes, Route } from "react-router";
import Root from './routes/Root';
import Home from './routes/Home';
import Burrito from './routes/Burrito';
import Profile from './routes/Profile';
import posthog from 'posthog-js';
import { PostHogErrorBoundary, PostHogProvider } from '@posthog/react'
posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN, {
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
defaults: '2026-01-30',
});
const root = document.getElementById("root");
if (!root) throw new Error("Root element not found");
ReactDOM.createRoot(root).render(
<StrictMode>
<PostHogProvider client={posthog}>
<PostHogErrorBoundary>
<BrowserRouter>
<Routes>
<Route path="/" element={<Root />}>
<Route index element={<Home />} />
<Route path="burrito" element={<Burrito />} />
<Route path="profile" element={<Profile />} />
</Route>
</Routes>
</BrowserRouter>
</PostHogErrorBoundary>
</PostHogProvider>
</StrictMode>,
);
src/routes/Burrito.tsx
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router';
import { useAuth } from '../contexts/AuthContext';
export default function BurritoPage() {
const { user, setUser } = useAuth();
const navigate = useNavigate();
const [hasConsidered, setHasConsidered] = useState(false);
useEffect(() => {
if (!user) {
navigate('/');
}
}, [user, navigate]);
if (!user) {
return null;
}
const handleConsideration = () => {
// Client-side only - no server calls
const updatedUser = {
...user,
burritoConsiderations: user.burritoConsiderations + 1
};
setUser(updatedUser);
setHasConsidered(true);
setTimeout(() => setHasConsidered(false), 2000);
};
return (
<div className="container">
<h1>Burrito consideration zone</h1>
<p>Take a moment to truly consider the potential of burritos.</p>
<div style={{ textAlign: 'center' }}>
<button
onClick={handleConsideration}
className="btn-burrito"
>
I have considered the burrito potential
</button>
{hasConsidered && (
<p className="success">
Thank you for your consideration! Count: {user.burritoConsiderations}
</p>
)}
</div>
<div className="stats">
<h3>Consideration stats</h3>
<p>Total considerations: {user.burritoConsiderations}</p>
</div>
</div>
);
}
src/routes/Home.tsx
import { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
export default function Home() {
const { user, login } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
const success = await login(username, password);
if (success) {
setUsername('');
setPassword('');
} else {
setError('Please provide both username and password');
}
};
if (user) {
return (
<div className="container">
<h1>Welcome back, {user.username}!</h1>
<p>You are logged in. Feel free to explore:</p>
<ul>
<li>Consider the potential of burritos</li>
<li>View your profile and statistics</li>
</ul>
</div>
);
}
return (
<div className="container">
<h1>Welcome to Burrito Consideration App</h1>
<p>Please sign in to begin your burrito journey</p>
<form onSubmit={handleSubmit} className="form">
<div className="form-group">
<label htmlFor="username">Username:</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter any username"
/>
</div>
<div className="form-group">
<label htmlFor="password">Password:</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter any password"
/>
</div>
{error && <p className="error">{error}</p>}
<button type="submit" className="btn-primary">Sign In</button>
</form>
<p className="note">
Note: This is a demo app. Use any username and password to sign in.
</p>
</div>
);
}
src/routes/Profile.tsx
import { useEffect } from 'react';
import { useNavigate } from 'react-router';
import { useAuth } from '../contexts/AuthContext';
export default function ProfilePage() {
const { user } = useAuth();
const navigate = useNavigate();
useEffect(() => {
if (!user) {
navigate('/');
}
}, [user, navigate]);
if (!user) {
return null;
}
return (
<div className="container">
<h1>User Profile</h1>
<div className="stats">
<h2>Your Information</h2>
<p><strong>Username:</strong> {user.username}</p>
<p><strong>Burrito Considerations:</strong> {user.burritoConsiderations}</p>
</div>
<div style={{ marginTop: '2rem' }}>
<h3>Your Burrito Journey</h3>
{user.burritoConsiderations === 0 ? (
<p>You haven't considered any burritos yet. Visit the Burrito Consideration page to start!</p>
) : user.burritoConsiderations === 1 ? (
<p>You've considered the burrito potential once. Keep going!</p>
) : user.burritoConsiderations < 5 ? (
<p>You're getting the hang of burrito consideration!</p>
) : user.burritoConsiderations < 10 ? (
<p>You're becoming a burrito consideration expert!</p>
) : (
<p>You are a true burrito consideration master! 🌯</p>
)}
</div>
</div>
);
}
src/routes/Root.tsx
import { Outlet } from "react-router";
import Header from "../components/Header";
import { AuthProvider } from "../contexts/AuthContext";
export default function Root() {
return (
<AuthProvider>
<Header />
<main>
<Outlet />
</main>
</AuthProvider>
);
}src/vite-env.d.ts
/// <reference types="vite/client" />
vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
dedupe: ['react', 'react-dom'],
},
})
references/EXAMPLE-react-react-router-7-framework.md
PostHog react-react-router-7-framework Example Project
Repository: https://github.com/PostHog/context-mill Path: example-apps/react-react-router-7-framework
README.md
PostHog React Router 7 Framework example
This is a React Router 7 Framework example demonstrating PostHog integration with product analytics, session replay, feature flags, and error tracking.
Features
- Product Analytics: Track user events and behaviors
- Session Replay: Record and replay user sessions
- Error Tracking: Capture and track errors
- User Authentication: Demo login system with PostHog user identification
- Server-side & Client-side Tracking: Examples of both tracking methods
- SSR Support: Server-side rendering with React Router 7 Framework
Getting Started
1. Install Dependencies
npm install
# or
pnpm install2. Configure Environment Variables
Create a .env file in the root directory:
VITE_PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.comGet your PostHog project token from your PostHog project settings.
3. Run the Development Server
npm run dev
# or
pnpm devOpen http://localhost:5173 with your browser to see the app.
Project Structure
app/
├── components/
│ └── Header.tsx # Navigation header with auth state
├── contexts/
│ └── AuthContext.tsx # Authentication context
├── lib/
│ ├── posthog-middleware.ts # Server-side PostHog middleware
│ └── db.ts # Database utilities
├── routes/
│ ├── home.tsx # Home/Login page
│ ├── burrito.tsx # Demo feature page with event tracking
│ ├── profile.tsx # User profile with error tracking demo
│ ├── api.auth.login.ts # Login API with server-side tracking
│ └── api.burrito.consider.ts # Burrito API with server-side tracking
├── entry.client.tsx # Client entry with PostHog initialization
├── entry.server.tsx # Server entry
└── root.tsx # Root route with error boundaryKey Integration Points
Client-side initialization (entry.client.tsx)
import posthog from 'posthog-js';
import { PostHogProvider } from '@posthog/react'
posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN, {
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
defaults: '2026-01-30',
tracing_headers: [ window.location.hostname ],
});
<PostHogProvider client={posthog}>
<HydratedRouter />
</PostHogProvider>User identification (home.tsx)
The user is identified when the user logs in on the client-side.
posthog?.identify(username, {
username: username,
});
posthog?.capture('user_logged_in', {
username: username,
});The session and distinct ID are automatically passed to the backend via the X-POSTHOG-SESSION-ID and X-POSTHOG-DISTINCT-ID headers because we set the tracing_headers option in the PostHog initialization.
Important: do not identify users on the server-side.
Server-side middleware (posthog-middleware.ts)
The PostHog middleware creates a server-side PostHog client for each request and extracts session and user context from request headers:
export const posthogMiddleware: Route.MiddlewareFunction = async ({ request, context }, next) => {
const posthog = new PostHog(process.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN!, {
host: process.env.VITE_PUBLIC_POSTHOG_HOST!,
flushAt: 1,
flushInterval: 0,
});
const sessionId = request.headers.get('X-POSTHOG-SESSION-ID');
const distinctId = request.headers.get('X-POSTHOG-DISTINCT-ID');
context.posthog = posthog;
const response = await posthog.withContext(
{ sessionId: sessionId ?? undefined, distinctId: distinctId ?? undefined },
next
);
await posthog.shutdown().catch(() => {});
return response;
};Key Points:
- Creates a new PostHog Node client for each request
- Extracts
sessionIdanddistinctIdfrom request headers (automatically set by the client-side SDK) - Sets the PostHog client on the request context for use in route handlers
- Uses
withContext()to associate server-side events with the correct session/user - Properly shuts down the client after each request
Event tracking (burrito.tsx)
posthog?.capture('burrito_considered', {
total_considerations: count,
username: username,
});Error tracking (root.tsx, profile.tsx)
Errors are captured in two ways:
- Error boundary - The
ErrorBoundaryinroot.tsxautomatically captures unhandled React Router errors:
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const posthog = usePostHog();
posthog.captureException(error);
// ... error UI
}- Manual error capture in components (profile.tsx):
posthog.captureException(err);Server-side tracking (api.auth.login.ts, api.burrito.consider.ts)
Server-side events use the PostHog client from the request context (set by the middleware):
const posthog = (context as any).posthog as PostHog | undefined;
if (posthog) {
posthog.capture({ event: 'server_login' });
}Key Points:
- The PostHog client is available via
context.posthog(set by the middleware) - Events are automatically associated with the correct user/session via the middleware's
withContext()call - The
distinctIdandsessionIdare extracted from request headers and used to maintain context between client and server
Learn More
.env.example
VITE_PUBLIC_POSTHOG_PROJECT_TOKEN=
VITE_PUBLIC_POSTHOG_HOST=
PROJECT_ID=app/components/Header.tsx
import { Link } from 'react-router';
import { useAuth } from '../contexts/AuthContext';
import { usePostHog } from '@posthog/react';
export default function Header() {
const { user, logout } = useAuth();
const posthog = usePostHog();
const handleLogout = () => {
posthog?.capture('user_logged_out');
posthog?.reset();
logout();
};
return (
<header className="header">
<div className="header-container">
<nav>
<Link to="/">Home</Link>
{user && (
<>
<Link to="/burrito">Burrito Consideration</Link>
<Link to="/profile">Profile</Link>
<Link to="/error">Error</Link>
</>
)}
</nav>
<div className="user-section">
{user ? (
<>
<span>Welcome, {user.username}!</span>
<button onClick={handleLogout} className="btn-logout">
Logout
</button>
</>
) : (
<span>Not logged in</span>
)}
</div>
</div>
</header>
);
}
app/contexts/AuthContext.tsx
import { createContext, useContext, useState, type ReactNode } from 'react';
interface User {
username: string;
burritoConsiderations: number;
}
interface AuthContextType {
user: User | null;
login: (username: string, password: string) => Promise<boolean>;
logout: () => void;
incrementBurritoConsiderations: () => void;
setUser: (user: User) => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const users: Map<string, User> = new Map();
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(() => {
if (typeof window === 'undefined') return null;
const storedUsername = localStorage.getItem('currentUser');
if (storedUsername) {
const existingUser = users.get(storedUsername);
if (existingUser) {
return existingUser;
}
}
return null;
});
const login = async (username: string, password: string): Promise<boolean> => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (response.ok) {
const { user: userData } = await response.json();
let localUser = users.get(username);
if (!localUser) {
localUser = userData as User;
users.set(username, localUser);
}
setUser(localUser);
localStorage.setItem('currentUser', username);
return true;
}
return false;
} catch (error) {
console.error('Login error:', error);
return false;
}
};
const logout = () => {
setUser(null);
localStorage.removeItem('currentUser');
};
const incrementBurritoConsiderations = () => {
if (user) {
user.burritoConsiderations++;
users.set(user.username, user);
setUser({ ...user });
}
};
const setUserState = (newUser: User) => {
setUser(newUser);
users.set(newUser.username, newUser);
};
return (
<AuthContext.Provider value={{ user, login, logout, incrementBurritoConsiderations, setUser: setUserState }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
app/entry.client.tsx
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { HydratedRouter } from "react-router/dom";
import posthog from 'posthog-js';
import { PostHogProvider } from '@posthog/react'
posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN, {
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
defaults: '2026-01-30',
tracing_headers: [ window.location.hostname ],
});
startTransition(() => {
hydrateRoot(
document,
<PostHogProvider client={posthog}>
<StrictMode>
<HydratedRouter />
</StrictMode>
</PostHogProvider>,
);
});
app/entry.server.tsx
import { PassThrough } from "node:stream";
import type { EntryContext, RouterContextProvider } from "react-router";
import { createReadableStreamFromReadable } from "@react-router/node";
import { ServerRouter } from "react-router";
import { isbot } from "isbot";
import type { RenderToPipeableStreamOptions } from "react-dom/server";
import { renderToPipeableStream } from "react-dom/server";
export const streamTimeout = 5_000;
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
loadContext: RouterContextProvider,
) {
// https://httpwg.org/specs/rfc9110.html#HEAD
if (request.method.toUpperCase() === "HEAD") {
return new Response(null, {
status: responseStatusCode,
headers: responseHeaders,
});
}
return new Promise((resolve, reject) => {
let shellRendered = false;
let userAgent = request.headers.get("user-agent");
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
let readyOption: keyof RenderToPipeableStreamOptions =
(userAgent && isbot(userAgent)) || routerContext.isSpaMode
? "onAllReady"
: "onShellReady";
// Abort the rendering stream after the `streamTimeout` so it has time to
// flush down the rejected boundaries
let timeoutId: ReturnType<typeof setTimeout> | undefined = setTimeout(
() => abort(),
streamTimeout + 1000,
);
const { pipe, abort } = renderToPipeableStream(
<ServerRouter context={routerContext} url={request.url} />,
{
[readyOption]() {
shellRendered = true;
const body = new PassThrough({
final(callback) {
// Clear the timeout to prevent retaining the closure and memory leak
clearTimeout(timeoutId);
timeoutId = undefined;
callback();
},
});
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
pipe(body);
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
}),
);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
},
);
});
}
app/lib/db.ts
import sqlite3 from "sqlite3";
import { join } from "node:path";
import { promisify } from "node:util";
const dbPath = join(process.cwd(), "burrito-considerations.db");
const db = new sqlite3.Database(dbPath);
// Initialize schema
db.serialize(() => {
db.run(`
CREATE TABLE IF NOT EXISTS burrito_considerations (
username TEXT PRIMARY KEY,
count INTEGER NOT NULL DEFAULT 0
)
`);
});
const dbGet = promisify(db.get.bind(db));
const dbRun = promisify(db.run.bind(db));
export function getBurritoConsiderations(username: string): Promise<number> {
return dbGet("SELECT count FROM burrito_considerations WHERE username = ?", [username])
.then((row: any) => row?.count ?? 0);
}
export function incrementBurritoConsiderations(username: string): Promise<number> {
return dbRun(`
INSERT INTO burrito_considerations (username, count)
VALUES (?, 1)
ON CONFLICT(username) DO UPDATE SET count = count + 1
`, [username])
.then(() => {
return dbGet("SELECT count FROM burrito_considerations WHERE username = ?", [username]);
})
.then((row: any) => row.count);
}
app/lib/posthog-middleware.ts
import { PostHog } from "posthog-node";
import type { RouterContextProvider } from "react-router";
import type { Route } from "../+types/root";
export interface PostHogContext extends RouterContextProvider {
posthog?: PostHog;
}
export const posthogMiddleware: Route.MiddlewareFunction = async ({ request, context }, next) => {
const posthog = new PostHog(process.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN!, {
host: process.env.VITE_PUBLIC_POSTHOG_HOST!,
flushAt: 1,
flushInterval: 0,
});
const sessionId = request.headers.get('X-POSTHOG-SESSION-ID');
const distinctId = request.headers.get('X-POSTHOG-DISTINCT-ID');
(context as PostHogContext).posthog = posthog;
const response = await posthog.withContext(
{ sessionId: sessionId ?? undefined, distinctId: distinctId ?? undefined },
next
);
await posthog.shutdown().catch(() => {});
return response;
};
app/root.tsx
import { usePostHog } from '@posthog/react';
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "react-router";
import type { Route } from "./+types/root";
import "./app.css";
import "./globals.css";
import Header from "./components/Header";
import { AuthProvider } from "./contexts/AuthContext";
import { posthogMiddleware } from "./lib/posthog-middleware";
export const middleware: Route.MiddlewareFunction[] = [
posthogMiddleware,
];
export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossOrigin: "anonymous",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
},
];
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return (
<AuthProvider>
<Header />
<main>
<Outlet />
</main>
</AuthProvider>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "Oops!";
let details = "An unexpected error occurred.";
let stack: string | undefined;
const posthog = usePostHog();
posthog.captureException(error);
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error";
details =
error.status === 404
? "The requested page could not be found."
: error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}
return (
<main className="pt-16 p-4 container mx-auto">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
);
}
app/routes.ts
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route("burrito", "routes/burrito.tsx"),
route("profile", "routes/profile.tsx"),
route("error", "routes/error.tsx"),
route("api/auth/login", "routes/api.auth.login.ts"),
route("api/burrito/consider", "routes/api.burrito.consider.ts"),
] satisfies RouteConfig;
app/routes/api.auth.login.ts
import type { Route } from "./+types/api.auth.login";
import { getBurritoConsiderations } from "../lib/db";
import type { PostHogContext } from "../lib/posthog-middleware";
const users = new Map<string, { username: string }>();
export { users };
export async function action({ request, context }: Route.ActionArgs) {
const body = await request.json();
const { username, password } = body;
if (!username || !password) {
return Response.json({ error: 'Username and password required' }, { status: 400 });
}
let user = users.get(username);
if (!user) {
user = { username };
users.set(username, user);
}
const posthog = (context as PostHogContext).posthog;
if (posthog) {
posthog.capture({ event: 'server_login' });
}
const burritoConsiderations = await getBurritoConsiderations(username);
return Response.json({
success: true,
user: { ...user, burritoConsiderations }
});
}
app/routes/api.burrito.consider.ts
import type { Route } from "./+types/api.burrito.consider";
import { users } from "./api.auth.login";
import { incrementBurritoConsiderations } from "../lib/db";
import type { PostHogContext } from "../lib/posthog-middleware";
export async function action({ request, context }: Route.ActionArgs) {
const body = await request.json();
const { username } = body;
if (!username) {
return Response.json({ error: 'Username required' }, { status: 400 });
}
const user = users.get(username);
if (!user) {
return Response.json({ error: 'User not found' }, { status: 404 });
}
const burritoConsiderations = await incrementBurritoConsiderations(username);
const posthog = (context as PostHogContext).posthog;
posthog?.capture({ event: 'burrito_considered' });
return Response.json({
success: true,
user: { ...user, burritoConsiderations }
});
}
app/routes/burrito.tsx
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router';
import type { Route } from "./+types/burrito";
import { useAuth } from '../contexts/AuthContext';
export function meta({}: Route.MetaArgs) {
return [
{ title: "Burrito Consideration - Burrito Consideration App" },
{ name: "description", content: "Consider the potential of burritos" },
];
}
export default function BurritoPage() {
const { user, setUser } = useAuth();
const navigate = useNavigate();
const [hasConsidered, setHasConsidered] = useState(false);
useEffect(() => {
if (!user) {
navigate('/');
}
}, [user, navigate]);
if (!user) {
return null;
}
const handleConsideration = async () => {
try {
const response = await fetch('/api/burrito/consider', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user.username }),
});
if (response.ok) {
const { user: updatedUser } = await response.json();
setUser(updatedUser);
setHasConsidered(true);
setTimeout(() => setHasConsidered(false), 2000);
} else {
console.error('Failed to increment burrito considerations');
}
} catch (err) {
console.error('Error considering burrito:', err);
}
};
return (
<div className="container">
<h1>Burrito consideration zone</h1>
<p>Take a moment to truly consider the potential of burritos.</p>
<div style={{ textAlign: 'center' }}>
<button
onClick={handleConsideration}
className="btn-burrito"
>
I have considered the burrito potential
</button>
{hasConsidered && (
<p className="success">
Thank you for your consideration! Count: {user.burritoConsiderations}
</p>
)}
</div>
<div className="stats">
<h3>Consideration stats</h3>
<p>Total considerations: {user.burritoConsiderations}</p>
</div>
</div>
);
}
app/routes/error.tsx
import type { Route } from "./+types/error";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Error Test - Burrito Consideration App" },
{ name: "description", content: "Test error boundary" },
];
}
export default function ErrorPage() {
// This will throw an error during render, which will be caught by ErrorBoundary
throw new Error('Test error for ErrorBoundary - this is a render-time error');
}
app/routes/home.tsx
import { useState } from 'react';
import type { Route } from "./+types/home";
import { useAuth } from '../contexts/AuthContext';
import { usePostHog } from '@posthog/react';
export function meta({}: Route.MetaArgs) {
return [
{ title: "Burrito Consideration App" },
{ name: "description", content: "Consider the potential of burritos" },
];
}
export default function Home() {
const { user, login } = useAuth();
const posthog = usePostHog();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
try {
const success = await login(username, password);
if (success) {
// Identifying the user once on login/sign up is enough.
posthog?.identify(username);
// Capture login event
posthog?.capture('user_logged_in');
setUsername('');
setPassword('');
} else {
setError('Please provide both username and password');
}
} catch (err) {
console.error('Login failed:', err);
setError('An error occurred during login');
}
};
if (user) {
return (
<div className="container">
<h1>Welcome back, {user.username}!</h1>
<p>You are logged in. Feel free to explore:</p>
<ul>
<li>Consider the potential of burritos</li>
<li>View your profile and statistics</li>
</ul>
</div>
);
}
return (
<div className="container">
<h1>Welcome to Burrito Consideration App</h1>
<p>Please sign in to begin your burrito journey</p>
<form onSubmit={handleSubmit} className="form">
<div className="form-group">
<label htmlFor="username">Username:</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter any username"
/>
</div>
<div className="form-group">
<label htmlFor="password">Password:</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter any password"
/>
</div>
{error && <p className="error">{error}</p>}
<button type="submit" className="btn-primary">Sign In</button>
</form>
<p className="note">
Note: This is a demo app. Use any username and password to sign in.
</p>
</div>
);
}
app/routes/profile.tsx
import { useEffect } from 'react';
import { useNavigate } from 'react-router';
import type { Route } from "./+types/profile";
import { useAuth } from '../contexts/AuthContext';
import posthog from 'posthog-js';
import { usePostHog } from '@posthog/react';
export function meta({}: Route.MetaArgs) {
return [
{ title: "User Profile - Burrito Consideration App" },
{ name: "description", content: "View your profile and burrito consideration stats" },
];
}
export default function ProfilePage() {
const { user } = useAuth();
const navigate = useNavigate();
const posthog = usePostHog();
useEffect(() => {
if (!user) {
navigate('/');
}
}, [user, navigate]);
if (!user) {
return null;
}
const triggerTestError = () => {
try {
throw new Error('Test error for PostHog error tracking');
} catch (err) {
console.error('Captured error:', err);
posthog.captureException(err);
}
};
return (
<div className="container">
<h1>User Profile</h1>
<div className="stats">
<h2>Your Information</h2>
<p><strong>Username:</strong> {user.username}</p>
<p><strong>Burrito Considerations:</strong> {user.burritoConsiderations}</p>
</div>
<div style={{ marginTop: '2rem' }}>
<button onClick={triggerTestError} className="btn-primary" style={{ backgroundColor: '#dc3545' }}>
Trigger Test Error (for PostHog)
</button>
</div>
<div style={{ marginTop: '2rem' }}>
<h3>Your Burrito Journey</h3>
{user.burritoConsiderations === 0 ? (
<p>You haven't considered any burritos yet. Visit the Burrito Consideration page to start!</p>
) : user.burritoConsiderations === 1 ? (
<p>You've considered the burrito potential once. Keep going!</p>
) : user.burritoConsiderations < 5 ? (
<p>You're getting the hang of burrito consideration!</p>
) : user.burritoConsiderations < 10 ? (
<p>You're becoming a burrito consideration expert!</p>
) : (
<p>You are a true burrito consideration master! 🌯</p>
)}
</div>
</div>
);
}
app/welcome/welcome.tsx
import logoDark from "./logo-dark.svg";
import logoLight from "./logo-light.svg";
export function Welcome() {
return (
<main className="flex items-center justify-center pt-16 pb-4">
<div className="flex-1 flex flex-col items-center gap-16 min-h-0">
<header className="flex flex-col items-center gap-9">
<div className="w-[500px] max-w-[100vw] p-4">
<img
src={logoLight}
alt="React Router"
className="block w-full dark:hidden"
/>
<img
src={logoDark}
alt="React Router"
className="hidden w-full dark:block"
/>
</div>
</header>
<div className="max-w-[300px] w-full space-y-6 px-4">
<nav className="rounded-3xl border border-gray-200 p-6 dark:border-gray-700 space-y-4">
<p className="leading-6 text-gray-700 dark:text-gray-200 text-center">
What's next?
</p>
<ul>
{resources.map(({ href, text, icon }) => (
<li key={href}>
<a
className="group flex items-center gap-3 self-stretch p-3 leading-normal text-blue-700 hover:underline dark:text-blue-500"
href={href}
target="_blank"
rel="noreferrer"
>
{icon}
{text}
</a>
</li>
))}
</ul>
</nav>
</div>
</div>
</main>
);
}
const resources = [
{
href: "https://reactrouter.com/docs",
text: "React Router Docs",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="20"
viewBox="0 0 20 20"
fill="none"
className="stroke-gray-600 group-hover:stroke-current dark:stroke-gray-300"
>
<path
d="M9.99981 10.0751V9.99992M17.4688 17.4688C15.889 19.0485 11.2645 16.9853 7.13958 12.8604C3.01467 8.73546 0.951405 4.11091 2.53116 2.53116C4.11091 0.951405 8.73546 3.01467 12.8604 7.13958C16.9853 11.2645 19.0485 15.889 17.4688 17.4688ZM2.53132 17.4688C0.951566 15.8891 3.01483 11.2645 7.13974 7.13963C11.2647 3.01471 15.8892 0.951453 17.469 2.53121C19.0487 4.11096 16.9854 8.73551 12.8605 12.8604C8.73562 16.9853 4.11107 19.0486 2.53132 17.4688Z"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
),
},
{
href: "https://rmx.as/discord",
text: "Join Discord",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="20"
viewBox="0 0 24 20"
fill="none"
className="stroke-gray-600 group-hover:stroke-current dark:stroke-gray-300"
>
<path
d="M15.0686 1.25995L14.5477 1.17423L14.2913 1.63578C14.1754 1.84439 14.0545 2.08275 13.9422 2.31963C12.6461 2.16488 11.3406 2.16505 10.0445 2.32014C9.92822 2.08178 9.80478 1.84975 9.67412 1.62413L9.41449 1.17584L8.90333 1.25995C7.33547 1.51794 5.80717 1.99419 4.37748 2.66939L4.19 2.75793L4.07461 2.93019C1.23864 7.16437 0.46302 11.3053 0.838165 15.3924L0.868838 15.7266L1.13844 15.9264C2.81818 17.1714 4.68053 18.1233 6.68582 18.719L7.18892 18.8684L7.50166 18.4469C7.96179 17.8268 8.36504 17.1824 8.709 16.4944L8.71099 16.4904C10.8645 17.0471 13.128 17.0485 15.2821 16.4947C15.6261 17.1826 16.0293 17.8269 16.4892 18.4469L16.805 18.8725L17.3116 18.717C19.3056 18.105 21.1876 17.1751 22.8559 15.9238L23.1224 15.724L23.1528 15.3923C23.5873 10.6524 22.3579 6.53306 19.8947 2.90714L19.7759 2.73227L19.5833 2.64518C18.1437 1.99439 16.6386 1.51826 15.0686 1.25995ZM16.6074 10.7755L16.6074 10.7756C16.5934 11.6409 16.0212 12.1444 15.4783 12.1444C14.9297 12.1444 14.3493 11.6173 14.3493 10.7877C14.3493 9.94885 14.9378 9.41192 15.4783 9.41192C16.0471 9.41192 16.6209 9.93851 16.6074 10.7755ZM8.49373 12.1444C7.94513 12.1444 7.36471 11.6173 7.36471 10.7877C7.36471 9.94885 7.95323 9.41192 8.49373 9.41192C9.06038 9.41192 9.63892 9.93712 9.6417 10.7815C9.62517 11.6239 9.05462 12.1444 8.49373 12.1444Z"
strokeWidth="1.5"
/>
</svg>
),
},
];
react-router.config.ts
import type { Config } from "@react-router/dev/config";
export default {
// Config options...
// Server-side render by default, to enable SPA mode set this to `false`
ssr: true,
future: {
v8_middleware: true,
},
} satisfies Config;
vite.config.ts
import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig, loadEnv } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
return {
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
ssr: {
noExternal: ['posthog-js', '@posthog/react'],
},
server: {
proxy: {
'/ingest/static': {
target: 'https://us-assets.i.posthog.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/ingest/, ''),
},
'/ingest/array': {
target: 'https://us-assets.i.posthog.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/ingest/, ''),
},
'/ingest': {
target: env.VITE_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/ingest/, ''),
},
},
},
};
});
references/EXAMPLE-react-tanstack-router-code-based.md
PostHog react-tanstack-router-code-based Example Project
Repository: https://github.com/PostHog/context-mill Path: example-apps/react-tanstack-router-code-based
README.md
PostHog TanStack Router Example (Code-Based Routing)
This is a React and TanStack Router example demonstrating PostHog integration with product analytics, session replay, and error tracking. This example uses code-based routing where routes are defined programmatically.
Features
- Product analytics: Track user events and behaviors
- Session replay: Record and replay user sessions
- Error tracking: Capture and track errors
- User authentication: Demo login system with PostHog user identification
- Client-side tracking: Pure client-side React implementation
- Reverse proxy: PostHog ingestion through Vite proxy
Getting started
1. Install dependencies
npm install2. Configure environment variables
Create a .env file in the root directory:
VITE_PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.comGet your PostHog project token from your PostHog project settings.
3. Run the development server
npm run devOpen http://localhost:3000 with your browser to see the app.
Project structure
src/
├── contexts/
│ └── AuthContext.tsx # Authentication context with PostHog integration
├── main.tsx # App entry point with all routes defined in code
├── reportWebVitals.ts # Performance monitoring
└── styles.css # Global stylesKey integration points
PostHog provider setup (main.tsx)
PostHog is initialized using PostHogProvider from @posthog/react. The provider wraps the entire app in the root route component:
import { PostHogProvider } from '@posthog/react'
import { createRootRoute } from '@tanstack/react-router'
const rootRoute = createRootRoute({
component: RootComponent,
})
function RootComponent() {
return (
<PostHogProvider
apiKey={import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN!}
options={{
api_host: '/ingest',
ui_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST || 'https://us.posthog.com',
defaults: '2026-01-30',
capture_exceptions: true,
debug: import.meta.env.DEV,
}}
>
{/* your app */}
</PostHogProvider>
)
}User identification (contexts/AuthContext.tsx)
import { usePostHog } from '@posthog/react'
const posthog = usePostHog()
posthog.identify(username, {
username: username,
})Event tracking (main.tsx - BurritoPage)
import { usePostHog } from '@posthog/react'
const posthog = usePostHog()
posthog.capture('burrito_considered', {
total_considerations: count,
username: username,
})Error tracking (main.tsx - ProfilePage)
posthog.captureException(error)TanStack Router details
This example uses TanStack Router with code-based routing. Key details:
- Client-side only: No server-side logic, no API routes, no posthog-node
- Code-based routing: All routes defined in
main.tsxusingcreateRoute()andcreateRootRoute() - Manual route tree: Routes connected with
addChildren()method - Standard hooks: Uses
useNavigate()from @tanstack/react-router - Vite proxy: Uses Vite's proxy config for PostHog calls
- Environment variables: Uses
import.meta.env.VITE_* - PostHog provider: Uses
PostHogProviderfrom@posthog/reactin root route
Code-based vs File-based routing
This example demonstrates code-based routing, where routes are defined programmatically:
import { createRoute, createRootRoute, createRouter } from '@tanstack/react-router'
const rootRoute = createRootRoute({ component: RootComponent })
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: Home,
})
const burritoRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/burrito',
component: BurritoPage,
})
const routeTree = rootRoute.addChildren([indexRoute, burritoRoute])
const router = createRouter({ routeTree })For file-based routing (auto-generated from file structure), see the react-tanstack-router-file-based example.
Learn more
- PostHog Documentation
- TanStack Router Documentation
- TanStack Router Code-Based Routing
- PostHog React Integration Guide
.env.example
VITE_PUBLIC_POSTHOG_PROJECT_TOKEN=<ph_project_token>
VITE_PUBLIC_POSTHOG_HOST=<ph_client_api_host>
.prettierignore
package-lock.json
pnpm-lock.yaml
yarn.lock
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="React TanStack Router code-based routing example"
/>
<link rel="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/manifest.json" />
<title>React TanStack Router - Code-Based</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
prettier.config.js
// @ts-check
/** @type {import('prettier').Config} */
const config = {
semi: false,
singleQuote: true,
trailingComma: "all",
};
export default config;
public/robots.txt
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
src/contexts/AuthContext.tsx
import { createContext, useContext, useState, type ReactNode } from 'react';
import { usePostHog } from '@posthog/react';
interface User {
username: string;
burritoConsiderations: number;
}
interface AuthContextType {
user: User | null;
login: (username: string, password: string) => Promise<boolean>;
logout: () => void;
incrementBurritoConsiderations: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const users: Map<string, User> = new Map();
export function AuthProvider({ children }: { children: ReactNode }) {
// Use lazy initializer to read from localStorage only once on mount
const [user, setUser] = useState<User | null>(() => {
if (typeof window === 'undefined') return null;
const storedUsername = localStorage.getItem('currentUser');
if (storedUsername) {
const existingUser = users.get(storedUsername);
if (existingUser) {
return existingUser;
}
}
return null;
});
const posthog = usePostHog();
const login = async (username: string, password: string): Promise<boolean> => {
if (!username || !password) {
return false;
}
// Get or create user in local map
let user = users.get(username);
const isNewUser = !user;
if (!user) {
user = { username, burritoConsiderations: 0 };
users.set(username, user);
}
setUser(user);
localStorage.setItem('currentUser', username);
// Identify user in PostHog using username as distinct ID
posthog.identify(username, {
username: username,
isNewUser: isNewUser,
});
// Capture login event
posthog.capture('user_logged_in', {
username: username,
isNewUser: isNewUser,
});
return true;
};
const logout = () => {
// Capture logout event before resetting
posthog.capture('user_logged_out');
posthog.reset();
setUser(null);
localStorage.removeItem('currentUser');
};
const incrementBurritoConsiderations = () => {
if (user) {
user.burritoConsiderations++;
users.set(user.username, user);
setUser({ ...user });
}
};
return (
<AuthContext.Provider value={{ user, login, logout, incrementBurritoConsiderations }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
src/main.tsx
import { StrictMode, useState } from 'react'
import ReactDOM from 'react-dom/client'
import {
Link,
Outlet,
RouterProvider,
createRootRoute,
createRoute,
createRouter,
useNavigate,
} from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { TanStackDevtools } from '@tanstack/react-devtools'
import { PostHogProvider, usePostHog } from '@posthog/react'
import { AuthProvider, useAuth } from './contexts/AuthContext'
import './styles.css'
import reportWebVitals from './reportWebVitals'
// ============================================================================
// Root Route
// ============================================================================
const rootRoute = createRootRoute({
component: RootComponent,
})
function RootComponent() {
return (
<PostHogProvider
apiKey={import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN!}
options={{
api_host: '/ingest',
ui_host:
import.meta.env.VITE_PUBLIC_POSTHOG_HOST || 'https://us.posthog.com',
defaults: '2026-01-30',
capture_exceptions: true,
debug: import.meta.env.DEV,
}}
>
<AuthProvider>
<Header />
<main>
<Outlet />
</main>
<TanStackDevtools
config={{
position: 'bottom-right',
}}
plugins={[
{
name: 'Tanstack Router',
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
</AuthProvider>
</PostHogProvider>
)
}
// ============================================================================
// Header Component
// ============================================================================
function Header() {
const { user, logout } = useAuth()
return (
<header className="header">
<div className="header-container">
<nav>
<Link to="/">Home</Link>
{user && (
<>
<Link to="/burrito">Burrito Consideration</Link>
<Link to="/profile">Profile</Link>
</>
)}
</nav>
<div className="user-section">
{user ? (
<>
<span>Welcome, {user.username}!</span>
<button onClick={logout} className="btn-logout">
Logout
</button>
</>
) : (
<span>Not logged in</span>
)}
</div>
</div>
</header>
)
}
// ============================================================================
// Index Route (Home Page)
// ============================================================================
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: Home,
})
function Home() {
const { user, login } = useAuth()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
try {
const success = await login(username, password)
if (success) {
setUsername('')
setPassword('')
} else {
setError('Please provide both username and password')
}
} catch (err) {
console.error('Login failed:', err)
setError('An error occurred during login')
}
}
if (user) {
return (
<div className="container">
<h1>Welcome back, {user.username}!</h1>
<p>You are logged in. Feel free to explore:</p>
<ul>
<li>Consider the potential of burritos</li>
<li>View your profile and statistics</li>
</ul>
</div>
)
}
return (
<div className="container">
<h1>Welcome to Burrito Consideration App</h1>
<p>Please sign in to begin your burrito journey</p>
<form onSubmit={handleSubmit} className="form">
<div className="form-group">
<label htmlFor="username">Username:</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter any username"
/>
</div>
<div className="form-group">
<label htmlFor="password">Password:</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter any password"
/>
</div>
{error && <p className="error">{error}</p>}
<button type="submit" className="btn-primary">
Sign In
</button>
</form>
<p className="note">
Note: This is a demo app. Use any username and password to sign in.
</p>
</div>
)
}
// ============================================================================
// Burrito Route
// ============================================================================
const burritoRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/burrito',
component: BurritoPage,
})
function BurritoPage() {
const { user, incrementBurritoConsiderations } = useAuth()
const navigate = useNavigate()
const posthog = usePostHog()
const [hasConsidered, setHasConsidered] = useState(false)
// Redirect to home if not logged in
if (!user) {
navigate({ to: '/' })
return null
}
const handleConsideration = () => {
incrementBurritoConsiderations()
setHasConsidered(true)
setTimeout(() => setHasConsidered(false), 2000)
// Capture burrito consideration event
console.log('posthog', posthog)
posthog.capture('burrito_considered', {
total_considerations: user.burritoConsiderations + 1,
username: user.username,
})
}
return (
<div className="container">
<h1>Burrito consideration zone</h1>
<p>Take a moment to truly consider the potential of burritos.</p>
<div style={{ textAlign: 'center' }}>
<button onClick={handleConsideration} className="btn-burrito">
I have considered the burrito potential
</button>
{hasConsidered && (
<p className="success">
Thank you for your consideration! Count: {user.burritoConsiderations}
</p>
)}
</div>
<div className="stats">
<h3>Consideration stats</h3>
<p>Total considerations: {user.burritoConsiderations}</p>
</div>
</div>
)
}
// ============================================================================
// Profile Route
// ============================================================================
const profileRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/profile',
component: ProfilePage,
})
function ProfilePage() {
const { user } = useAuth()
const navigate = useNavigate()
const posthog = usePostHog()
// Redirect to home if not logged in
if (!user) {
navigate({ to: '/' })
return null
}
const triggerTestError = () => {
try {
throw new Error('Test error for PostHog error tracking')
} catch (err) {
posthog.captureException(err)
console.error('Captured error:', err)
alert('Error captured and sent to PostHog!')
}
}
return (
<div className="container">
<h1>User Profile</h1>
<div className="stats">
<h2>Your Information</h2>
<p>
<strong>Username:</strong> {user.username}
</p>
<p>
<strong>Burrito Considerations:</strong> {user.burritoConsiderations}
</p>
</div>
<div style={{ marginTop: '2rem' }}>
<button
onClick={triggerTestError}
className="btn-primary"
style={{ backgroundColor: '#dc3545' }}
>
Trigger Test Error (for PostHog)
</button>
</div>
<div style={{ marginTop: '2rem' }}>
<h3>Your Burrito Journey</h3>
{user.burritoConsiderations === 0 ? (
<p>
You haven't considered any burritos yet. Visit the Burrito
Consideration page to start!
</p>
) : user.burritoConsiderations === 1 ? (
<p>You've considered the burrito potential once. Keep going!</p>
) : user.burritoConsiderations < 5 ? (
<p>You're getting the hang of burrito consideration!</p>
) : user.burritoConsiderations < 10 ? (
<p>You're becoming a burrito consideration expert!</p>
) : (
<p>You are a true burrito consideration master!</p>
)}
</div>
</div>
)
}
// ============================================================================
// Route Tree & Router Setup
// ============================================================================
const routeTree = rootRoute.addChildren([indexRoute, burritoRoute, profileRoute])
const router = createRouter({
routeTree,
context: {},
defaultPreload: 'intent',
scrollRestoration: true,
defaultStructuralSharing: true,
defaultPreloadStaleTime: 0,
})
// Register the router instance for type safety
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
// ============================================================================
// Render the App
// ============================================================================
const rootElement = document.getElementById('app')
if (rootElement && !rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement)
root.render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
)
}
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals()
src/reportWebVitals.ts
const reportWebVitals = (onPerfEntry?: () => void) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ onCLS, onINP, onFCP, onLCP, onTTFB }) => {
onCLS(onPerfEntry)
onINP(onPerfEntry)
onFCP(onPerfEntry)
onLCP(onPerfEntry)
onTTFB(onPerfEntry)
})
}
}
export default reportWebVitals
vite.config.ts
import { defineConfig, loadEnv } from 'vite'
import viteReact from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { fileURLToPath, URL } from 'node:url'
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
return {
plugins: [viteReact(), tailwindcss()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
proxy: {
'/ingest/static': {
target: 'https://us-assets.i.posthog.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/ingest/, ''),
},
'/ingest/array': {
target: 'https://us-assets.i.posthog.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/ingest/, ''),
},
'/ingest': {
target: env.VITE_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/ingest/, ''),
},
},
},
}
})
references/EXAMPLE-react-tanstack-router-file-based.md
PostHog react-tanstack-router-file-based Example Project
Repository: https://github.com/PostHog/context-mill Path: example-apps/react-tanstack-router-file-based
README.md
PostHog TanStack Router Example
This is a React and TanStack Router example demonstrating PostHog integration with product analytics, session replay, and error tracking.
Features
- Product analytics: Track user events and behaviors
- Session replay: Record and replay user sessions
- Error tracking: Capture and track errors
- User authentication: Demo login system with PostHog user identification
- Client-side tracking: Pure client-side React implementation
- Reverse proxy: PostHog ingestion through Vite proxy
Getting started
1. Install dependencies
npm install2. Configure environment variables
Create a .env file in the root directory:
VITE_PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.comGet your PostHog project token from your PostHog project settings.
3. Run the development server
npm run devOpen http://localhost:3000 with your browser to see the app.
Project structure
src/
├── components/
│ └── Header.tsx # Navigation header with auth state
├── contexts/
│ └── AuthContext.tsx # Authentication context with PostHog integration
├── routes/
│ ├── __root.tsx # Root layout with PostHogProvider
│ ├── index.tsx # Home/Login page
│ ├── burrito.tsx # Demo feature page with event tracking
│ └── profile.tsx # User profile with error tracking demo
├── main.tsx # App entry point
└── styles.css # Global stylesKey integration points
PostHog provider setup (routes/__root.tsx)
PostHog is initialized using PostHogProvider from @posthog/react. The provider wraps the entire app and handles calling posthog.init() automatically:
import { PostHogProvider } from '@posthog/react'
export const Route = createRootRoute({
component: () => (
<PostHogProvider
apiKey={import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN!}
options={{
api_host: '/ingest',
ui_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST || 'https://us.posthog.com',
defaults: '2026-01-30',
capture_exceptions: true,
debug: import.meta.env.DEV,
}}
>
{/* your app */}
</PostHogProvider>
),
})User identification (contexts/AuthContext.tsx)
import { usePostHog } from '@posthog/react'
const posthog = usePostHog()
posthog.identify(username, {
username: username,
})Event tracking (routes/burrito.tsx)
import { usePostHog } from '@posthog/react'
const posthog = usePostHog()
posthog.capture('burrito_considered', {
total_considerations: count,
username: username,
})Error tracking (routes/profile.tsx)
posthog.captureException(error)TanStack Router details
This example uses TanStack Router. Key details:
- Client-side only: No server-side logic, no API routes, no posthog-node
- File-based routing: Routes are files in
src/routesdirectory - Standard hooks: Uses
useNavigate()from @tanstack/react-router - Vite proxy: Uses Vite's proxy config for PostHog calls
- Environment variables: Uses
import.meta.env.VITE_* - PostHog provider: Uses
PostHogProviderfrom@posthog/reactin root route
Learn more
.env.example
VITE_PUBLIC_POSTHOG_PROJECT_TOKEN=<ph_project_token>
VITE_PUBLIC_POSTHOG_HOST=<ph_client_api_host>
.prettierignore
package-lock.json
pnpm-lock.yaml
yarn.lockindex.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-tsrouter-app"
/>
<link rel="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/manifest.json" />
<title>Create TanStack App - react-tanstack</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
prettier.config.js
// @ts-check
/** @type {import('prettier').Config} */
const config = {
semi: false,
singleQuote: true,
trailingComma: "all",
};
export default config;
public/robots.txt
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
src/components/Header.tsx
import { Link } from '@tanstack/react-router'
import { useAuth } from '../contexts/AuthContext'
export default function Header() {
const { user, logout } = useAuth()
return (
<header className="header">
<div className="header-container">
<nav>
<Link to="/">Home</Link>
{user && (
<>
<Link to="/burrito">Burrito Consideration</Link>
<Link to="/profile">Profile</Link>
</>
)}
</nav>
<div className="user-section">
{user ? (
<>
<span>Welcome, {user.username}!</span>
<button onClick={logout} className="btn-logout">
Logout
</button>
</>
) : (
<span>Not logged in</span>
)}
</div>
</div>
</header>
)
}
src/contexts/AuthContext.tsx
import { createContext, useContext, useState, type ReactNode } from 'react';
import { usePostHog } from '@posthog/react';
interface User {
username: string;
burritoConsiderations: number;
}
interface AuthContextType {
user: User | null;
login: (username: string, password: string) => Promise<boolean>;
logout: () => void;
incrementBurritoConsiderations: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const users: Map<string, User> = new Map();
export function AuthProvider({ children }: { children: ReactNode }) {
// Use lazy initializer to read from localStorage only once on mount
const [user, setUser] = useState<User | null>(() => {
if (typeof window === 'undefined') return null;
const storedUsername = localStorage.getItem('currentUser');
if (storedUsername) {
const existingUser = users.get(storedUsername);
if (existingUser) {
return existingUser;
}
}
return null;
});
const posthog = usePostHog();
const login = async (username: string, password: string): Promise<boolean> => {
if (!username || !password) {
return false;
}
// Get or create user in local map
let user = users.get(username);
const isNewUser = !user;
if (!user) {
user = { username, burritoConsiderations: 0 };
users.set(username, user);
}
setUser(user);
localStorage.setItem('currentUser', username);
// Identify user in PostHog using username as distinct ID
posthog.identify(username, {
username: username,
isNewUser: isNewUser,
});
// Capture login event
posthog.capture('user_logged_in', {
username: username,
isNewUser: isNewUser,
});
return true;
};
const logout = () => {
// Capture logout event before resetting
posthog.capture('user_logged_out');
posthog.reset();
setUser(null);
localStorage.removeItem('currentUser');
};
const incrementBurritoConsiderations = () => {
if (user) {
user.burritoConsiderations++;
users.set(user.username, user);
setUser({ ...user });
}
};
return (
<AuthContext.Provider value={{ user, login, logout, incrementBurritoConsiderations }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
src/main.tsx
import { StrictMode } from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider, createRouter } from '@tanstack/react-router'
// Import the generated route tree
import { routeTree } from './routeTree.gen.ts'
import './styles.css'
import reportWebVitals from './reportWebVitals.ts'
// Create a new router instance
const router = createRouter({
routeTree,
context: {},
defaultPreload: 'intent',
scrollRestoration: true,
defaultStructuralSharing: true,
defaultPreloadStaleTime: 0,
})
// Register the router instance for type safety
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
// Render the app
const rootElement = document.getElementById('app')
if (rootElement && !rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement)
root.render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
)
}
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals()
src/reportWebVitals.ts
const reportWebVitals = (onPerfEntry?: () => void) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ onCLS, onINP, onFCP, onLCP, onTTFB }) => {
onCLS(onPerfEntry)
onINP(onPerfEntry)
onFCP(onPerfEntry)
onLCP(onPerfEntry)
onTTFB(onPerfEntry)
})
}
}
export default reportWebVitals
src/routes/__root.tsx
import { Outlet, createRootRoute } from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { TanStackDevtools } from '@tanstack/react-devtools'
import { PostHogProvider } from '@posthog/react'
import Header from '../components/Header'
import { AuthProvider } from '../contexts/AuthContext'
export const Route = createRootRoute({
component: () => (
<PostHogProvider
apiKey={import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN!}
options={{
api_host: '/ingest',
ui_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST || 'https://us.posthog.com',
defaults: '2026-01-30',
capture_exceptions: true,
debug: import.meta.env.DEV,
}}
>
<AuthProvider>
<Header />
<main>
<Outlet />
</main>
<TanStackDevtools
config={{
position: 'bottom-right',
}}
plugins={[
{
name: 'Tanstack Router',
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
</AuthProvider>
</PostHogProvider>
),
})
src/routes/burrito.tsx
import { useState } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { usePostHog } from '@posthog/react'
import { useAuth } from '../contexts/AuthContext'
export const Route = createFileRoute('/burrito')({
component: BurritoPage,
})
function BurritoPage() {
const { user, incrementBurritoConsiderations } = useAuth()
const navigate = useNavigate()
const posthog = usePostHog()
const [hasConsidered, setHasConsidered] = useState(false)
// Redirect to home if not logged in
if (!user) {
navigate({ to: '/' })
return null
}
const handleConsideration = () => {
incrementBurritoConsiderations()
setHasConsidered(true)
setTimeout(() => setHasConsidered(false), 2000)
// Capture burrito consideration event
console.log('posthog', posthog)
posthog.capture('burrito_considered', {
total_considerations: user.burritoConsiderations + 1,
username: user.username,
})
}
return (
<div className="container">
<h1>Burrito consideration zone</h1>
<p>Take a moment to truly consider the potential of burritos.</p>
<div style={{ textAlign: 'center' }}>
<button onClick={handleConsideration} className="btn-burrito">
I have considered the burrito potential
</button>
{hasConsidered && (
<p className="success">
Thank you for your consideration! Count: {user.burritoConsiderations}
</p>
)}
</div>
<div className="stats">
<h3>Consideration stats</h3>
<p>Total considerations: {user.burritoConsiderations}</p>
</div>
</div>
)
}
src/routes/index.tsx
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useAuth } from '../contexts/AuthContext'
export const Route = createFileRoute('/')({
component: Home,
})
function Home() {
const { user, login } = useAuth()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
try {
const success = await login(username, password)
if (success) {
setUsername('')
setPassword('')
} else {
setError('Please provide both username and password')
}
} catch (err) {
console.error('Login failed:', err)
setError('An error occurred during login')
}
}
if (user) {
return (
<div className="container">
<h1>Welcome back, {user.username}!</h1>
<p>You are logged in. Feel free to explore:</p>
<ul>
<li>Consider the potential of burritos</li>
<li>View your profile and statistics</li>
</ul>
</div>
)
}
return (
<div className="container">
<h1>Welcome to Burrito Consideration App</h1>
<p>Please sign in to begin your burrito journey</p>
<form onSubmit={handleSubmit} className="form">
<div className="form-group">
<label htmlFor="username">Username:</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter any username"
/>
</div>
<div className="form-group">
<label htmlFor="password">Password:</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter any password"
/>
</div>
{error && <p className="error">{error}</p>}
<button type="submit" className="btn-primary">
Sign In
</button>
</form>
<p className="note">
Note: This is a demo app. Use any username and password to sign in.
</p>
</div>
)
}
src/routes/profile.tsx
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { usePostHog } from 'posthog-js/react'
import { useAuth } from '../contexts/AuthContext'
export const Route = createFileRoute('/profile')({
component: ProfilePage,
})
function ProfilePage() {
const { user } = useAuth()
const navigate = useNavigate()
const posthog = usePostHog()
// Redirect to home if not logged in
if (!user) {
navigate({ to: '/' })
return null
}
const triggerTestError = () => {
try {
throw new Error('Test error for PostHog error tracking')
} catch (err) {
posthog.captureException(err)
console.error('Captured error:', err)
alert('Error captured and sent to PostHog!')
}
}
return (
<div className="container">
<h1>User Profile</h1>
<div className="stats">
<h2>Your Information</h2>
<p>
<strong>Username:</strong> {user.username}
</p>
<p>
<strong>Burrito Considerations:</strong> {user.burritoConsiderations}
</p>
</div>
<div style={{ marginTop: '2rem' }}>
<button
onClick={triggerTestError}
className="btn-primary"
style={{ backgroundColor: '#dc3545' }}
>
Trigger Test Error (for PostHog)
</button>
</div>
<div style={{ marginTop: '2rem' }}>
<h3>Your Burrito Journey</h3>
{user.burritoConsiderations === 0 ? (
<p>You haven't considered any burritos yet. Visit the Burrito Consideration page to start!</p>
) : user.burritoConsiderations === 1 ? (
<p>You've considered the burrito potential once. Keep going!</p>
) : user.burritoConsiderations < 5 ? (
<p>You're getting the hang of burrito consideration!</p>
) : user.burritoConsiderations < 10 ? (
<p>You're becoming a burrito consideration expert!</p>
) : (
<p>You are a true burrito consideration master!</p>
)}
</div>
</div>
)
}
vite.config.ts
import { defineConfig, loadEnv } from 'vite'
import viteReact from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { tanstackRouter } from '@tanstack/router-plugin/vite'
import { fileURLToPath, URL } from 'node:url'
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
return {
plugins: [
tanstackRouter({
target: 'react',
autoCodeSplitting: true,
}),
viteReact(),
tailwindcss(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
proxy: {
'/ingest/static': {
target: 'https://us-assets.i.posthog.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/ingest/, ''),
},
'/ingest/array': {
target: 'https://us-assets.i.posthog.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/ingest/, ''),
},
'/ingest': {
target: env.VITE_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/ingest/, ''),
},
},
},
}
})
references/EXAMPLE-ruby-on-rails.md
PostHog ruby-on-rails Example Project
Repository: https://github.com/PostHog/context-mill Path: example-apps/ruby-on-rails
README.md
PostHog Ruby on Rails example
This is a Ruby on Rails example demonstrating PostHog integration with product analytics, error tracking (auto-instrumentation), feature flags, user identification, and ActiveJob instrumentation via the posthog-rails gem.
Features
- Product analytics: Track user events and behaviors with
PostHog.capture - Error tracking (auto): Unhandled exceptions captured automatically by
posthog-rails - Error tracking (manual): Handled errors captured with
PostHog.capture_exception - Rails.error integration: Rails 7+ error reporting captured automatically
- ActiveJob instrumentation: Background job failures captured automatically
- User identification: Associate events with authenticated users via
PostHog.identify - Feature flags: Control feature rollouts with
PostHog.is_feature_enabled - User context: Exceptions automatically associated with
current_user - Frontend tracking: posthog-js captures pageviews and session replay alongside backend events
Getting started
1. Install dependencies
bundle install2. Configure environment variables
cp .env.example .env
# Edit .env and add your PostHog project tokenGet your PostHog project token from your PostHog project settings.
3. Setup database
bin/rails db:create db:migrate db:seed4. Run the development server
bin/rails serverOpen http://localhost:3000 with your browser. Login with admin@example.com / admin.
Project structure
ruby-on-rails/
├── config/
│ ├── routes.rb # URL routing
│ └── initializers/
│ └── posthog.rb # PostHog + posthog-rails configuration
├── app/
│ ├── controllers/
│ │ ├── application_controller.rb # Base controller with current_user
│ │ ├── sessions_controller.rb # Login/logout with PostHog identify
│ │ ├── registrations_controller.rb # Signup with PostHog identify
│ │ ├── dashboard_controller.rb # Feature flags + ActiveJob demo
│ │ ├── burritos_controller.rb # Custom event tracking
│ │ ├── profiles_controller.rb # Page view tracking
│ │ └── errors_controller.rb # Error tracking demos
│ ├── jobs/
│ │ └── example_job.rb # ActiveJob auto-instrumentation demo
│ ├── models/
│ │ └── user.rb # posthog_distinct_id + posthog_properties
│ └── views/
│ ├── layouts/application.html.erb # Base layout with posthog-js snippet
│ ├── sessions/new.html.erb # Login page
│ ├── registrations/new.html.erb # Signup page
│ ├── dashboard/show.html.erb # Feature flags demo
│ ├── burritos/show.html.erb # Event tracking demo
│ └── profiles/show.html.erb # Error tracking demo
├── db/
│ ├── migrate/ # Database migrations
│ └── seeds.rb # Default admin user
├── .env.example # Environment variable template
├── Gemfile # Ruby dependencies
└── README.md # This fileKey integration points
PostHog initialization (config/initializers/posthog.rb)
# Rails-specific auto-instrumentation
PostHog::Rails.configure do |config|
config.auto_capture_exceptions = true
config.report_rescued_exceptions = true
config.auto_instrument_active_job = true
config.capture_user_context = true
config.current_user_method = :current_user
config.user_id_method = :posthog_distinct_id
end
PostHog.init do |config|
config.api_key = ENV.fetch('POSTHOG_PROJECT_TOKEN', nil)
config.host = ENV.fetch('POSTHOG_HOST', 'https://us.i.posthog.com')
endUser model (app/models/user.rb)
class User < ApplicationRecord
has_secure_password
# Called by posthog-rails for automatic user association in error reports.
# The primary key, not the email — an email can change, which splits one
# person's history in two, and it is PII on every event's identity.
def posthog_distinct_id
id.to_s
end
def posthog_properties
{ email: email, is_staff: is_staff, date_joined: created_at&.iso8601 }
end
endUser identification (app/controllers/sessions_controller.rb)
# Identify the user and capture login event
PostHog.identify(
distinct_id: user.posthog_distinct_id,
properties: user.posthog_properties
)
PostHog.capture(
distinct_id: user.posthog_distinct_id,
event: 'user_logged_in',
properties: { login_method: 'email' }
)Feature flags (app/controllers/dashboard_controller.rb)
# Check if a feature flag is enabled
@show_new_feature = PostHog.is_feature_enabled(
'new-dashboard-feature',
user.posthog_distinct_id,
person_properties: user.posthog_properties
)
# Get feature flag payload for configuration
@feature_config = PostHog.get_feature_flag_payload(
'new-dashboard-feature',
user.posthog_distinct_id
)Error tracking — auto-capture
With auto_capture_exceptions: true, unhandled exceptions in controllers are captured automatically. No code needed:
# This exception is automatically captured by posthog-rails
# with the current_user's posthog_distinct_id attached
def show
raise "Something went wrong" # Captured automatically!
endError tracking — manual capture
begin
risky_operation
rescue => e
PostHog.capture_exception(e, current_user.posthog_distinct_id)
endError tracking — Rails.error integration
# posthog-rails subscribes to Rails.error automatically
Rails.error.handle(context: { user_id: user.id }) do
risky_operation
endActiveJob instrumentation
# config: auto_instrument_active_job = true
# Job failures are captured automatically.
# Use the posthog_distinct_id DSL to associate errors with a user.
class ExampleJob < ApplicationJob
posthog_distinct_id ->(distinct_id, *) { distinct_id }
def perform(distinct_id, should_fail: false)
raise "Job failed" # Captured automatically with user context
end
end
# In the controller, pass the distinct_id when enqueuing:
ExampleJob.perform_later(current_user.posthog_distinct_id, should_fail: true)Frontend + Backend integration
This example includes the posthog-js snippet in the layout template to demonstrate how frontend and backend tracking work together.
How it works
- posthog-js (frontend) captures pageviews, clicks, and session replay
- posthog-ruby + posthog-rails (backend) captures business logic events, errors, and feature flag evaluations
- Shared distinct_id — frontend and backend events are linked when the same
distinct_idis used on both sides. Callposthog.identify(user.id.to_s)in posthog-js after login, matching theposthog_distinct_idused on the backend - Session replay lets you watch user sessions where errors occurred
Note: Unlike the Django SDK, posthog-rails does not include a context middleware that reads X-POSTHOG-SESSION-ID or X-POSTHOG-DISTINCT-ID tracing headers. Frontend and backend events are correlated through the shared distinct_id.
When to track frontend vs backend
- Frontend: UI interactions, client-side errors, session replay, pageviews
- Backend: Business logic (signups, purchases), server errors, feature flag evaluations, background jobs
Learn more
- PostHog Ruby on Rails integration
- PostHog Ruby SDK
- PostHog Error Tracking
- Ruby on Rails documentation
.env.example
# PostHog Configuration
POSTHOG_PROJECT_TOKEN=phc_your_project_token_here
POSTHOG_HOST=https://us.i.posthog.com
# Optional: Enable debug mode to see PostHog requests
# POSTHOG_DEBUG=true
app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
private
def current_user
@current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
end
helper_method :current_user
def require_login
unless current_user
redirect_to login_path
end
end
end
app/controllers/burritos_controller.rb
class BurritosController < ApplicationController
before_action :require_login
def show
@burrito_count = session[:burrito_count] || 0
end
def consider
count = (session[:burrito_count] || 0) + 1
session[:burrito_count] = count
user = current_user
# PostHog: Track custom event
PostHog.identify(
distinct_id: user.posthog_distinct_id,
properties: user.posthog_properties
)
PostHog.capture(
distinct_id: user.posthog_distinct_id,
event: 'burrito_considered',
properties: { total_considerations: count }
)
render json: { success: true, count: count }
end
end
app/controllers/dashboard_controller.rb
class DashboardController < ApplicationController
before_action :require_login
def show
user = current_user
# PostHog: Track dashboard view
PostHog.capture(
distinct_id: user.posthog_distinct_id,
event: 'dashboard_viewed',
properties: { is_staff: user.is_staff }
)
# PostHog: Check feature flag
@show_new_feature = PostHog.is_feature_enabled(
'new-dashboard-feature',
user.posthog_distinct_id,
person_properties: user.posthog_properties
)
# PostHog: Get feature flag payload for configuration
@feature_config = PostHog.get_feature_flag_payload(
'new-dashboard-feature',
user.posthog_distinct_id
)
end
def enqueue_test_job
# Enqueue a job that will fail — posthog-rails captures the error automatically.
# The distinct_id is passed so the posthog_distinct_id DSL can associate the error with this user.
ExampleJob.perform_later(current_user.posthog_distinct_id, should_fail: true)
render json: {
success: true,
message: 'Job enqueued. The job will fail and posthog-rails will capture the error automatically.'
}
end
end
app/controllers/errors_controller.rb
class ErrorsController < ApplicationController
before_action :require_login
def test
# Manual exception capture — catch the error and report it explicitly
begin
raise StandardError, 'Test exception from critical operation'
rescue StandardError => e
# PostHog: Manually capture the exception
PostHog.capture_exception(e, current_user.posthog_distinct_id)
PostHog.capture(
distinct_id: current_user.posthog_distinct_id,
event: 'error_triggered',
properties: {
error_type: e.class.name,
error_message: e.message
}
)
render json: {
success: false,
error: e.message,
message: 'Error has been captured by PostHog'
}, status: :internal_server_error
end
end
def test_rails_error
# Rails.error.handle — Rails 7+ error reporting integration.
# posthog-rails subscribes to Rails.error, so exceptions reported
# via Rails.error.handle are automatically captured in PostHog.
Rails.error.handle(context: { user_id: current_user.id }) do
raise StandardError, 'Test error via Rails.error.handle — captured automatically by posthog-rails'
end
render json: {
success: true,
message: 'Error was handled via Rails.error.handle and captured by posthog-rails'
}
end
end
app/controllers/profiles_controller.rb
class ProfilesController < ApplicationController
before_action :require_login
def show
# PostHog: Track profile view
PostHog.capture(
distinct_id: current_user.posthog_distinct_id,
event: 'profile_viewed'
)
end
end
app/controllers/registrations_controller.rb
class RegistrationsController < ApplicationController
def new
redirect_to dashboard_path if current_user
end
def create
user = User.new(
email: params[:email],
password: params[:password],
password_confirmation: params[:password_confirmation]
)
if user.save
session[:user_id] = user.id
# PostHog: Identify the new user and capture signup event
PostHog.identify(
distinct_id: user.posthog_distinct_id,
properties: user.posthog_properties
)
PostHog.capture(
distinct_id: user.posthog_distinct_id,
event: 'user_signed_up',
properties: { signup_method: 'form' }
)
redirect_to dashboard_path
else
flash[:error] = user.errors.full_messages.join(', ')
render :new, status: :unprocessable_entity
end
end
end
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
def new
redirect_to dashboard_path if current_user
end
def create
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
session[:user_id] = user.id
# PostHog: Identify the user and capture login event
PostHog.identify(
distinct_id: user.posthog_distinct_id,
properties: user.posthog_properties
)
PostHog.capture(
distinct_id: user.posthog_distinct_id,
event: 'user_logged_in',
properties: { login_method: 'email' }
)
redirect_to dashboard_path
else
flash[:error] = 'Invalid email or password'
render :new, status: :unprocessable_entity
end
end
def destroy
if current_user
# PostHog: Track logout before session ends
PostHog.capture(
distinct_id: current_user.posthog_distinct_id,
event: 'user_logged_out'
)
end
session.delete(:user_id)
redirect_to login_path
end
end
app/jobs/application_job.rb
class ApplicationJob < ActiveJob::Base
end
app/jobs/example_job.rb
# Example ActiveJob demonstrating posthog-rails auto-instrumentation.
#
# When auto_instrument_active_job is enabled in the PostHog config,
# posthog-rails automatically captures exceptions from failed jobs.
# The job class name, queue, and arguments are included as properties
# on the error event.
#
# Use the posthog_distinct_id DSL to associate job errors with a user.
# The proc receives the same arguments as perform and should return
# the distinct_id string. Without this, job errors have no user context.
class ExampleJob < ApplicationJob
queue_as :default
# Extract distinct_id from the first argument so posthog-rails
# can associate the error with the user who triggered the job.
posthog_distinct_id ->(distinct_id, *) { distinct_id }
def perform(distinct_id, should_fail: false)
if should_fail
raise StandardError, 'Example job failure - this error is automatically captured by posthog-rails'
end
Rails.logger.info "ExampleJob completed successfully for #{distinct_id}"
end
end
app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
end
app/models/user.rb
class User < ApplicationRecord
has_secure_password
validates :email, presence: true, uniqueness: true
# Called by posthog-rails for automatic user association in error reports.
# When auto_capture_exceptions and capture_user_context are enabled,
# posthog-rails calls this method on current_user to get the distinct_id.
# The primary key, not the email: an email can change, which would split one
# person's history in two, and it is PII on every event's identity.
def posthog_distinct_id
id.to_s
end
# Helper used by controllers when calling PostHog.identify to set person properties.
# These properties appear on the person profile in PostHog.
def posthog_properties
{
email: email,
is_staff: is_staff,
date_joined: created_at&.iso8601
}
end
end
app/views/burritos/show.html.erb
<% content_for(:title) { 'Burrito - PostHog Rails example' } %>
<div class="card">
<h1>Burrito consideration tracker</h1>
<p>This page demonstrates custom event tracking with PostHog.</p>
</div>
<div class="card" style="text-align: center;">
<h2>Times considered</h2>
<div class="count" id="burrito-count"><%= @burrito_count %></div>
<button onclick="considerBurrito()" style="font-size: 18px; padding: 15px 30px;">
Consider a burrito
</button>
</div>
<div class="card">
<h3>How event tracking works</h3>
<p>Each time you click the button, a <code>burrito_considered</code> event is sent to PostHog:</p>
<pre style="background: #f3f4f6; padding: 15px; border-radius: 5px; overflow-x: auto; margin-top: 15px;"><code>PostHog.capture(
distinct_id: user.posthog_distinct_id,
event: 'burrito_considered',
properties: { total_considerations: count }
)</code></pre>
</div>
<% content_for :scripts do %>
<script>
async function considerBurrito() {
try {
const response = await fetch('/api/burrito/consider', {
method: 'POST',
headers: {
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content,
'Content-Type': 'application/json',
},
});
const data = await response.json();
if (data.success) {
document.getElementById('burrito-count').textContent = data.count;
}
} catch (error) {
console.error('Error:', error);
}
}
</script>
<% end %>
app/views/dashboard/show.html.erb
<% content_for(:title) { 'Dashboard - PostHog Rails example' } %>
<div class="card">
<h1>Dashboard</h1>
<p>Welcome back, <strong><%= current_user.email %></strong>!</p>
</div>
<div class="card">
<h2>Feature flags</h2>
<p>Feature flags allow you to control feature rollouts and run A/B tests.</p>
<% if @show_new_feature %>
<div class="feature-flag">
<h3>New feature enabled!</h3>
<p>
This section is only visible because the <code>new-dashboard-feature</code>
flag is enabled for your user.
</p>
<% if @feature_config %>
<p><strong>Feature config:</strong> <%= @feature_config %></p>
<% end %>
</div>
<% else %>
<div style="background: #f3f4f6; padding: 15px; border-radius: 8px; margin-top: 15px;">
<p>
The <code>new-dashboard-feature</code> flag is not enabled for your user.
Create this flag in your PostHog project to see it in action.
</p>
</div>
<% end %>
</div>
<div class="card">
<h2>ActiveJob instrumentation</h2>
<p>
Click below to enqueue a background job that will fail.
<code>posthog-rails</code> automatically captures the exception — no extra code needed.
</p>
<button onclick="enqueueTestJob()" style="margin-top: 10px;">Enqueue failing job</button>
<div id="job-result" style="margin-top: 15px; display: none;"></div>
</div>
<div class="card">
<h3>How feature flags work</h3>
<pre style="background: #f3f4f6; padding: 15px; border-radius: 5px; overflow-x: auto;"><code># Check if a feature flag is enabled
show_feature = PostHog.is_feature_enabled(
'new-dashboard-feature',
user.posthog_distinct_id,
person_properties: user.posthog_properties
)
# Get feature flag payload for configuration
config = PostHog.get_feature_flag_payload(
'new-dashboard-feature',
user.posthog_distinct_id
)</code></pre>
</div>
<% content_for :scripts do %>
<script>
async function enqueueTestJob() {
const resultDiv = document.getElementById('job-result');
try {
const response = await fetch('/api/test-job', {
method: 'POST',
headers: {
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content,
'Content-Type': 'application/json',
},
});
const data = await response.json();
resultDiv.style.display = 'block';
resultDiv.innerHTML = '<div class="flash success">' + data.message + '</div>';
} catch (error) {
resultDiv.style.display = 'block';
resultDiv.innerHTML = '<div class="flash error">Request failed: ' + error + '</div>';
}
}
</script>
<% end %>
app/views/layouts/application.html.erb
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= content_for?(:title) ? yield(:title) : 'PostHog Rails example' %></title>
<%= csrf_meta_tags %>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
line-height: 1.6;
background-color: #f5f5f5;
color: #333;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
nav {
background: #1d4ed8;
padding: 15px 20px;
margin-bottom: 30px;
}
nav a {
color: white;
text-decoration: none;
margin-right: 20px;
}
nav a:hover {
text-decoration: underline;
}
.card {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1, h2, h3 {
margin-bottom: 15px;
color: #1d4ed8;
}
button, .btn {
background: #1d4ed8;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
display: inline-block;
text-decoration: none;
}
button:hover, .btn:hover {
background: #1e40af;
}
button.danger {
background: #dc2626;
}
button.danger:hover {
background: #b91c1c;
}
input {
width: 100%;
padding: 10px;
margin-bottom: 15px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 14px;
}
.flash {
padding: 10px 15px;
border-radius: 5px;
margin-bottom: 20px;
}
.flash.error {
background: #fee2e2;
color: #dc2626;
}
.flash.success {
background: #d1fae5;
color: #059669;
}
.feature-flag {
background: #fef3c7;
border: 2px dashed #f59e0b;
padding: 15px;
border-radius: 8px;
margin: 20px 0;
}
code {
background: #f3f4f6;
padding: 2px 6px;
border-radius: 3px;
font-family: monospace;
}
.count {
font-size: 48px;
font-weight: bold;
color: #1d4ed8;
text-align: center;
padding: 20px;
}
</style>
<!-- PostHog frontend tracking (posthog-js) -->
<script>
// POSTHOG_BROWSER_SNIPPET_START
!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],Object.defineProperty(u,"toString",{configurable:!0,enumerable:!0,writable:!0,value:function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e}}),Object.defineProperty(u.people,"toString",{configurable:!0,enumerable:!0,writable:!0,value:function(){return u.toString(1)+".people (stub)"}}),o="init capture register register_once register_for_session unregister unregister_for_session getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty createPersonProfile opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing debug getPageViewId".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
// POSTHOG_BROWSER_SNIPPET_END
posthog.init('<%= ENV["POSTHOG_PROJECT_TOKEN"] %>', {
api_host: '<%= ENV.fetch("POSTHOG_HOST", "https://us.i.posthog.com") %>',
person_profiles: 'identified_only'
})
</script>
</head>
<body>
<% if current_user %>
<nav style="display: flex; align-items: center;">
<a href="<%= dashboard_path %>">Dashboard</a>
<a href="<%= burrito_path %>">Burrito</a>
<a href="<%= profile_path %>">Profile</a>
<%= button_to 'Logout (' + current_user.email + ')', logout_path, method: :delete, form: { style: 'margin-left: auto;' }, style: 'background: transparent; border: none; color: white; cursor: pointer; font-size: inherit; padding: 0;' %>
</nav>
<% end %>
<div class="container">
<% if flash[:error] %>
<div class="flash error"><%= flash[:error] %></div>
<% end %>
<% if flash[:notice] %>
<div class="flash success"><%= flash[:notice] %></div>
<% end %>
<%= yield %>
</div>
<%= yield :scripts %>
</body>
</html>
app/views/profiles/show.html.erb
<% content_for(:title) { 'Profile - PostHog Rails example' } %>
<div class="card">
<h1>Profile</h1>
<p>This page demonstrates error tracking with PostHog and <code>posthog-rails</code>.</p>
</div>
<div class="card">
<h2>User information</h2>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 10px; border-bottom: 1px solid #eee;"><strong>Email:</strong></td>
<td style="padding: 10px; border-bottom: 1px solid #eee;"><%= current_user.email %></td>
</tr>
<tr>
<td style="padding: 10px; border-bottom: 1px solid #eee;"><strong>Date Joined:</strong></td>
<td style="padding: 10px; border-bottom: 1px solid #eee;"><%= current_user.created_at %></td>
</tr>
<tr>
<td style="padding: 10px;"><strong>Staff Status:</strong></td>
<td style="padding: 10px;"><%= current_user.is_staff ? 'Yes' : 'No' %></td>
</tr>
</table>
</div>
<div class="card">
<h2>Error tracking demo</h2>
<p>Click the buttons below to trigger different types of errors and see how PostHog captures them.</p>
<div style="margin-top: 20px;">
<button class="danger" onclick="triggerError('manual')">
Manual capture_exception
</button>
<button class="danger" onclick="triggerError('rails')" style="margin-left: 10px;">
Rails.error.handle
</button>
</div>
<div id="error-result" style="margin-top: 20px; display: none;"></div>
</div>
<div class="card">
<h3>How error tracking works</h3>
<p><strong>Auto-capture (no code needed):</strong></p>
<pre style="background: #f3f4f6; padding: 15px; border-radius: 5px; overflow-x: auto;"><code># config/initializers/posthog.rb
PostHog::Rails.configure do |config|
config.auto_capture_exceptions = true
config.capture_user_context = true
end
# That's it! Unhandled exceptions are captured automatically.</code></pre>
<p style="margin-top: 15px;"><strong>Manual capture:</strong></p>
<pre style="background: #f3f4f6; padding: 15px; border-radius: 5px; overflow-x: auto;"><code>begin
risky_operation
rescue => e
PostHog.capture_exception(e, user.posthog_distinct_id)
end</code></pre>
<p style="margin-top: 15px;"><strong>Rails.error integration:</strong></p>
<pre style="background: #f3f4f6; padding: 15px; border-radius: 5px; overflow-x: auto;"><code># posthog-rails subscribes to Rails.error automatically
Rails.error.handle(context: { user_id: user.id }) do
risky_operation
end</code></pre>
</div>
<% content_for :scripts do %>
<script>
async function triggerError(type) {
const resultDiv = document.getElementById('error-result');
const url = type === 'rails' ? '/api/test-rails-error' : '/api/test-error';
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content,
'Content-Type': 'application/json',
},
});
const data = await response.json();
resultDiv.style.display = 'block';
if (data.success) {
resultDiv.innerHTML = '<div class="flash success">' + data.message + '</div>';
} else {
resultDiv.innerHTML = '<div class="flash error"><strong>Error captured:</strong> ' + data.error + '<br><small>' + data.message + '</small></div>';
}
} catch (error) {
resultDiv.style.display = 'block';
resultDiv.innerHTML = '<div class="flash error">Request failed: ' + error + '</div>';
}
}
</script>
<% end %>
app/views/registrations/new.html.erb
<% content_for(:title) { 'Sign Up - PostHog Rails example' } %>
<div class="card">
<h1>Sign Up</h1>
<p>Create an account to see PostHog analytics in action.</p>
<form action="<%= signup_path %>" method="post" style="margin-top: 20px;">
<%= hidden_field_tag :authenticity_token, form_authenticity_token %>
<input type="email" name="email" placeholder="Email" required>
<input type="password" name="password" placeholder="Password" required>
<input type="password" name="password_confirmation" placeholder="Confirm Password" required>
<button type="submit">Sign Up</button>
</form>
<p style="margin-top: 15px; color: #666; font-size: 14px;">
Already have an account? <a href="<%= login_path %>">Login</a>
</p>
</div>
app/views/sessions/new.html.erb
<% content_for(:title) { 'Login - PostHog Rails example' } %>
<div class="card">
<h1>PostHog Rails example</h1>
<p>Welcome! This example demonstrates PostHog integration with Ruby on Rails, including automatic error tracking via <code>posthog-rails</code>.</p>
</div>
<div class="card">
<h2>Login</h2>
<p>Login to see PostHog analytics in action.</p>
<form action="<%= login_path %>" method="post" style="margin-top: 20px;">
<%= hidden_field_tag :authenticity_token, form_authenticity_token %>
<input type="email" name="email" placeholder="Email" required>
<input type="password" name="password" placeholder="Password" required>
<button type="submit">Login</button>
</form>
<p style="margin-top: 15px; color: #666; font-size: 14px;">
Don't have an account? <a href="<%= signup_path %>">Sign up</a><br>
Tip: Run <code>bin/rails db:seed</code> to create admin@example.com / admin
</p>
</div>
<div class="card">
<h3>What this example demonstrates</h3>
<ul style="padding-left: 20px;">
<li><strong>User identification</strong> — Users are identified with <code>PostHog.identify</code> on login</li>
<li><strong>Event tracking</strong> — Custom events captured with <code>PostHog.capture</code></li>
<li><strong>Feature flags</strong> — Conditional features with <code>PostHog.is_feature_enabled</code></li>
<li><strong>Error tracking (auto)</strong> — Unhandled exceptions captured automatically by <code>posthog-rails</code></li>
<li><strong>Error tracking (manual)</strong> — Handled errors captured with <code>PostHog.capture_exception</code></li>
<li><strong>ActiveJob instrumentation</strong> — Background job failures captured automatically</li>
<li><strong>Rails.error integration</strong> — Rails 7+ error reporting captured by <code>posthog-rails</code></li>
<li><strong>Frontend tracking</strong> — posthog-js captures pageviews and session replay</li>
</ul>
</div>
config.ru
require_relative 'config/environment'
run Rails.application
config/application.rb
require_relative 'boot'
require 'rails/all'
Bundler.require(*Rails.groups)
module PosthogExample
class Application < Rails::Application
config.load_defaults 7.1
# Use SQLite for all stores
config.active_job.queue_adapter = :async
end
end
config/boot.rb
ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
require 'bundler/setup'
config/environment.rb
require_relative 'application'
Rails.application.initialize!
config/environments/development.rb
require 'active_support/core_ext/integer/time'
Rails.application.configure do
config.enable_reloading = true
config.eager_load = false
config.consider_all_requests_local = true
config.server_timing = true
# Secret key for development (not used in production)
config.secret_key_base = 'dev-secret-key-for-posthog-example-only'
config.action_controller.perform_caching = false
config.cache_store = :memory_store
config.active_support.deprecation = :log
config.active_support.disallowed_deprecation = :raise
config.active_support.disallowed_deprecation_warnings = []
config.active_record.migration_error = :page_load
config.active_record.verbose_query_logs = true
end
config/initializers/posthog.rb
# PostHog configuration with posthog-rails auto-instrumentation
#
# The posthog-rails gem provides:
# - Automatic exception capture for unhandled controller errors
# - ActiveJob instrumentation for background job failures
# - User context detection from current_user
# - Rails.error integration for rescued exceptions
PostHog.init do |config|
config.api_key = ENV.fetch('POSTHOG_PROJECT_TOKEN', nil)
config.host = ENV.fetch('POSTHOG_HOST', 'https://us.i.posthog.com')
end
PostHog::Rails.configure do |config|
# Auto-capture unhandled exceptions in controllers
config.auto_capture_exceptions = true
# Also capture exceptions that Rails rescues (e.g. ActiveRecord::RecordNotFound)
config.report_rescued_exceptions = true
# Auto-instrument ActiveJob failures
config.auto_instrument_active_job = true
# Automatically associate errors with the current user
config.capture_user_context = true
config.current_user_method = :current_user
config.user_id_method = :posthog_distinct_id
end
config/routes.rb
Rails.application.routes.draw do
# Auth
get 'login', to: 'sessions#new'
post 'login', to: 'sessions#create'
delete 'logout', to: 'sessions#destroy'
get 'signup', to: 'registrations#new'
post 'signup', to: 'registrations#create'
# App
get 'dashboard', to: 'dashboard#show'
get 'burrito', to: 'burritos#show'
post 'api/burrito/consider', to: 'burritos#consider'
get 'profile', to: 'profiles#show'
# Error tracking demos
post 'api/test-error', to: 'errors#test'
post 'api/test-rails-error', to: 'errors#test_rails_error'
# Background job demo
post 'api/test-job', to: 'dashboard#enqueue_test_job'
root 'sessions#new'
end
db/migrate/20240101000000_create_users.rb
class CreateUsers < ActiveRecord::Migration[7.1]
def change
create_table :users do |t|
t.string :email, null: false
t.string :password_digest, null: false
t.boolean :is_staff, default: false
t.timestamps
end
add_index :users, :email, unique: true
end
end
db/schema.rb
# This file is auto-generated from the current state of the database. Instead
# of editing this file, please use the migrations feature of Active Record to
# incrementally modify your database, and then regenerate this schema definition.
#
# This file is the source Rails uses to define your schema when running `bin/rails
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
# be faster and is potentially less error prone than running all of your
# migrations from scratch. Old migrations may fail to apply correctly if those
# migrations use external dependencies or application code.
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2024_01_01_000000) do
create_table "users", force: :cascade do |t|
t.string "email", null: false
t.string "password_digest", null: false
t.boolean "is_staff", default: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["email"], name: "index_users_on_email", unique: true
end
end
db/seeds.rb
# Create a default admin user for testing
User.find_or_create_by!(email: 'admin@example.com') do |user|
user.password = 'admin'
user.password_confirmation = 'admin'
user.is_staff = true
end
puts 'Seed data created: admin@example.com / admin'
Gemfile
source 'https://rubygems.org'
gem 'rails', '~> 7.1'
gem 'sqlite3', '~> 1.7'
gem 'puma', '~> 6.0'
gem 'bcrypt', '~> 3.1'
gem 'dotenv-rails', '~> 3.0'
# PostHog
gem 'posthog-ruby', '~> 3.0'
gem 'posthog-rails'
Rakefile
require_relative 'config/application'
Rails.application.load_tasks
references/EXAMPLE-ruby.md
PostHog ruby Example Project
Repository: https://github.com/PostHog/context-mill Path: example-apps/ruby
README.md
PostHog Ruby Example - CLI Todo App
A simple command-line todo application built with plain Ruby (no frameworks) demonstrating PostHog integration for CLIs, scripts, data pipelines, and non-web Ruby applications.
Purpose
This example serves as:
- Verification that the context-mill wizard works for plain Ruby projects
- Reference implementation of PostHog best practices for non-framework Ruby code
- Working example you can run and modify
Features Demonstrated
- Instance-based API - Uses
PostHog::Client.new(...)for explicit client management - Proper shutdown - Uses
shutdowninensureblock to flush events before exit - Event tracking - Captures user actions with
distinct_idand properties - User identification - Associates properties with users via
identify - Error handling - Manual exception capture for handled errors
Quick Start
1. Install Dependencies
# Install bundler if needed
gem install bundler
# Install dependencies
bundle install2. Configure PostHog
# Copy environment template
cp .env.example .env
# Edit .env and add your PostHog project token
# POSTHOG_PROJECT_TOKEN=phc_your_project_token_here
# POSTHOG_HOST=https://us.i.posthog.com3. Run the App
# Add a todo
ruby todo.rb add "Buy groceries"
# List all todos
ruby todo.rb list
# Complete a todo
ruby todo.rb complete 1
# Delete a todo
ruby todo.rb delete 1
# Show statistics
ruby todo.rb statsWhat Gets Tracked
The app tracks these events in PostHog:
| Event | Properties | Purpose |
|---|---|---|
todo_added |
todo_id, todo_length, total_todos |
When user adds a new todo |
todos_viewed |
total_todos, completed_todos |
When user lists todos |
todo_completed |
todo_id, time_to_complete_hours |
When user completes a todo |
todo_deleted |
todo_id, was_completed |
When user deletes a todo |
stats_viewed |
total_todos, completed_todos, pending_todos |
When user views stats |
Code Structure
basics/ruby/
├── todo.rb # Main CLI application
├── Gemfile # Ruby dependencies
├── .env.example # Environment variable template
├── .gitignore # Git ignore rules
└── README.md # This fileKey Implementation Patterns
1. Instance-Based Initialization
require 'posthog-ruby'
posthog = PostHog::Client.new(
api_key: api_key,
host: 'https://us.i.posthog.com',
on_error: proc { |status, msg| puts "PostHog error: #{status} - #{msg}" }
)2. Event Tracking Pattern
# Track events with distinct_id
posthog.capture(
distinct_id: 'user_123',
event: 'event_name',
properties: { key: 'value' }
)3. Proper Shutdown
begin
# Your application code
ensure
# Always call shutdown to flush events and close connections
posthog&.shutdown
end4. Identifying Users
# Identify users (optional - adds user properties)
posthog.identify(
distinct_id: 'user_123',
properties: { email: 'user@example.com', plan: 'pro' }
)Running Without PostHog
The app works fine without PostHog configured - it simply won't track analytics. You'll see a warning message but the app continues to function normally.
Next Steps
- Modify
todo.rbto experiment with PostHog tracking - Add new commands and track their usage
- Explore feature flags:
posthog.is_feature_enabled('flag-name', 'user_id') - Check your PostHog dashboard to see tracked events
Learn More
.env.example
# PostHog Configuration
POSTHOG_PROJECT_TOKEN=phc_your_project_token_here
POSTHOG_HOST=https://us.i.posthog.com
# Optional: Enable debug mode to see PostHog requests
# POSTHOG_DEBUG=true
Gemfile
source 'https://rubygems.org'
gem 'posthog-ruby', '~> 3.3'
gem 'dotenv', '~> 3.0'
todo.rb
#!/usr/bin/env ruby
# frozen_string_literal: true
# Simple CLI Todo App with PostHog Analytics
#
# A minimal plain Ruby CLI application demonstrating PostHog integration
# for non-framework Ruby projects (CLIs, scripts, data pipelines, etc.).
require 'json'
require 'securerandom'
require 'time'
require 'dotenv/load'
require 'posthog'
# Data file location
DATA_FILE = File.join(Dir.home, '.todo_app.json')
def initialize_posthog
# Initialize PostHog with instance-based API.
# Returns PostHog client or nil if project token not configured.
project_token = ENV['POSTHOG_PROJECT_TOKEN']
unless project_token
puts 'WARNING: PostHog not configured (POSTHOG_PROJECT_TOKEN not set)'
puts ' App will work but analytics won\'t be tracked'
return nil
end
PostHog::Client.new(
api_key: project_token,
host: ENV.fetch('POSTHOG_HOST', 'https://us.i.posthog.com'),
on_error: proc { |status, msg| puts "PostHog error: #{status} - #{msg}" }
)
end
def get_user_id
# Get or create a user ID for this installation.
# Uses a UUID stored in the data file to represent this user.
if File.exist?(DATA_FILE)
data = JSON.parse(File.read(DATA_FILE))
return data['user_id'] if data['user_id']
end
"user_#{SecureRandom.hex(4)}"
end
def load_todos
# Load todos from disk.
return { 'user_id' => get_user_id, 'todos' => [] } unless File.exist?(DATA_FILE)
JSON.parse(File.read(DATA_FILE))
end
def save_todos(data)
# Save todos to disk.
File.write(DATA_FILE, JSON.pretty_generate(data))
end
def track_event(posthog, event_name, properties = {})
# Track an event with PostHog.
return unless posthog
posthog.capture(
distinct_id: get_user_id,
event: event_name,
properties: properties
)
end
def cmd_add(text, posthog)
# Add a new todo item.
data = load_todos
todo = {
'id' => data['todos'].length + 1,
'text' => text,
'completed' => false,
'created_at' => Time.now.iso8601
}
data['todos'] << todo
save_todos(data)
puts "Added todo ##{todo['id']}: #{todo['text']}"
track_event(posthog, 'todo_added', {
'todo_id' => todo['id'],
'todo_length' => todo['text'].length,
'total_todos' => data['todos'].length
})
end
def cmd_list(posthog)
# List all todos.
data = load_todos
if data['todos'].empty?
puts "No todos yet! Add one with: ruby todo.rb add 'Your task'"
return
end
puts "\nYour Todos (#{data['todos'].length} total):\n\n"
data['todos'].each do |todo|
status = todo['completed'] ? 'X' : ' '
puts " [#{status}] ##{todo['id']}: #{todo['text']}"
end
puts
track_event(posthog, 'todos_viewed', {
'total_todos' => data['todos'].length,
'completed_todos' => data['todos'].count { |t| t['completed'] }
})
end
def cmd_complete(id, posthog)
# Mark a todo as completed.
data = load_todos
todo = data['todos'].find { |t| t['id'] == id }
unless todo
puts "ERROR: Todo ##{id} not found"
return
end
if todo['completed']
puts "Todo ##{id} is already completed"
return
end
todo['completed'] = true
todo['completed_at'] = Time.now.iso8601
save_todos(data)
puts "Completed todo ##{todo['id']}: #{todo['text']}"
time_to_complete = (Time.parse(todo['completed_at']) - Time.parse(todo['created_at'])) / 3600.0
track_event(posthog, 'todo_completed', {
'todo_id' => todo['id'],
'time_to_complete_hours' => time_to_complete
})
end
def cmd_delete(id, posthog)
# Delete a todo.
data = load_todos
todo = data['todos'].find { |t| t['id'] == id }
unless todo
puts "ERROR: Todo ##{id} not found"
return
end
data['todos'].delete(todo)
save_todos(data)
puts "Deleted todo ##{id}"
track_event(posthog, 'todo_deleted', {
'todo_id' => todo['id'],
'was_completed' => todo['completed']
})
end
def cmd_stats(posthog)
# Show usage statistics.
data = load_todos
total = data['todos'].length
completed = data['todos'].count { |t| t['completed'] }
pending = total - completed
puts "\nStats:\n\n"
puts " Total todos: #{total}"
puts " Completed: #{completed}"
puts " Pending: #{pending}"
puts " Completion rate: #{total > 0 ? format('%.1f', completed.to_f / total * 100) : '0.0'}%"
puts
track_event(posthog, 'stats_viewed', {
'total_todos' => total,
'completed_todos' => completed,
'pending_todos' => pending
})
end
def print_usage
puts <<~USAGE
Simple todo app with PostHog analytics
Usage:
ruby todo.rb add "Todo text" Add a new todo
ruby todo.rb list List all todos
ruby todo.rb complete <id> Mark todo as completed
ruby todo.rb delete <id> Delete a todo
ruby todo.rb stats Show statistics
USAGE
end
# Main entry point
posthog = nil
begin
posthog = initialize_posthog
command = ARGV[0]
unless command
print_usage
exit 0
end
case command
when 'add'
text = ARGV[1]
unless text
puts 'ERROR: Please provide todo text'
puts 'Usage: ruby todo.rb add "Your task"'
exit 1
end
cmd_add(text, posthog)
when 'list'
cmd_list(posthog)
when 'complete'
id = ARGV[1]&.to_i
unless id && id > 0
puts 'ERROR: Please provide a valid todo ID'
puts 'Usage: ruby todo.rb complete <id>'
exit 1
end
cmd_complete(id, posthog)
when 'delete'
id = ARGV[1]&.to_i
unless id && id > 0
puts 'ERROR: Please provide a valid todo ID'
puts 'Usage: ruby todo.rb delete <id>'
exit 1
end
cmd_delete(id, posthog)
when 'stats'
cmd_stats(posthog)
else
puts "ERROR: Unknown command '#{command}'"
print_usage
exit 1
end
rescue StandardError => e
puts "ERROR: #{e.message}"
# Manually capture handled errors
posthog&.capture_exception(e, get_user_id)
exit 1
ensure
# IMPORTANT: Always shutdown PostHog to flush events
posthog&.shutdown
end
references/EXAMPLE-sveltekit.md
PostHog sveltekit Example Project
Repository: https://github.com/PostHog/context-mill Path: example-apps/sveltekit
README.md
SvelteKit PostHog example
This example demonstrates how to integrate PostHog with a SvelteKit application, including:
- Client-side PostHog initialization using SvelteKit hooks
- Server-side PostHog tracking with the Node.js SDK
- Reverse proxy to avoid ad blockers
- User identification and event tracking
- Error tracking with
captureException - Session replay configuration
Getting started
1. Install dependencies
npm install2. Configure environment variables
Copy the example environment file and add your PostHog credentials:
cp .env.example .envEdit .env with your PostHog project token:
PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token_here
PUBLIC_POSTHOG_HOST=https://us.i.posthog.comYou can find your project token in your PostHog project settings.
3. Run the development server
npm run devOpen http://localhost:5173 to view the app.
Project structure
src/
├── lib/
│ ├── auth.svelte.ts # Auth context with Svelte 5 runes
│ ├── components/
│ │ └── Header.svelte # Navigation component
│ └── server/
│ └── posthog.ts # Server-side PostHog singleton
├── routes/
│ ├── +layout.svelte # Root layout with auth provider
│ ├── +page.svelte # Home/login page
│ ├── burrito/
│ │ └── +page.svelte # Event tracking demo
│ ├── profile/
│ │ └── +page.svelte # Error tracking demo
│ └── api/
│ └── auth/
│ └── login/
│ └── +server.ts # Login API with server-side tracking
├── hooks.client.ts # Client-side PostHog init + error handling
├── hooks.server.ts # Server hooks with reverse proxy
├── app.css # Global styles
└── app.html # HTML templateKey integration points
Client-side initialization (src/hooks.client.ts)
PostHog is initialized in the SvelteKit client hooks init function, which runs once when the app starts:
import posthog from 'posthog-js';
export async function init() {
posthog.init(PUBLIC_POSTHOG_PROJECT_TOKEN, {
api_host: '/ingest',
ui_host: 'https://us.posthog.com',
defaults: '2026-01-30',
capture_exceptions: true
});
}Server-side tracking (src/lib/server/posthog.ts)
A singleton pattern ensures one PostHog client instance for server-side tracking:
import { PostHog } from 'posthog-node';
let posthogClient: PostHog | null = null;
export function getPostHogClient() {
if (!posthogClient) {
posthogClient = new PostHog(PUBLIC_POSTHOG_PROJECT_TOKEN, {
host: PUBLIC_POSTHOG_HOST,
flushAt: 1,
flushInterval: 0
});
}
return posthogClient;
}Reverse proxy (src/hooks.server.ts)
The server hooks handle proxies requests through /ingest to avoid ad blockers:
export const handle: Handle = async ({ event, resolve }) => {
if (event.url.pathname.startsWith('/ingest')) {
const pathname = event.url.pathname.replace('/ingest', '');
const host = pathname.startsWith('/static')
? 'https://us-assets.i.posthog.com'
: 'https://us.i.posthog.com';
// Proxy to PostHog...
}
return resolve(event);
};User identification
When a user logs in, they are identified in PostHog:
import posthog from 'posthog-js';
// On login
posthog.identify(userId, { username });
posthog.capture('user_logged_in', { username });
// On logout
posthog.capture('user_logged_out');
posthog.reset();Error tracking
Errors are automatically captured via the handleError hook:
export const handleError: HandleClientError = async ({ error }) => {
posthog.captureException(error);
return { message: 'An error occurred' };
};You can also manually capture errors:
try {
// Some operation
} catch (err) {
posthog.captureException(err);
}Session replay configuration
For session replay to work correctly, add this to svelte.config.js:
export default {
kit: {
paths: {
relative: false
}
}
};Features demonstrated
- Login page (
/) - User authentication with PostHog identification - Burrito page (
/burrito) - Custom event tracking with properties - Profile page (
/profile) - Error tracking demonstration
Learn more
.env.example
# PostHog configuration
# Get your PostHog project token from: https://app.posthog.com/project/settings
PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token_here
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
.npmrc
engine-strict=true
min-release-age=7
src/app.d.ts
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
src/app.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
src/hooks.client.ts
import posthog from 'posthog-js';
import { PUBLIC_POSTHOG_PROJECT_TOKEN } from '$env/static/public';
import type { HandleClientError } from '@sveltejs/kit';
// Initialize PostHog when the app starts in the browser
export async function init() {
posthog.init(PUBLIC_POSTHOG_PROJECT_TOKEN, {
api_host: '/ingest',
ui_host: 'https://us.posthog.com',
defaults: '2026-01-30',
capture_exceptions: true
});
}
// Capture client-side errors with PostHog
export const handleError: HandleClientError = async ({ error, status, message }) => {
posthog.captureException(error);
return {
message,
status
};
};
src/hooks.server.ts
import type { Handle, HandleServerError } from '@sveltejs/kit';
import { getPostHogClient } from '$lib/server/posthog';
// Handle requests - includes reverse proxy for PostHog
export const handle: Handle = async ({ event, resolve }) => {
const { pathname } = event.url;
// Reverse proxy for PostHog - route /ingest requests to PostHog servers
if (pathname.startsWith('/ingest')) {
const useAssetHost = pathname.startsWith('/ingest/static/') || pathname.startsWith('/ingest/array/')
const hostname = useAssetHost ? 'us-assets.i.posthog.com' : 'us.i.posthog.com';
const url = new URL(event.request.url);
url.protocol = 'https:';
url.hostname = hostname;
url.port = '443';
url.pathname = pathname.replace(/^\/ingest/, '');
const headers = new Headers(event.request.headers);
headers.set('host', hostname);
headers.set('accept-encoding', '');
const clientIp = event.request.headers.get('x-forwarded-for') || event.getClientAddress();
if (clientIp) {
headers.set('x-forwarded-for', clientIp);
}
const response = await fetch(url.toString(), {
method: event.request.method,
headers,
body: event.request.body,
// @ts-expect-error - duplex is required for streaming request bodies
duplex: 'half'
});
return response;
}
return resolve(event);
};
// Capture server-side errors with PostHog
export const handleError: HandleServerError = async ({ error, status, message }) => {
const posthog = getPostHogClient();
posthog.capture({
distinctId: 'server',
event: 'server_error',
properties: {
error: error instanceof Error ? error.message : String(error),
status,
message
}
});
// handleError runs per request; flush so the enqueued event sends before it returns
await posthog.flush();
return {
message,
status
};
};
src/lib/auth.svelte.ts
import { getContext, setContext } from 'svelte';
import posthog from 'posthog-js';
import { browser } from '$app/environment';
export interface User {
username: string;
burritoConsiderations: number;
}
const AUTH_KEY = Symbol('auth');
// Class-based auth state using Svelte 5 $state in class fields
// This is the recommended pattern for encapsulating reactive state + behavior
export class AuthState {
user = $state<User | null>(null);
constructor() {
// Restore user from localStorage on creation (browser only)
if (browser) {
const storedUsername = localStorage.getItem('currentUser');
if (storedUsername) {
this.user = { username: storedUsername, burritoConsiderations: 0 };
}
}
}
login = async (username: string, password: string): Promise<boolean> => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (response.ok) {
const { user: userData } = await response.json();
this.user = userData as User;
if (browser) {
localStorage.setItem('currentUser', username);
posthog.identify(username, { username });
posthog.capture('user_logged_in', { username });
}
return true;
}
return false;
} catch (error) {
console.error('Login error:', error);
return false;
}
};
logout = (): void => {
if (browser) {
posthog.capture('user_logged_out');
posthog.reset();
localStorage.removeItem('currentUser');
}
this.user = null;
};
incrementBurritoConsiderations = (): void => {
if (this.user) {
this.user = {
...this.user,
burritoConsiderations: this.user.burritoConsiderations + 1
};
}
};
}
export function setAuthContext(auth: AuthState) {
setContext(AUTH_KEY, auth);
}
export function getAuthContext(): AuthState {
return getContext<AuthState>(AUTH_KEY);
}
src/lib/components/Header.svelte
<script lang="ts">
import { getAuthContext } from '$lib/auth.svelte';
const auth = getAuthContext();
</script>
<header class="header">
<div class="header-container">
<nav>
<a href="/">Home</a>
{#if auth.user}
<a href="/burrito">Burrito</a>
<a href="/profile">Profile</a>
{/if}
</nav>
<div class="user-section">
{#if auth.user}
<span>Welcome, {auth.user.username}</span>
<button class="btn-logout" onclick={() => auth.logout()}>Logout</button>
{/if}
</div>
</div>
</header>
src/lib/index.ts
// place files you want to import through the `$lib` alias in this folder.
src/lib/server/posthog.ts
import { PostHog } from 'posthog-node';
import { PUBLIC_POSTHOG_PROJECT_TOKEN, PUBLIC_POSTHOG_HOST } from '$env/static/public';
let posthogClient: PostHog | null = null;
export function getPostHogClient() {
if (!posthogClient) {
posthogClient = new PostHog(PUBLIC_POSTHOG_PROJECT_TOKEN, {
host: PUBLIC_POSTHOG_HOST,
flushAt: 1,
flushInterval: 0
});
}
return posthogClient;
}
export async function shutdownPostHog() {
if (posthogClient) {
await posthogClient.shutdown();
}
}
src/routes/+layout.svelte
<script lang="ts">
import { AuthState, setAuthContext } from '$lib/auth.svelte';
import Header from '$lib/components/Header.svelte';
import '../app.css';
let { children } = $props();
// Create and provide auth context
const auth = new AuthState();
setAuthContext(auth);
</script>
<svelte:head>
<title>Burrito consideration app</title>
<meta name="description" content="Consider the potential of burritos with PostHog analytics" />
</svelte:head>
<Header />
<main>
{@render children()}
</main>
src/routes/+page.svelte
<script lang="ts">
import { getAuthContext } from '$lib/auth.svelte';
const auth = getAuthContext();
let username = $state('');
let password = $state('');
let error = $state('');
async function handleSubmit(e: Event) {
e.preventDefault();
error = '';
try {
const success = await auth.login(username, password);
if (success) {
username = '';
password = '';
} else {
error = 'Please provide both username and password';
}
} catch (err) {
console.error('Login failed:', err);
error = 'An error occurred during login';
}
}
</script>
<div class="container">
{#if auth.user}
<h1>Welcome back, {auth.user.username}!</h1>
<p>You are logged in. Check out the navigation to explore features.</p>
<ul>
<li><a href="/burrito">Consider a burrito</a></li>
<li><a href="/profile">View your profile</a></li>
</ul>
{:else}
<h1>Welcome to Burrito consideration app</h1>
<p>Sign in to start considering burritos.</p>
<form class="form" onsubmit={handleSubmit}>
<div class="form-group">
<label for="username">Username:</label>
<input type="text" id="username" bind:value={username} required />
</div>
<div class="form-group">
<label for="password">Password:</label>
<input type="password" id="password" bind:value={password} required />
</div>
{#if error}
<p class="error">{error}</p>
{/if}
<button type="submit" class="btn-primary">Sign In</button>
</form>
<p class="note">
Enter any username and password to sign in. This is a demo app.
</p>
{/if}
</div>
src/routes/api/auth/login/+server.ts
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getPostHogClient } from '$lib/server/posthog';
const users = new Map<string, { username: string; burritoConsiderations: number }>();
export const POST: RequestHandler = async ({ request }) => {
const { username, password } = await request.json();
if (!username || !password) {
return json({ error: 'Username and password required' }, { status: 400 });
}
let user = users.get(username);
const isNewUser = !user;
if (!user) {
user = { username, burritoConsiderations: 0 };
users.set(username, user);
}
// Capture server-side login event with user context
const posthog = getPostHogClient();
posthog.withContext(
{
distinctId: username,
personProperties: {
username,
createdAt: isNewUser ? new Date().toISOString() : undefined
}
},
() => {
posthog.capture({
event: 'server_login',
properties: {
isNewUser,
source: 'api'
}
});
}
);
// Flush events to ensure they're sent
await posthog.flush();
return json({ success: true, user });
};
src/routes/burrito/+page.svelte
<script lang="ts">
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import posthog from 'posthog-js';
import { getAuthContext } from '$lib/auth.svelte';
const auth = getAuthContext();
let hasConsidered = $state(false);
// Redirect to home if not logged in
$effect(() => {
if (browser && !auth.user) {
goto('/');
}
});
function handleConsideration() {
if (!auth.user) return;
auth.incrementBurritoConsiderations();
hasConsidered = true;
setTimeout(() => (hasConsidered = false), 2000);
// Capture burrito consideration event with PostHog
posthog.capture('burrito_considered', {
total_considerations: auth.user.burritoConsiderations,
username: auth.user.username
});
}
</script>
<div class="container">
{#if auth.user}
<h1>Burrito consideration zone</h1>
<p>This is where you consider the infinite potential of burritos.</p>
<p>Current considerations: <strong>{auth.user.burritoConsiderations}</strong></p>
<button class="btn-burrito" onclick={handleConsideration}>
I have considered the burrito potential
</button>
{#if hasConsidered}
<p class="success">
Thank you for your consideration! Count: {auth.user.burritoConsiderations}
</p>
{/if}
<div class="note">
<p>Each consideration is tracked as a PostHog event with custom properties.</p>
</div>
{:else}
<p>Please log in to consider burritos.</p>
{/if}
</div>
src/routes/profile/+page.svelte
<script lang="ts">
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import posthog from 'posthog-js';
import { getAuthContext } from '$lib/auth.svelte';
const auth = getAuthContext();
// Redirect to home if not logged in
$effect(() => {
if (browser && !auth.user) {
goto('/');
}
});
function triggerTestError() {
try {
throw new Error('Test error for PostHog error tracking');
} catch (err) {
posthog.captureException(err);
console.error('Captured error:', err);
alert('Error captured and sent to PostHog!');
}
}
</script>
<div class="container">
{#if auth.user}
<h1>User profile</h1>
<div class="stats">
<h2>Your information</h2>
<p><strong>Username:</strong> {auth.user.username}</p>
<p><strong>Burrito considerations:</strong> {auth.user.burritoConsiderations}</p>
</div>
<h2 style="margin-top: 2rem;">Error tracking demo</h2>
<p>Click the button below to trigger a test error that will be captured by PostHog.</p>
<button class="btn-primary" onclick={triggerTestError} style="margin-top: 1rem;">
Trigger test error (for PostHog)
</button>
<div class="note">
<p>This demonstrates PostHog's error tracking capabilities.</p>
<p>The error will appear in your PostHog error tracking dashboard.</p>
</div>
{:else}
<p>Please log in to view your profile.</p>
{/if}
</div>
static/robots.txt
# allow crawling everything by default
User-agent: *
Disallow:
svelte.config.js
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter(),
// Required for PostHog session replay to work correctly with SSR
paths: {
relative: false
}
}
};
export default config;
vite.config.ts
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});
references/EXAMPLE-swift.md
PostHog swift Example Project
Repository: https://github.com/PostHog/context-mill Path: example-apps/swift
README.md
PostHog Swift (iOS/macOS) example
This is a SwiftUI example demonstrating PostHog integration with product analytics, error tracking, and user identification. The app targets both iOS and macOS using NavigationSplitView.
Features
- Product analytics: Track user events and behaviors
- Error tracking: Capture and track errors
- User identification: Associate events with authenticated users
- Multi-platform: Runs on iOS, iPadOS, macOS, and visionOS
Getting started
1. Add the PostHog dependency
The Xcode project already includes the PostHog iOS SDK via Swift Package Manager. When you open the project, Xcode will resolve the package automatically.
To add it manually to a new project: File > Add Package Dependencies > enter https://github.com/PostHog/posthog-ios.
2. Set your PostHog project token
Open BurritoConsiderationClientApp.swift and replace the <your-project-token> placeholder in posthogProjectToken with your project token from your PostHog project settings.
The PostHog project token is a public client-side key — it is designed to ship in the app binary — so hardcoding it is safe and is the recommended approach for iOS distribution.
Don't rely on Xcode scheme environment variables as the only source. Scheme environment variables are injected only when launching from Xcode (debug/simulator); they are absent in Archive / Release builds (TestFlight, App Store). Reading them is fine, but treat them as an optional override over a value that ships in the binary — never force-unwrap or
fatalErroron their absence, or production builds will crash on launch.
3. Build and run
Open BurritoConsiderationClient.xcodeproj in Xcode and run on an iOS Simulator or macOS.
Project structure
BurritoConsiderationClient/
├── BurritoConsiderationClientApp.swift # App entry point with PostHog initialization
├── ContentView.swift # NavigationSplitView with sidebar routing
├── UserState.swift # @Observable user state with PostHog identify
├── LoginView.swift # Login form
├── DashboardView.swift # Welcome screen with dashboard_viewed tracking
├── BurritoView.swift # Burrito consideration with event capture
├── ProfileView.swift # Profile with journey progress and error trigger
└── Assets.xcassets/ # Asset catalogKey integration points
PostHog initialization (BurritoConsiderationClientApp.swift)
import PostHog
// The project token is a public client-side key, so it's safe to ship in the
// binary. Replace the placeholder with your token from the PostHog project settings.
let config = PostHogConfig(apiKey: "<your-project-token>", host: "https://us.i.posthog.com")
config.captureApplicationLifecycleEvents = true
PostHogSDK.shared.setup(config)User identification (UserState.swift)
PostHogSDK.shared.identify(username, userProperties: [
"username": username,
])Screen view tracking (DashboardView.swift, ProfileView.swift)
.onAppear {
PostHogSDK.shared.capture("dashboard_viewed", properties: [
"username": userState.username ?? "unknown",
])
}Event tracking (BurritoView.swift)
PostHogSDK.shared.capture("burrito_considered", properties: [
"total_considerations": count,
"username": username,
])Error tracking (ProfileView.swift)
PostHogSDK.shared.capture("test_error_triggered", properties: [
"error_type": "test",
"error_message": error.localizedDescription,
])User logout (UserState.swift)
PostHogSDK.shared.capture("user_logged_out")
PostHogSDK.shared.reset()Learn more
BurritoConsiderationClient.xcodeproj/project.xcworkspace/contents.xcworkspacedata
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
BurritoConsiderationClient.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
{
"originHash" : "a2fc303e4b16c93c972ef2ddc4042cf91a9400e5d1639bc9740a80c0336cdd4e",
"pins" : [
{
"identity" : "posthog-ios",
"kind" : "remoteSourceControl",
"location" : "https://github.com/PostHog/posthog-ios",
"state" : {
"revision" : "1783865d79a1cabc472cf2d56a1fe3f797417b52",
"version" : "3.40.0"
}
}
],
"version" : 3
}
BurritoConsiderationClient.xcodeproj/xcshareddata/xcschemes/BurritoConsiderationClient.xcscheme
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2630"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1F6BD9732F3520A100189B0B"
BuildableName = "BurritoConsiderationClient.app"
BlueprintName = "BurritoConsiderationClient"
ReferencedContainer = "container:BurritoConsiderationClient.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1F6BD9732F3520A100189B0B"
BuildableName = "BurritoConsiderationClient.app"
BlueprintName = "BurritoConsiderationClient"
ReferencedContainer = "container:BurritoConsiderationClient.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "POSTHOG_PROJECT_TOKEN"
value = "phc_jE9kXU0depRekiuabVROlxxkIXn95NqsNO3qB4qNKtl"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "POSTHOG_HOST"
value = "https://us.i.posthog.com"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1F6BD9732F3520A100189B0B"
BuildableName = "BurritoConsiderationClient.app"
BlueprintName = "BurritoConsiderationClient"
ReferencedContainer = "container:BurritoConsiderationClient.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
BurritoConsiderationClient/BurritoConsiderationClientApp.swift
//
// BurritoConsiderationClientApp.swift
// BurritoConsiderationClient
//
// Created by Danilo Campos on 2/5/26.
//
import SwiftUI
import PostHog
// PostHog configuration.
//
// The project token is a PUBLIC client-side key — it is designed to ship in the
// app binary, so hardcoding it here is safe and is the recommended approach for
// iOS. Replace the placeholder below with your project token from
// https://app.posthog.com/project/settings.
private let posthogProjectToken = "<your-project-token>"
private let posthogHost = "https://us.i.posthog.com"
@main
struct BurritoConsiderationClientApp: App {
@State private var userState = UserState()
init() {
let config = PostHogConfig(apiKey: posthogProjectToken, host: posthogHost)
config.captureApplicationLifecycleEvents = true
config.debug = true
PostHogSDK.shared.setup(config)
}
var body: some Scene {
WindowGroup {
ContentView()
.environment(userState)
}
}
}
BurritoConsiderationClient/BurritoView.swift
//
// BurritoView.swift
// BurritoConsiderationClient
//
import SwiftUI
import PostHog
struct BurritoView: View {
@Environment(UserState.self) private var userState
@State private var showConfirmation = false
var body: some View {
VStack(spacing: 24) {
Text("Take a moment to truly consider the potential of burritos.")
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Text("🌯")
.font(.system(size: 80))
Button("I Have Considered the Burrito Potential") {
userState.burritoConsiderations += 1
// PostHog: Capture burrito consideration event
PostHogSDK.shared.capture("burrito_considered", properties: [
"total_considerations": userState.burritoConsiderations,
"username": userState.username ?? "unknown",
])
showConfirmation = true
Task {
try? await Task.sleep(for: .seconds(2))
showConfirmation = false
}
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
if showConfirmation {
Text("Thank you for your consideration! Count: \(userState.burritoConsiderations)")
.foregroundStyle(.green)
.transition(.opacity)
}
Text("Total considerations: \(userState.burritoConsiderations)")
.font(.title2)
.padding(.top)
}
.padding()
.animation(.default, value: showConfirmation)
.navigationTitle("Burrito Consideration Zone")
}
}
BurritoConsiderationClient/ContentView.swift
//
// ContentView.swift
// BurritoConsiderationClient
//
// Created by Danilo Campos on 2/5/26.
//
import SwiftUI
enum Screen: CaseIterable, Identifiable {
case dashboard, burrito, profile
var id: Self { self }
var title: String {
switch self {
case .dashboard: "Home"
case .burrito: "Burrito"
case .profile: "Profile"
}
}
var icon: String {
switch self {
case .dashboard: "house"
case .burrito: "fork.knife"
case .profile: "person.circle"
}
}
}
struct ContentView: View {
@Environment(UserState.self) private var userState
@State private var selectedScreen: Screen? = .dashboard
var body: some View {
if userState.isLoggedIn {
NavigationSplitView {
List(Screen.allCases, selection: $selectedScreen) { screen in
Label(screen.title, systemImage: screen.icon)
}
.navigationTitle("Menu")
} detail: {
if let selectedScreen {
switch selectedScreen {
case .dashboard:
DashboardView()
case .burrito:
BurritoView()
case .profile:
ProfileView()
}
} else {
Text("Select an item from the sidebar")
.foregroundStyle(.secondary)
}
}
} else {
NavigationStack {
LoginView()
}
}
}
}
BurritoConsiderationClient/DashboardView.swift
//
// DashboardView.swift
// BurritoConsiderationClient
//
import SwiftUI
import PostHog
struct DashboardView: View {
@Environment(UserState.self) private var userState
var body: some View {
VStack(spacing: 20) {
Text("Welcome back, \(userState.username ?? "")!")
.font(.largeTitle)
.padding(.top, 40)
Text("You are logged in. Feel free to explore:")
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 12) {
Label("Consider the potential of burritos", systemImage: "fork.knife")
Label("View your profile and statistics", systemImage: "person.circle")
}
.padding()
Spacer()
}
.padding()
.navigationTitle("Home")
.onAppear {
// PostHog: Track dashboard view
PostHogSDK.shared.capture("dashboard_viewed", properties: [
"username": userState.username ?? "unknown",
])
}
}
}
BurritoConsiderationClient/LoginView.swift
//
// LoginView.swift
// BurritoConsiderationClient
//
import SwiftUI
struct LoginView: View {
@Environment(UserState.self) private var userState
@State private var username = ""
@State private var password = ""
@State private var showError = false
var body: some View {
Form {
Section("Login") {
TextField("Username", text: $username)
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
.autocorrectionDisabled()
SecureField("Password", text: $password)
}
Section {
Button("Log In") {
if !userState.login(username: username, password: password) {
showError = true
}
}
.disabled(username.isEmpty || password.isEmpty)
}
}
.formStyle(.grouped)
.navigationTitle("Burrito Consideration")
.alert("Login Failed", isPresented: $showError) {
Button("OK", role: .cancel) { }
} message: {
Text("Please enter a valid username and password.")
}
}
}
BurritoConsiderationClient/ProfileView.swift
//
// ProfileView.swift
// BurritoConsiderationClient
//
import SwiftUI
import PostHog
struct ProfileView: View {
@Environment(UserState.self) private var userState
private var journeyMessage: String {
switch userState.burritoConsiderations {
case 0:
"You haven't considered any burritos yet. Visit the Burrito Consideration page to start!"
case 1:
"You've considered the burrito potential once. Keep going!"
case 2...4:
"You're getting the hang of burrito consideration!"
case 5...9:
"You're becoming a burrito consideration expert!"
default:
"You are a true burrito consideration master!"
}
}
var body: some View {
Form {
Section("Your Information") {
LabeledContent("Username", value: userState.username ?? "—")
LabeledContent("Burrito Considerations", value: "\(userState.burritoConsiderations)")
}
Section("Your Burrito Journey") {
Text(journeyMessage)
}
Section("Diagnostics") {
Button("Trigger Test Error") {
let error = NSError(
domain: "com.posthog.BurritoConsiderationClient",
code: 42,
userInfo: [NSLocalizedDescriptionKey: "Test error triggered by user"]
)
// PostHog: Capture exception for error tracking
PostHogSDK.shared.capture("test_error_triggered", properties: [
"error_type": "test",
"error_message": error.localizedDescription,
"username": userState.username ?? "unknown",
])
}
}
Section {
Button("Log Out", role: .destructive) {
userState.logout()
}
}
}
.formStyle(.grouped)
.navigationTitle("Profile")
.onAppear {
// PostHog: Track profile view
PostHogSDK.shared.capture("profile_viewed", properties: [
"username": userState.username ?? "unknown",
])
}
}
}
BurritoConsiderationClient/UserState.swift
//
// UserState.swift
// BurritoConsiderationClient
//
import Foundation
import PostHog
@Observable
class UserState {
var username: String?
var burritoConsiderations: Int = 0
var isLoggedIn: Bool {
username != nil
}
func login(username: String, password: String) -> Bool {
// In a real app, validate credentials against a backend
guard !username.isEmpty, !password.isEmpty else {
return false
}
self.username = username
self.burritoConsiderations = 0
// PostHog: Identify user on login
PostHogSDK.shared.identify(username, userProperties: [
"username": username,
])
// PostHog: Capture login event
PostHogSDK.shared.capture("user_logged_in", properties: [
"username": username,
])
return true
}
func logout() {
// PostHog: Capture logout event before reset
PostHogSDK.shared.capture("user_logged_out")
PostHogSDK.shared.reset()
username = nil
burritoConsiderations = 0
}
}
references/EXAMPLE-tanstack-start.md
PostHog tanstack-start Example Project
Repository: https://github.com/PostHog/context-mill Path: example-apps/tanstack-start
README.md
PostHog TanStack Start example
This is a TanStack Start example demonstrating PostHog integration with product analytics, session replay, feature flags, and error tracking.
Features
- Product analytics: Track user events and behaviors
- Session replay: Record and replay user sessions
- Error tracking: Capture and track errors automatically
- User authentication: Demo login system with PostHog user identification
- Server-side & client-side tracking: Complete examples of both tracking methods
- Reverse proxy: PostHog ingestion through Vite dev server proxy
Getting started
1. Install dependencies
npm install2. Configure environment variables
Create a .env file in the root directory:
VITE_PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.comGet your PostHog project token from your PostHog project settings.
3. Run the development server
npm run devOpen http://localhost:3000 with your browser to see the app.
Project structure
src/
├── components/
│ └── Header.tsx # Navigation header with auth state
├── contexts/
│ └── AuthContext.tsx # Authentication context with PostHog integration
├── utils/
│ └── posthog-server.ts # Server-side PostHog client
├── routes/
│ ├── __root.tsx # Root route with PostHogProvider
│ ├── index.tsx # Home/login page
│ ├── burrito.tsx # Demo feature page with event tracking
│ ├── profile.tsx # User profile with error tracking demo
│ └── api/
│ ├── auth/
│ │ └── login.ts # Login API with server-side tracking
│ └── burrito/
│ └── consider.ts # Burrito API with server-side tracking
└── styles.css # Global styles
vite.config.ts # Vite config with PostHog proxy
.env # Environment variablesKey integration points
Client-side initialization (routes/__root.tsx)
PostHog is initialized using PostHogProvider from @posthog/react. The provider wraps the entire app in the root shell component and handles calling posthog.init() automatically:
import { PostHogProvider } from '@posthog/react'
<PostHogProvider
apiKey={import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN!}
options={{
api_host: '/ingest',
ui_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST || 'https://us.posthog.com',
defaults: '2025-05-24',
capture_exceptions: true,
debug: import.meta.env.DEV,
}}
>
{children}
</PostHogProvider>Server-side setup (utils/posthog-server.ts)
For server-side tracking, we use the posthog-node SDK with a singleton pattern:
import { PostHog } from 'posthog-node'
export function getPostHogClient() {
if (!posthogClient) {
posthogClient = new PostHog(
process.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN || import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN!,
{
host: process.env.VITE_PUBLIC_POSTHOG_HOST || import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
flushAt: 1,
flushInterval: 0,
}
)
}
return posthogClient
}This client is used in API routes to track server-side events.
Server-side capture (routes/api/*)
Server-side events include the client's $session_id so they appear in the same session in PostHog. The tracing_headers option on the PostHogProvider adds the X-POSTHOG-SESSION-ID and X-POSTHOG-DISTINCT-ID headers to same-origin requests automatically, so the frontend fetch needs no PostHog headers of its own:
// Client: tracing_headers is configured once on the PostHogProvider
options={{
api_host: '/ingest',
// ...
// Guarded for SSR, where `window` is undefined.
tracing_headers: typeof window !== 'undefined' ? [window.location.hostname] : [],
}}
// Frontend fetch — no manual header needed
await fetch('/api/burrito/consider', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ... }),
})// Server: read session ID from header and include in capture
import { getPostHogClient } from '../../utils/posthog-server'
const sessionId = request.headers.get('X-PostHog-Session-Id')
const posthog = getPostHogClient()
posthog.capture({
distinctId: username,
event: 'burrito_considered',
properties: {
$session_id: sessionId || undefined,
username: username,
source: 'api',
},
})Reverse proxy configuration
The Vite dev server is configured to proxy PostHog requests to avoid CORS issues and improve reliability:
server: {
proxy: {
'/ingest': {
target: 'https://us.i.posthog.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/ingest/, ''),
secure: false,
},
},
}User identification (contexts/AuthContext.tsx)
import { usePostHog } from '@posthog/react'
const posthog = usePostHog()
posthog.identify(username, {
username: username,
})Event tracking (routes/burrito.tsx)
import { usePostHog } from '@posthog/react'
const posthog = usePostHog()
posthog.capture('burrito_considered', {
total_considerations: user.burritoConsiderations + 1,
username: user.username,
})Error tracking (routes/profile.tsx)
posthog.captureException(error)Learn more
- PostHog documentation
- TanStack Start documentation
- TanStack Router documentation
- PostHog React integration
- PostHog Node.js integration
.env.example
VITE_PUBLIC_POSTHOG_PROJECT_TOKEN=<ph_project_token>
VITE_PUBLIC_POSTHOG_HOST=<ph_client_api_host>
.prettierignore
package-lock.json
pnpm-lock.yaml
yarn.lockprettier.config.js
// @ts-check
/** @type {import('prettier').Config} */
const config = {
semi: false,
singleQuote: true,
trailingComma: "all",
};
export default config;
public/robots.txt
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
src/components/Header.tsx
import { Link } from '@tanstack/react-router'
import { useAuth } from '../contexts/AuthContext'
export default function Header() {
const { user, logout } = useAuth()
return (
<header className="header">
<div className="header-container">
<nav>
<Link to="/">Home</Link>
{user && (
<>
<Link to="/burrito">Burrito Consideration</Link>
<Link to="/profile">Profile</Link>
</>
)}
</nav>
<div className="user-section">
{user ? (
<>
<span>Welcome, {user.username}!</span>
<button onClick={logout} className="btn-logout">
Logout
</button>
</>
) : (
<span>Not logged in</span>
)}
</div>
</div>
</header>
)
}
src/contexts/AuthContext.tsx
import {
createContext,
useContext,
useState,
ReactNode,
} from 'react'
import { usePostHog } from '@posthog/react'
interface User {
username: string
burritoConsiderations: number
}
interface AuthContextType {
user: User | null
login: (username: string, password: string) => Promise<boolean>
logout: () => void
incrementBurritoConsiderations: () => void
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
const users: Map<string, User> = new Map()
export function AuthProvider({ children }: { children: ReactNode }) {
const posthog = usePostHog()
// Use lazy initializer to read from localStorage only once on mount
const [user, setUser] = useState<User | null>(() => {
if (typeof window === 'undefined') return null
const storedUsername = localStorage.getItem('currentUser')
if (storedUsername) {
const existingUser = users.get(storedUsername)
if (existingUser) {
return existingUser
}
}
return null
})
const login = async (
username: string,
password: string,
): Promise<boolean> => {
try {
// The session and distinct ID are added automatically by the
// tracing_headers option configured on the PostHogProvider.
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
})
if (response.ok) {
const { user: userData } = await response.json()
// Get or create user in local map
let localUser = users.get(username)
if (!localUser) {
localUser = userData as User
users.set(username, localUser)
}
setUser(localUser)
if (typeof window !== 'undefined') {
localStorage.setItem('currentUser', username)
}
// Identify user in PostHog using username as distinct ID
posthog.identify(username, {
username: username,
})
// Capture login event
posthog.capture('user_logged_in', {
username: username,
})
return true
}
return false
} catch (error) {
console.error('Login error:', error)
return false
}
}
const logout = () => {
// Capture logout event before resetting
posthog.capture('user_logged_out')
posthog.reset()
setUser(null)
if (typeof window !== 'undefined') {
localStorage.removeItem('currentUser')
}
}
const incrementBurritoConsiderations = () => {
if (user) {
user.burritoConsiderations++
users.set(user.username, user)
setUser({ ...user })
}
}
return (
<AuthContext.Provider
value={{ user, login, logout, incrementBurritoConsiderations }}
>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}
src/router.tsx
import { createRouter } from '@tanstack/react-router'
// Import the generated route tree
import { routeTree } from './routeTree.gen'
// Create a new router instance
export const getRouter = () => {
return createRouter({
routeTree,
scrollRestoration: true,
defaultPreloadStaleTime: 0,
})
}
src/routes/__root.tsx
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { TanStackDevtools } from '@tanstack/react-devtools'
import { PostHogProvider } from '@posthog/react'
import Header from '../components/Header'
import { AuthProvider } from '../contexts/AuthContext'
import appCss from '../styles.css?url'
export const Route = createRootRoute({
head: () => ({
meta: [
{
charSet: 'utf-8',
},
{
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
{
title: 'TanStack Start Starter',
},
],
links: [
{
rel: 'stylesheet',
href: appCss,
},
],
}),
shellComponent: RootDocument,
})
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<PostHogProvider
apiKey={import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN!}
options={{
api_host: '/ingest',
ui_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST || 'https://us.posthog.com',
defaults: '2025-05-24',
capture_exceptions: true,
debug: import.meta.env.DEV,
// Automatically add X-POSTHOG-SESSION-ID and X-POSTHOG-DISTINCT-ID
// headers to same-origin requests so server-side events join the
// same session. Guarded for SSR, where `window` is undefined.
tracing_headers:
typeof window !== 'undefined' ? [window.location.hostname] : [],
}}
>
<AuthProvider>
<Header />
{children}
<TanStackDevtools
config={{
position: 'bottom-right',
}}
plugins={[
{
name: 'Tanstack Router',
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
</AuthProvider>
</PostHogProvider>
<Scripts />
</body>
</html>
)
}
src/routes/api/auth/login.ts
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { getPostHogClient } from '../../../utils/posthog-server'
export const Route = createFileRoute('/api/auth/login')({
server: {
handlers: {
POST: async ({ request }) => {
const body = await request.json()
const { username, password } = body
// Simple validation (in production, you'd verify against a real database)
if (!username || !password) {
return json(
{ error: 'Username and password required' },
{ status: 400 },
)
}
// Check if this is a new user (simplified - in production use a database)
const isNewUser = !username
// Create or get user
const user = {
username,
burritoConsiderations: 0,
}
const sessionId = request.headers.get('X-PostHog-Session-Id')
// Capture server-side login event
const posthog = getPostHogClient()
posthog.capture({
distinctId: username,
event: 'server_login',
properties: {
$session_id: sessionId || undefined,
username: username,
isNewUser: isNewUser,
source: 'api',
},
})
// Identify user on server side
posthog.identify({
distinctId: username,
properties: {
username: username,
createdAt: isNewUser ? new Date().toISOString() : undefined,
},
})
// This handler is short-lived; flush so the enqueued events send before it returns
await posthog.flush()
return json({ success: true, user })
},
},
},
})
src/routes/api/burrito/consider.ts
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { getPostHogClient } from '../../../utils/posthog-server'
export const Route = createFileRoute('/api/burrito/consider')({
server: {
handlers: {
POST: async ({ request }) => {
const body = await request.json()
const { username, totalConsiderations } = body
if (!username) {
return json(
{ error: 'Username is required' },
{ status: 400 },
)
}
const sessionId = request.headers.get('X-PostHog-Session-Id')
const posthog = getPostHogClient()
posthog.capture({
distinctId: username,
event: 'burrito_considered',
properties: {
$session_id: sessionId || undefined,
total_considerations: totalConsiderations,
username: username,
source: 'api',
},
})
// This handler is short-lived; flush so the enqueued event sends before it returns
await posthog.flush()
return json({ success: true })
},
},
},
})
src/routes/burrito.tsx
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
import { usePostHog } from '@posthog/react'
import { useAuth } from '../contexts/AuthContext'
export const Route = createFileRoute('/burrito')({
component: BurritoPage,
head: () => ({
meta: [
{
title: 'Burrito Consideration - Burrito Consideration App',
},
{
name: 'description',
content: 'Consider the potential of burritos',
},
],
}),
})
function BurritoPage() {
const { user, incrementBurritoConsiderations } = useAuth()
const navigate = useNavigate()
const posthog = usePostHog()
const [hasConsidered, setHasConsidered] = useState(false)
// Redirect to home if not logged in
if (!user) {
navigate({ to: '/' })
return null
}
const handleClientConsideration = () => {
incrementBurritoConsiderations()
setHasConsidered(true)
setTimeout(() => setHasConsidered(false), 2000)
posthog.capture('burrito_considered', {
total_considerations: user.burritoConsiderations + 1,
username: user.username,
})
}
const handleServerConsideration = async () => {
incrementBurritoConsiderations()
setHasConsidered(true)
setTimeout(() => setHasConsidered(false), 2000)
// The session and distinct ID are added automatically by the
// tracing_headers option configured on the PostHogProvider.
await fetch('/api/burrito/consider', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: user.username,
totalConsiderations: user.burritoConsiderations + 1,
}),
})
}
return (
<main>
<div className="container">
<h1>Burrito consideration zone</h1>
<p>Take a moment to truly consider the potential of burritos.</p>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.125rem' }}>
<button
onClick={handleClientConsideration}
className="btn-burrito"
style={{ backgroundColor: '#e07c24', color: '#fff' }}
>
Consider burrito (client)
</button>
<button
onClick={handleServerConsideration}
className="btn-burrito"
style={{ backgroundColor: '#4a90d9', color: '#fff' }}
>
Consider burrito (server)
</button>
{hasConsidered && (
<p className="success">
Thank you for your consideration! Count:{' '}
{user.burritoConsiderations}
</p>
)}
</div>
<div className="stats">
<h3>Consideration stats</h3>
<p>Total considerations: {user.burritoConsiderations}</p>
</div>
</div>
</main>
)
}
src/routes/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import { useAuth } from '../contexts/AuthContext'
export const Route = createFileRoute('/')({
component: Home,
head: () => ({
meta: [
{
title: 'Burrito Consideration App',
},
{
name: 'description',
content: 'Consider the potential of burritos',
},
],
}),
})
function Home() {
const { user, login } = useAuth()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
try {
const success = await login(username, password)
if (success) {
setUsername('')
setPassword('')
} else {
setError('Please provide both username and password')
}
} catch (err) {
console.error('Login failed:', err)
setError('An error occurred during login')
}
}
return (
<main>
{user ? (
<div className="container">
<h1>Welcome back, {user.username}!</h1>
<p>You are logged in. Feel free to explore:</p>
<ul>
<li>Consider the potential of burritos</li>
<li>View your profile and statistics</li>
</ul>
</div>
) : (
<div className="container">
<h1>Welcome to Burrito Consideration App</h1>
<p>Please sign in to begin your burrito journey</p>
<form onSubmit={handleSubmit} className="form">
<div className="form-group">
<label htmlFor="username">Username:</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter any username"
/>
</div>
<div className="form-group">
<label htmlFor="password">Password:</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter any password"
/>
</div>
{error && <p className="error">{error}</p>}
<button type="submit" className="btn-primary">
Sign In
</button>
</form>
<p className="note">
Note: This is a demo app. Use any username and password to sign in.
</p>
</div>
)}
</main>
)
}
src/routes/profile.tsx
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { usePostHog } from '@posthog/react'
import { useAuth } from '../contexts/AuthContext'
export const Route = createFileRoute('/profile')({
component: ProfilePage,
head: () => ({
meta: [
{
title: 'Profile - Burrito Consideration App',
},
{
name: 'description',
content: 'Your burrito consideration profile',
},
],
}),
})
function ProfilePage() {
const { user } = useAuth()
const navigate = useNavigate()
const posthog = usePostHog()
// Redirect to home if not logged in
if (!user) {
navigate({ to: '/' })
return null
}
const triggerTestError = () => {
try {
throw new Error('Test error for PostHog error tracking')
} catch (err) {
posthog.captureException(err)
console.error('Captured error:', err)
alert('Error captured and sent to PostHog!')
}
}
return (
<main>
<div className="container">
<h1>User Profile</h1>
<div className="stats">
<h2>Your Information</h2>
<p>
<strong>Username:</strong> {user.username}
</p>
<p>
<strong>Burrito Considerations:</strong>{' '}
{user.burritoConsiderations}
</p>
</div>
<div style={{ marginTop: '2rem' }}>
<button
onClick={triggerTestError}
className="btn-primary"
style={{ backgroundColor: '#dc3545' }}
>
Trigger Test Error (for PostHog)
</button>
</div>
<div style={{ marginTop: '2rem' }}>
<h3>Your Burrito Journey</h3>
{user.burritoConsiderations === 0 ? (
<p>
You haven't considered any burritos yet. Visit the Burrito
Consideration page to start!
</p>
) : user.burritoConsiderations === 1 ? (
<p>You've considered the burrito potential once. Keep going!</p>
) : user.burritoConsiderations < 5 ? (
<p>You're getting the hang of burrito consideration!</p>
) : user.burritoConsiderations < 10 ? (
<p>You're becoming a burrito consideration expert!</p>
) : (
<p>You are a true burrito consideration master! 🌯</p>
)}
</div>
</div>
</main>
)
}
src/routeTree.gen.ts
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as ProfileRouteImport } from './routes/profile'
import { Route as BurritoRouteImport } from './routes/burrito'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ApiBurritoConsiderRouteImport } from './routes/api/burrito/consider'
import { Route as ApiAuthLoginRouteImport } from './routes/api/auth/login'
const ProfileRoute = ProfileRouteImport.update({
id: '/profile',
path: '/profile',
getParentRoute: () => rootRouteImport,
} as any)
const BurritoRoute = BurritoRouteImport.update({
id: '/burrito',
path: '/burrito',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiBurritoConsiderRoute = ApiBurritoConsiderRouteImport.update({
id: '/api/burrito/consider',
path: '/api/burrito/consider',
getParentRoute: () => rootRouteImport,
} as any)
const ApiAuthLoginRoute = ApiAuthLoginRouteImport.update({
id: '/api/auth/login',
path: '/api/auth/login',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/burrito': typeof BurritoRoute
'/profile': typeof ProfileRoute
'/api/auth/login': typeof ApiAuthLoginRoute
'/api/burrito/consider': typeof ApiBurritoConsiderRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/burrito': typeof BurritoRoute
'/profile': typeof ProfileRoute
'/api/auth/login': typeof ApiAuthLoginRoute
'/api/burrito/consider': typeof ApiBurritoConsiderRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/burrito': typeof BurritoRoute
'/profile': typeof ProfileRoute
'/api/auth/login': typeof ApiAuthLoginRoute
'/api/burrito/consider': typeof ApiBurritoConsiderRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/burrito'
| '/profile'
| '/api/auth/login'
| '/api/burrito/consider'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/burrito'
| '/profile'
| '/api/auth/login'
| '/api/burrito/consider'
id:
| '__root__'
| '/'
| '/burrito'
| '/profile'
| '/api/auth/login'
| '/api/burrito/consider'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
BurritoRoute: typeof BurritoRoute
ProfileRoute: typeof ProfileRoute
ApiAuthLoginRoute: typeof ApiAuthLoginRoute
ApiBurritoConsiderRoute: typeof ApiBurritoConsiderRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/profile': {
id: '/profile'
path: '/profile'
fullPath: '/profile'
preLoaderRoute: typeof ProfileRouteImport
parentRoute: typeof rootRouteImport
}
'/burrito': {
id: '/burrito'
path: '/burrito'
fullPath: '/burrito'
preLoaderRoute: typeof BurritoRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/burrito/consider': {
id: '/api/burrito/consider'
path: '/api/burrito/consider'
fullPath: '/api/burrito/consider'
preLoaderRoute: typeof ApiBurritoConsiderRouteImport
parentRoute: typeof rootRouteImport
}
'/api/auth/login': {
id: '/api/auth/login'
path: '/api/auth/login'
fullPath: '/api/auth/login'
preLoaderRoute: typeof ApiAuthLoginRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
BurritoRoute: BurritoRoute,
ProfileRoute: ProfileRoute,
ApiAuthLoginRoute: ApiAuthLoginRoute,
ApiBurritoConsiderRoute: ApiBurritoConsiderRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}
src/utils/posthog-server.ts
import { PostHog } from 'posthog-node'
let posthogClient: PostHog | null = null
export function getPostHogClient() {
if (!posthogClient) {
posthogClient = new PostHog(
process.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN || import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN!,
{
host: process.env.VITE_PUBLIC_POSTHOG_HOST || import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
flushAt: 1,
flushInterval: 0,
},
)
}
return posthogClient
}
vite.config.ts
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'
import viteTsConfigPaths from 'vite-tsconfig-paths'
const config = defineConfig({
plugins: [
// this is the plugin that enables path aliases
viteTsConfigPaths({
projects: ['./tsconfig.json'],
}),
tanstackStart(),
viteReact(),
],
server: {
proxy: {
'/ingest/static': {
target: 'https://us-assets.i.posthog.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/ingest/, ''),
secure: false,
},
'/ingest/array': {
target: 'https://us-assets.i.posthog.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/ingest/, ''),
secure: false,
},
'/ingest': {
target: 'https://us.i.posthog.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/ingest/, ''),
secure: false,
},
},
},
})
export default config
references/EXAMPLE-vue-3.md
PostHog vue-3 Example Project
Repository: https://github.com/PostHog/context-mill Path: example-apps/vue-3
README.md
PostHog Vue 3 + Vite example
This is a Vue 3 + Vite example demonstrating PostHog integration with product analytics, session replay, and error tracking.
It uses the posthog-js browser SDK directly and shows how to:
- Initialize PostHog in a Vue 3 SPA
- Identify users after login
- Track custom events from components
- Capture errors via Vue’s global
errorHandler - Reset PostHog state on logout
Features
- Product analytics: Track login and burrito consideration events
- Session replay: Enabled via
posthog-jsconfiguration - Error tracking: Global Vue error handler sends exceptions to PostHog
- Simple auth flow: Demo login + protected routes using Pinia + Vue Router
Getting started
1. Install dependencies
npm install
# or
pnpm install2. Configure environment variables
Create a .env file in the project root:
VITE_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
VITE_POSTHOG_HOST=https://us.i.posthog.comGet your PostHog project token from your project settings in PostHog.
3. Run the development server
npm run dev
# or
pnpm devOpen http://localhost:5173 (or whatever Vite prints) in your browser.
Project structure
src/
main.ts # Vue app entrypoint, PostHog init + global errorHandler
router/
index.ts # Routes + simple auth guard
stores/
auth.ts # Pinia auth store (login, logout, user state)
components/
Header.vue # Navigation + logout, calls posthog.reset()
views/
Home.vue # Login form, identifies user + captures 'user_logged_in'
Burrito.vue # Burrito consideration demo, captures 'burrito_considered'
Profile.vue # Profile + error tracking demo (if implemented)
App.vue # Root layoutKey integration points
PostHog initialization (src/main.ts)
posthog-js is initialized once when the app boots:
import posthog from 'posthog-js'
posthog.init(import.meta.env.VITE_POSTHOG_PROJECT_TOKEN || '', {
api_host: import.meta.env.VITE_POSTHOG_HOST || 'https://us.i.posthog.com',
})
app.config.errorHandler = (err) => {
posthog.captureException(err)
}This ensures:
- The SDK is configured with your project key and host
- The singleton instance is initialized only once and before the app mounts
- Any uncaught Vue errors are sent to PostHog
User identification (src/views/Home.vue)
After a successful “login”, the app identifies the user and captures a login event:
const success = await authStore.login(username.value, password.value)
if (success) {
posthog.identify(username.value)
posthog.capture('user_logged_in')
}Identification happens only on login, all further requests will automatically use the same distinct ID.
Event tracking (src/views/Burrito.vue)
The burrito page tracks a custom event when a user “considers” the burrito:
posthog.capture('burrito_considered', {
total_considerations: updatedUser.burritoConsiderations,
username: updatedUser.username,
})This shows how to attach useful properties to events (e.g. counts, usernames).
Logout and session reset (src/components/Header.vue)
On logout, both the local auth state and PostHog state are cleared:
authStore.logout()
posthog.reset()
router.push({ name: 'home' })posthog.reset() clears the current distinct ID and session so the next login starts a fresh identity.
Scripts
# Run dev server
npm run dev
# Type-check, compile, and minify for production
npm run build
# Lint
npm run lintLearn more
.editorconfig
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100
.env.example
VITE_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
VITE_POSTHOG_HOST=https://us.i.posthog.com
env.d.ts
/// <reference types="vite/client" />
index.html
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
src/App.vue
<script setup lang="ts">
import Header from '@/components/Header.vue'
</script>
<template>
<Header />
<main>
<RouterView />
</main>
</template>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
background: #f5f5f5;
}
#app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
main {
flex: 1;
max-width: 1200px;
width: 100%;
margin: 2rem auto;
padding: 0 1rem;
}
h1 {
margin-bottom: 1rem;
}
p {
margin-bottom: 1rem;
}
</style>
src/components/Header.vue
<template>
<header class="header">
<div class="header-container">
<nav>
<RouterLink to="/">Home</RouterLink>
<template v-if="authStore.user">
<RouterLink to="/burrito">Burrito Consideration</RouterLink>
<RouterLink to="/profile">Profile</RouterLink>
</template>
</nav>
<div class="user-section">
<template v-if="authStore.user && authStore.user.username">
<span>Welcome, {{ authStore.user.username }}!</span>
<button @click="handleLogout" class="btn-logout">
Logout
</button>
</template>
<template v-else>
<span>Not logged in</span>
<button v-if="authStore.user" @click="handleLogout" class="btn-logout">
Clear Session
</button>
</template>
</div>
</div>
</header>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import posthog from 'posthog-js'
const authStore = useAuthStore()
const router = useRouter()
const handleLogout = () => {
authStore.logout()
// IMPORTANT: Reset the PostHog instance to clear the user session
posthog.reset()
router.push({ name: 'home' })
}
</script>
<style scoped>
.header {
background-color: #333;
color: white;
padding: 1rem;
}
.header-container {
max-width: 1200px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
}
.header nav {
display: flex;
gap: 1rem;
}
.header a {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background-color 0.2s;
}
.header a:hover {
background-color: #555;
text-decoration: none;
}
.user-section {
display: flex;
align-items: center;
gap: 1rem;
}
.btn-logout {
background-color: #dc3545;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.btn-logout:hover {
background-color: #c82333;
}
</style>
src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import posthog from "posthog-js";
const app = createApp(App);
posthog.init(import.meta.env.VITE_POSTHOG_PROJECT_TOKEN || '', {
api_host: import.meta.env.VITE_POSTHOG_HOST || 'https://us.i.posthog.com',
defaults: '2026-01-30',
});
app.use(createPinia())
app.use(router)
app.config.errorHandler = (err, instance, info) => {
// report error to tracking services
posthog.captureException(err)
}
app.mount('#app')
src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import Home from '@/views/Home.vue'
import Burrito from '@/views/Burrito.vue'
import Profile from '@/views/Profile.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: Home
},
{
path: '/burrito',
name: 'burrito',
component: Burrito,
meta: { requiresAuth: true }
},
{
path: '/profile',
name: 'profile',
component: Profile,
meta: { requiresAuth: true }
}
]
})
router.beforeEach((to, from, next) => {
const authStore = useAuthStore()
// Check if user exists and has a valid username
const isValidUser = authStore.user && authStore.user.username
if (to.meta.requiresAuth && !isValidUser) {
// Clear invalid state
if (authStore.user && !authStore.user.username) {
authStore.logout()
}
next({ name: 'home' })
} else {
next()
}
})
export default router
src/stores/auth.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
interface User {
username: string
burritoConsiderations: number
}
const users = new Map<string, User>()
export const useAuthStore = defineStore('auth', () => {
const getInitialUser = (): User | null => {
if (typeof window === 'undefined') return null
const storedUsername = localStorage.getItem('currentUser')
if (storedUsername) {
const existingUser = users.get(storedUsername)
if (existingUser && existingUser.username) {
return existingUser
} else {
// Clean up invalid state
localStorage.removeItem('currentUser')
}
}
return null
}
const user = ref<User | null>(getInitialUser())
const isAuthenticated = computed(() => user.value !== null)
const login = async (username: string, password: string): Promise<boolean> => {
// Client-side only fake auth - no server calls
if (!username || !password) {
return false
}
let localUser = users.get(username)
if (!localUser) {
localUser = {
username,
burritoConsiderations: 0
}
users.set(username, localUser)
}
user.value = localUser
localStorage.setItem('currentUser', username)
return true
}
const logout = () => {
user.value = null
localStorage.removeItem('currentUser')
}
const setUser = (newUser: User) => {
user.value = newUser
users.set(newUser.username, newUser)
}
return {
user,
isAuthenticated,
login,
logout,
setUser
}
})
src/views/Burrito.vue
<template>
<div class="container">
<h1>Burrito consideration zone</h1>
<p>Take a moment to truly consider the potential of burritos.</p>
<div style="text-align: center">
<button @click="handleConsideration" class="btn-burrito">
I have considered the burrito potential
</button>
<p v-if="hasConsidered" class="success">
Thank you for your consideration! Count: {{ authStore.user?.burritoConsiderations }}
</p>
</div>
<div class="stats">
<h3>Consideration stats</h3>
<p>Total considerations: {{ authStore.user?.burritoConsiderations }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import posthog from 'posthog-js'
const authStore = useAuthStore()
const hasConsidered = ref(false)
const handleConsideration = () => {
if (!authStore.user) return
// Client-side only - no server calls
const updatedUser = {
...authStore.user,
burritoConsiderations: authStore.user.burritoConsiderations + 1
}
authStore.setUser(updatedUser)
hasConsidered.value = true
setTimeout(() => {
hasConsidered.value = false
}, 2000)
// Capture burrito consideration event
posthog.capture('burrito_considered', {
total_considerations: updatedUser.burritoConsiderations,
username: updatedUser.username
})
}
</script>
<style scoped>
.container {
padding: 2rem;
max-width: 600px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.btn-burrito {
background-color: #28a745;
color: white;
border: none;
padding: 1rem 2rem;
border-radius: 4px;
font-size: 18px;
cursor: pointer;
margin: 2rem 0;
}
.btn-burrito:hover {
background-color: #218838;
}
.success {
color: #28a745;
margin-top: 0.5rem;
}
.stats {
background-color: #f8f9fa;
padding: 1rem;
border-radius: 4px;
margin-top: 1rem;
}
h3 {
margin-top: 1rem;
margin-bottom: 0.5rem;
}
</style>
src/views/Home.vue
<template>
<div class="container">
<template v-if="authStore.user && authStore.user.username">
<h1>Welcome back, {{ authStore.user.username }}!</h1>
<p>You are logged in. Feel free to explore:</p>
<ul>
<li>Consider the potential of burritos</li>
<li>View your profile and statistics</li>
</ul>
</template>
<template v-else>
<h1>Welcome to Burrito Consideration App</h1>
<p>Please sign in to begin your burrito journey</p>
<form @submit.prevent="handleSubmit" class="form">
<div class="form-group">
<label for="username">Username:</label>
<input
type="text"
id="username"
v-model="username"
placeholder="Enter any username"
/>
</div>
<div class="form-group">
<label for="password">Password:</label>
<input
type="password"
id="password"
v-model="password"
placeholder="Enter any password"
/>
</div>
<p v-if="error" class="error">{{ error }}</p>
<button type="submit" class="btn-primary">Sign In</button>
</form>
<p class="note">
Note: This is a demo app. Use any username and password to sign in.
</p>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import posthog from 'posthog-js'
const authStore = useAuthStore()
const username = ref('')
const password = ref('')
const error = ref('')
// Clean up invalid user state on mount
onMounted(() => {
if (authStore.user && !authStore.user.username) {
authStore.logout()
}
})
const handleSubmit = async () => {
error.value = ''
const success = await authStore.login(username.value, password.value)
if (success) {
// Identifying the user once on login/sign up is enough.
posthog.identify(username.value)
posthog.capture('user_logged_in')
username.value = ''
password.value = ''
} else {
error.value = 'Please provide both username and password'
}
}
</script>
<style scoped>
.container {
padding: 2rem;
max-width: 600px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.form {
margin-top: 2rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 16px;
}
.form-group input:focus {
outline: none;
border-color: #0070f3;
}
.btn-primary {
background-color: #0070f3;
color: white;
border: none;
padding: 0.75rem 2rem;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
width: 100%;
margin-top: 1rem;
}
.btn-primary:hover {
background-color: #0051cc;
}
.error {
color: #dc3545;
margin-top: 0.5rem;
}
.note {
margin-top: 2rem;
color: #666;
font-size: 14px;
text-align: center;
}
ul {
margin-top: 1rem;
padding-left: 1.5rem;
}
li {
margin-bottom: 0.5rem;
}
</style>
src/views/Profile.vue
<template>
<div class="container">
<h1>User Profile</h1>
<div class="stats">
<h2>Your Information</h2>
<p><strong>Username:</strong> {{ authStore.user?.username }}</p>
<p><strong>Burrito Considerations:</strong> {{ authStore.user?.burritoConsiderations }}</p>
</div>
<div style="margin-top: 2rem">
<h3>Your Burrito Journey</h3>
<template v-if="authStore.user">
<p v-if="authStore.user.burritoConsiderations === 0">
You haven't considered any burritos yet. Visit the Burrito Consideration page to start!
</p>
<p v-else-if="authStore.user.burritoConsiderations === 1">
You've considered the burrito potential once. Keep going!
</p>
<p v-else-if="authStore.user.burritoConsiderations < 5">
You're getting the hang of burrito consideration!
</p>
<p v-else-if="authStore.user.burritoConsiderations < 10">
You're becoming a burrito consideration expert!
</p>
<p v-else>
You are a true burrito consideration master! 🌯
</p>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
</script>
<style scoped>
.container {
padding: 2rem;
max-width: 600px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.stats {
background-color: #f8f9fa;
padding: 1rem;
border-radius: 4px;
margin-top: 1rem;
}
h2 {
margin-top: 1rem;
margin-bottom: 0.5rem;
}
h3 {
margin-top: 1rem;
margin-bottom: 0.5rem;
}
</style>
vite.config.ts
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
})
references/android.md
AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt
Android
It uses an internal queue to make calls fast and non-blocking. It also batches requests and flushes asynchronously, making it perfect to use in any part of your mobile app.
Installation
The best way to install the PostHog Android library is with a build system like Gradle. This ensures you can easily upgrade to the latest versions.
All you need to do is add the posthog-android module to your App's build.gradle or build.gradle.kts:
app/build.gradle
dependencies {
implementation 'com.posthog:posthog-android:3.+'
}app/build.gradle.kts
dependencies {
implementation("com.posthog:posthog-android:3.+")
}Configuration
The best place to initialize the client is in your Application subclass.
Kotlin
import android.app.Application
import com.posthog.android.PostHogAndroid
import com.posthog.android.PostHogAndroidConfig
class SampleApp : Application() {
companion object {
const val POSTHOG_API_KEY = "<ph_project_token>"
// usually 'https://us.i.posthog.com' or 'https://eu.i.posthog.com'
const val POSTHOG_HOST = "https://us.i.posthog.com"
}
override fun onCreate() {
super.onCreate()
val config = PostHogAndroidConfig(
apiKey = POSTHOG_API_KEY,
host = POSTHOG_HOST
)
PostHogAndroid.setup(this, config)
}
}Capturing events
You can send custom events using capture:
Kotlin
import com.posthog.PostHog
PostHog.capture(event = "user_signed_up")Tip: We recommend using a
[object] [verb]format for your event names, where[object]is the entity that the behavior relates to, and[verb]is the behavior itself. For example,project created,user signed up, orinvite sent.
Setting event properties
Optionally, you can include additional information with the event by including a properties (/docs/data/events.md#event-properties) object:
Kotlin
import com.posthog.PostHog
PostHog.capture(
event = "user_signed_up",
properties = mapOf(
"login_type" to "email",
"is_free_trial" to true
)
)Autocapture
PostHog autocapture automatically tracks the following events for you:
- Application Opened - when the app is opened from a closed state or when the app comes to the foreground. (e.g. from the app switcher)
- Deep Link Opened - when the app is opened from a deep link.
- Application Backgrounded - when the app is sent to the background by the user.
- Application Installed - when the app is installed.
- Application Updated - when the app is updated.
- $screen - when the user navigates. (if using
android.app.Activity) - $exception - when uncaught exception autocapture is enabled. To use this, enable Android error tracking (/docs/error-tracking/installation/android.md) and exception autocapture in the SDK config.
Capturing screen views
With captureScreenViews = true (/docs/libraries/android.md#all-configuration-options), PostHog will try to record all screen changes automatically.
The screenTitle will be the <activity>'s android:label, if not set it'll fallback to the <application>'s android:label or the <activity>'s android:name.
XML
<activity
android:name="com.example.app.ChildActivity"
android:label="@string/title_child_activity"
...
</activity>If you want to manually send a new screen capture event, use the screen function.
This function requires a screenTitle. You may also pass in an optional properties object.
Kotlin
import com.posthog.PostHog
PostHog.screen(
screenTitle = "Dashboard",
properties = mapOf(
"background" to "blue",
"hero" to "superhog"
)
)Identifying users
We highly recommend reading our section on Identifying users (/docs/integrate/identifying-users.md) to better understand how to correctly use this method.
Using identify, you can associate events with specific users. This enables you to gain full insights as to how they're using your product across different sessions, devices, and platforms.
An identify call has the following arguments:
- distinctId: Required. A unique identifier for your user. Typically either their email or database ID.
- userProperties: Optional. A dictionary with key:value pairs to set the person properties (/docs/product-analytics/person-properties.md)
- userPropertiesSetOnce: Optional. Similar to
userProperties. See the difference betweenuserPropertiesanduserPropertiesSetOnce(/docs/product-analytics/person-properties.md#what-is-the-difference-between-set-and-set_once)
Kotlin
import com.posthog.PostHog
PostHog.identify(
distinctId = distinctID,
userProperties = mapOf(
"name" to "Max Hedgehog",
"email" to "max@hedgehogmail.com"
),
userPropertiesSetOnce = mapOf(
"date_of_first_log_in" to "2024-03-01"
),
)You should call identify as soon as you're able to. Typically, this is after your user logs in. This ensures that events sent during your user's sessions are correctly associated with them.
When you call identify, all previously tracked anonymous events will be linked to the user.
Get the current user's distinct ID
You may find it helpful to get the current user's distinct ID. For example, to check whether you've already called identify for a user or not.
To do this, call distinctId(). This returns either the ID automatically generated by PostHog or the ID that has been passed by a call to identify().
Tracing headers
Use tracingHeaders to connect Android network requests to backend events, errors, and LLM traces captured by a server-side PostHog SDK. Tracing headers are added by the PostHogOkHttpInterceptor, so install the interceptor on each OkHttpClient whose requests should include PostHog context.
Kotlin
import com.posthog.PostHogOkHttpInterceptor
import com.posthog.android.PostHogAndroid
import com.posthog.android.PostHogAndroidConfig
import okhttp3.OkHttpClient
val config = PostHogAndroidConfig(
apiKey = POSTHOG_API_KEY,
host = POSTHOG_HOST,
).apply {
tracingHeaders = listOf("api.example.com")
}
PostHogAndroid.setup(this, config)
val okHttpClient = OkHttpClient.Builder()
.addInterceptor(PostHogOkHttpInterceptor())
.build()Hostnames are matched exactly and should not include protocols, paths, ports, or wildcard subdomains. Matching OkHttp requests include X-POSTHOG-DISTINCT-ID and X-POSTHOG-SESSION-ID when those values are available.
Alias
Sometimes, you want to assign multiple distinct IDs to a single user. This is helpful when your primary distinct ID is inaccessible. For example, if a distinct ID used on the frontend is not available in your backend.
In this case, you can use alias to assign another distinct ID to the same user.
Kotlin
/**
* Create an alias for the current user.
*/
PostHog.alias("distinct_id")We strongly recommend reading our docs on alias (/docs/product-analytics/identify.md#alias-assigning-multiple-distinct-ids-to-the-same-user) to best understand how to correctly use this method.
Anonymous and identified events
PostHog captures two types of events: anonymous and identified (/docs/data/anonymous-vs-identified-events.md)
Identified events enable you to attribute events to specific users, and attach person properties (/docs/product-analytics/person-properties.md). They're best suited for logged-in users.
Scenarios where you want to capture identified events are:
- Tracking logged-in users in B2B and B2C SaaS apps
- Doing user segmented product analysis
- Growth and marketing teams wanting to analyze the complete conversion lifecycle
Anonymous events are events without individually identifiable data. They're best suited for web analytics (/docs/web-analytics.md) or apps where users aren't logged in.
Scenarios where you want to capture anonymous events are:
- Tracking a marketing website
- Content-focused sites
- B2C apps where users don't sign up or log in
Under the hood, the key difference between identified and anonymous events is that for identified events we create a person profile (/docs/data/persons.md) for the user, whereas for anonymous events we do not.
Important: Due to the reduced cost of processing them, anonymous events can be up to 4x cheaper than identified ones, so we recommended you only capture identified events when needed.
How to capture anonymous events
The Android SDK captures anonymous events by default. However, this may change depending on your personProfiles config (/docs/libraries/android.md#all-configuration-options) when initializing PostHog:
personProfiles = PersonProfiles.IDENTIFIED_ONLY(recommended) (default) - Anonymous events are captured by default. PostHog only captures identified events for users where person profiles (/docs/data/persons.md) have already been created.personProfiles = PersonProfiles.ALWAYS- Capture identified events for all events.personProfiles = PersonProfiles.NEVER- Capture anonymous events for all events.
For example:
Kotlin
val config = PostHogAndroidConfig(
apiKey = POSTHOG_API_KEY,
host = POSTHOG_HOST,
).apply {
personProfiles = PersonProfiles.IDENTIFIED_ONLY
}How to capture identified events
If you've set the personProfiles config (/docs/libraries/android.md#all-configuration-options) to IDENTIFIED_ONLY (the default option), anonymous events are captured by default. Then, to capture identified events, call any of the following functions:
identify()(/docs/product-analytics/identify.md)alias()(/docs/product-analytics/identify.md#alias-assigning-multiple-distinct-ids-to-the-same-user)group()(/docs/product-analytics/group-analytics.md)
When you call any of these functions, it creates a person profile (/docs/data/persons.md) for the user. Once this profile is created, all subsequent events for this user will be captured as identified events.
Alternatively, you can set personProfiles to ALWAYS to capture identified events by default.
Setting person properties
To set properties (/docs/product-analytics/person-properties.md) on your users via an event, you can leverage the event properties userProperties and userPropertiesSetOnce.
When capturing an event, you can pass a property called userProperties as an event property, and specify its value to be an object with properties to be set on the user that will be associated with the user who triggered the event.
Kotlin
import com.posthog.PostHog
PostHog.capture(
event = "button_b_clicked",
properties = mapOf("color" to "blue"),
userProperties = mapOf(
"string" to "value1",
"integer" to 2
)
)userPropertiesSetOnce works just like userProperties, except that it will only set the property if the user doesn't already have that property set.
Kotlin
import com.posthog.PostHog
PostHog.capture(
event = "button_b_clicked",
properties = mapOf("color" to "blue"),
userPropertiesSetOnce = mapOf(
"string" to "value1",
"integer" to 2
)
)Super Properties
Super Properties are properties associated with events that are set once and then sent with every capture call, be it a $screen, or anything else.
They are set using PostHog.register, which takes a key and value, and they persist across sessions.
For example, take a look at the following call:
Kotlin
import com.posthog.PostHog
PostHog.register("team_id", 22)The call above ensures that every event sent by the user will include "team_id": 22. This way, if you filtered events by property using team_id = 22, it would display all events captured on that user after the PostHog.register call, since they all include the specified Super Property.
However, please note that this does not store properties against the User, only against their events. To store properties against the User object, you should use PostHog.identify. More information on this can be found on the Sending User Information section (#sending-user-information).
Removing stored Super Properties
Super Properties are persisted across sessions so you have to explicitly remove them if they are no longer relevant. In order to stop sending a Super Property with events, you can use PostHog.unregister, like so:
Kotlin
import com.posthog.PostHog
PostHog.unregister("team_id")This will remove the Super Property and subsequent events will not include it.
If you are doing this as part of a user logging out you can instead simply use PostHog.reset which takes care of clearing all stored Super Properties and more.
Opt out of data capture
You can completely opt-out users from data capture. To do this, there are two options:
- Opt users out by default by setting
optOuttotruein your PostHog config:
Kotlin
val config = PostHogAndroidConfig(
apiKey = "<ph_project_token>",
host = "https://us.i.posthog.com"
)
config.optOut = true
PostHogAndroid.setup(this, config)- Opt users out on a per-person basis by calling
optOut():
Kotlin
PostHog.optOut()Similarly, you can opt users in:
Kotlin
PostHog.optIn()To check if a user is opted out:
Kotlin
PostHog.isOptOut()Flush
You can configure how many events queue before flushing with flushAt. Setting this to 1 will send events immediately and will use more battery. The default is 20.
You can also configure the flush interval with flushIntervalSeconds (default 30), after which queued events are sent regardless of how many have been gathered:
Kotlin
import com.posthog.android.PostHogAndroidConfig
val config = PostHogAndroidConfig(apiKey = POSTHOG_API_KEY, host = POSTHOG_HOST).apply {
flushAt = 20
flushIntervalSeconds = 30
}You can also manually flush the queue to start sending events immediately instead of waiting for the next batch:
Kotlin
import com.posthog.PostHog
PostHog.flush()Flushing is best-effort and asynchronous – it starts sending queued events in the background but doesn't wait for the request to finish, so it isn't a delivery guarantee.
Reset after logout
To reset the user's ID and anonymous ID, call reset. Usually you would do this right after the user logs out.
Kotlin
import com.posthog.PostHog
PostHog.reset()Feature Flags
PostHog's feature flags (/docs/feature-flags.md) enable you to safely deploy and roll back new features as well as target specific users and groups with them.
Boolean feature flags
Kotlin
import com.posthog.PostHog
val result = PostHog.getFeatureFlagResult("flag-key")
if (result?.enabled == true) {
// Do something differently for this user
// Optional: fetch the payload from the same evaluation result
val matchedFlagPayload = result.payload
}Multivariate feature flags
Kotlin
import com.posthog.PostHog
val result = PostHog.getFeatureFlagResult("flag-key")
if (result?.variant == "variant-key") { // replace "variant-key" with the key of your variant
// Do something differently for this user
// Optional: fetch the payload from the same evaluation result
val matchedFlagPayload = result.payload
}Inspecting all feature flags
You can inspect all currently loaded feature flags with PostHog.getAllFeatureFlags(). It returns each flag's key, enabled state, variant, and payload, and does not send a $feature_flag_called event, so calling it won't affect your experiment results or flag usage analytics:
Kotlin
import com.posthog.PostHog
PostHog.getAllFeatureFlags()?.forEach { flag ->
println("${flag.key} ${flag.enabled} ${flag.variant} ${flag.payload}")
}Ensuring flags are loaded before usage
Every time a user opens the app, we send a request in the background to fetch the feature flags that apply to that user. We store those flags in the storage.
This means that for most screens, the feature flags are available immediately – except for the first time a user visits.
To handle this, you can use the onFeatureFlags callback to wait for the feature flag request to finish:
Kotlin
import com.posthog.PostHog
import com.posthog.android.PostHogAndroidConfig
import com.posthog.PostHogOnFeatureFlags
// During SDK initialization
val config = PostHogAndroidConfig(apiKey = "<ph_project_token>").apply {
onFeatureFlags = PostHogOnFeatureFlags {
if (PostHog.isFeatureEnabled("flag-key")) {
// do something
}
}
}
// And/or after the SDK is initialized
PostHog.reloadFeatureFlags {
if (PostHog.isFeatureEnabled("flag-key")) {
// do something
}
}Reloading feature flags
Feature flag values are cached. If something has changed with your user and you'd like to refetch their flag values, call:
Kotlin
import com.posthog.PostHog
PostHog.reloadFeatureFlags()Tracking feature usage
To track when someone sees or interacts with a feature, use captureFeatureView and captureFeatureInteraction.
Kotlin
import com.posthog.PostHog
PostHog.captureFeatureView("flag-key", flagVariant = "variant-key")
PostHog.captureFeatureInteraction("flag-key", flagVariant = "variant-key")Bootstrapping flags
Since there is a delay between initializing PostHog and fetching feature flags, feature flags are not always available immediately. This makes them unusable if you want to do something like redirecting a user to a different page based on a feature flag.
To have your feature flags available immediately, you can initialize PostHog with precomputed values until it has had a chance to fetch them. This is called bootstrapping. After the SDK fetches feature flags from PostHog, it will use those flag values instead of bootstrapped ones.
Set config.bootstrap before calling setup() to seed identity and flag values before the first /flags response (requires Android SDK 3.55.0+):
Kotlin
import com.posthog.PostHogBootstrapConfig
val config = PostHogAndroidConfig(apiKey = POSTHOG_API_KEY, host = POSTHOG_HOST)
config.bootstrap = PostHogBootstrapConfig(
distinctId = "distinct_id_of_your_user",
isIdentifiedId = true,
featureFlags = mapOf(
"flag-1" to true,
"variant-flag" to "control"
)
)
PostHogAndroid.setup(this, config)- Bootstrapped identity applies during setup. On a fresh install, setting it before
setup()means events captured synchronously during initialization (likeApplication Installed) carry your distinct ID instead of the SDK-generated UUID.- An anonymous bootstrap (
isIdentifiedId: false, the default) seeds the anonymous ID only when none is persisted yet. Once an anonymous ID exists on disk, or the person has been identified, the SDK ignores it. - An identified bootstrap (
isIdentifiedId: true) is for a signed-in identity available to your app (for example, from a backend session token). On a fresh install, it seeds the distinct ID, marks the person identified, and generates a separate device ID. On a returning install, a matching anonymous ID is marked identified without emitting$identify; a different anonymous ID is merged viaidentify()when person profiles are enabled. This emits$identifyunless capturing is opted out. A different, already-identified person is left untouched.
- An anonymous bootstrap (
- Bootstrapped flags are served until the first
/flagsresponse, then replaced. A complete/flagsresponse takes over entirely, so bootstrapped-only keys don't persist past it. Only enabled flags are seeded: atrueboolean or a non-empty variant string. Afalseor empty value is dropped, matching posthog-js. Seed payloads with the separatefeatureFlagPayloadsoption. Flag values and payloads must be JSON-serializable, or they're dropped. Bootstrapped flags are cleared onreset().
The feature-flags-loaded signal fires as soon as bootstrapped flags are applied, so startup logic can read them immediately. These SDKs don't support the sessionID bootstrap option. When person profiles are set to never, the SDK preserves a different anonymous identity instead of merging it into an identified bootstrap.
See the SDK bootstrapping guide (/docs/libraries/bootstrapping.md) for the cross-SDK overview.
Experiments (A/B tests)
Since experiments (/docs/experiments/start-here.md) use feature flags, the code for running an experiment is very similar to the feature flags code:
Kotlin
import com.posthog.PostHog
if (PostHog.getFeatureFlag("experiment-feature-flag-key") == "variant-name") {
// do something
}It's also possible to run experiments without using feature flags (/docs/experiments/running-experiments-without-feature-flags.md).
Group analytics
Group analytics allows you to associate the events for that person's session with a group (e.g. teams, organizations, etc.). Read the Group Analytics (/docs/user-guides/group-analytics.md) guide for more information.
Note: This is a paid feature and is not available on the open-source or free cloud plan. Learn more on the pricing page (/pricing.md).
- Associate the events for this session with a group
Kotlin
import com.posthog.PostHog
// organization is the group type, company_id_in_your_db is the group ID
PostHog.group(
type = "company",
key = "company_id_in_your_db"
)- Associate the events for this session with a group AND update the properties of that group
Kotlin
import com.posthog.PostHog
PostHog.group(
type = "company",
key = "company_id_in_your_db",
groupProperties = mapOf("name" to "Awesome Inc.")
)The name is a special property which is used in the PostHog UI for the name of the group. If you don't specify a name property, the group ID will be used instead.
Error tracking
To set up error tracking in your project, see the error tracking docs (/docs/error-tracking.md).
Logs
To set up logs (/docs/logs.md) in your Android app, follow the Android logs installation guide (/docs/logs/installation/android.md). The SDK exposes PostHog.logger.{trace,debug,info,warn,error,fatal} for sending structured records to PostHog Logs, with batching, offline persistence, and a rate cap built in.
Minimum version:
com.posthog:posthog-android@3.46.0or later.
Session replay
To set up session replay (/docs/session-replay/mobile.md) in your project, all you need to do is install the Android SDK, enable "Record user sessions" in your project settings and enable the sessionReplay option.
Surveys
To set up surveys, follow the additional installation instructions for Android (/docs/surveys/installation/android.md). Surveys launched with popover presentation (/docs/surveys/creating-surveys.md#presentation) are automatically shown to users matching the display conditions (/docs/surveys/creating-surveys.md#display-conditions) you set up.
Offline behavior
The PostHog Android SDK will continue to capture events when the device is offline. The events are stored in a queue in the device's file storage and are flushed when the device is online.
- The queue has a maximum size defined by
maxQueueSizein the configuration. - When the queue is full, the oldest event is deleted first.
- The queue is flushed when the app is restarted and the device is online.
- When you call
flush()(#flush) while the device is offline, it aborts early and the events are not flushed.
Debug mode
If you're not seeing the expected events being captured, the feature flags being evaluated, surveys being shown, or session replay/error tracking behavior, you can enable debug mode to see what's happening.
You can enable debug mode by setting the debug option to true in the PostHogAndroidConfig object. This will enable verbose logs about the inner workings of the SDK.
Kotlin
val config = PostHogAndroidConfig(apiKey = POSTHOG_API_KEY, host = POSTHOG_HOST).apply {
debug = true
// ... other config options
}All configuration options
When creating the PostHog client, pass a PostHogAndroidConfig. It inherits the core PostHogConfig options and adds Android-specific options.
Kotlin
import com.posthog.PersonProfiles
import com.posthog.android.PostHogAndroidConfig
val config = PostHogAndroidConfig(
apiKey = POSTHOG_API_KEY,
host = POSTHOG_HOST
).apply {
captureApplicationLifecycleEvents = true
captureScreenViews = true
captureDeepLinks = true
flushAt = 20
maxQueueSize = 1000
maxBatchSize = 50
maxRetries = 3
flushIntervalSeconds = 30
debug = false
optOut = false
sendFeatureFlagEvent = true
featureFlagCalledCacheSize = 1000
preloadFeatureFlags = true
evaluationContexts = listOf("production", "android", "mobile")
setDefaultPersonProperties = true
personProfiles = PersonProfiles.IDENTIFIED_ONLY
reuseAnonymousId = false
sessionReplay = false
errorTrackingConfig.autoCapture = false
}Android-specific options
| Option | Default | Description |
|---|---|---|
captureApplicationLifecycleEvents |
true |
Captures Application Installed, Application Updated, Application Opened, and Application Backgrounded. |
captureScreenViews |
true |
Captures $screen for foreground android.app.Activity screens. |
captureDeepLinks |
true |
Captures Deep Link Opened with URL/query/referrer properties. |
Core options
| Option | Default | Description |
|---|---|---|
debug |
false |
Enables verbose SDK logs in Logcat. You can also call PostHog.debug(true). |
optOut |
false |
Prevents data capture when enabled. You can also call PostHog.optOut() and PostHog.optIn(). |
flushAt |
20 |
Number of queued events that triggers a flush. |
maxQueueSize |
1000 |
Maximum number of events kept across memory and disk before FIFO eviction. |
maxBatchSize |
50 |
Maximum number of events sent in one batch request. |
maxRetries |
3 |
Maximum retry attempts for failed requests. |
flushIntervalSeconds |
30 |
Maximum delay before queued data is flushed. |
encryption |
null |
Optional PostHogEncryption implementation for encrypting persisted queued events. |
proxy |
null |
Optional java.net.Proxy for PostHog API requests. |
getAnonymousId |
generated UUID | Optional hook to customize anonymous ID generation. |
reuseAnonymousId |
false |
Reuses one anonymous ID across user changes on the same device. |
personProfiles |
PersonProfiles.IDENTIFIED_ONLY |
Controls when person profiles are processed: IDENTIFIED_ONLY, ALWAYS, or NEVER. |
setDefaultPersonProperties |
true |
Includes default device and app properties in feature flag evaluation requests. |
releaseIdentifier |
app/version fallback | Release identifier used by error tracking and uploaded ProGuard/R8 mappings. The Android Gradle plugin can inject this automatically. |
tracingHeaders |
null |
Exact hostnames that should receive PostHog tracing headers when using PostHogOkHttpInterceptor. |
Feature flag options
| Option | Default | Description |
|---|---|---|
sendFeatureFlagEvent |
true |
Sends $feature_flag_called when a feature flag is evaluated. |
featureFlagCalledCacheSize |
1000 |
Number of feature flag calls cached for deduplicating $feature_flag_called events. |
preloadFeatureFlags |
true |
Fetches feature flags automatically during setup. |
evaluationContexts |
null |
Context tags that constrain which feature flags are evaluated. Available in version 3.29.1+. The legacy evaluationEnvironments option is available in version 3.24.0+. |
onFeatureFlags |
null |
Callback invoked when feature flags are loaded. |
Product configuration objects
| Option | Default | Description |
|---|---|---|
sessionReplay |
false |
Enables session replay when project settings also allow recording. |
sessionReplayConfig |
PostHogSessionReplayConfig() |
Configures masking, screenshots, Logcat capture, sampling, and custom drawable conversion. |
logs |
PostHogLogsConfig() |
Configures Android logs (/docs/logs/installation/android.md). |
errorTrackingConfig |
PostHogErrorTrackingConfig() |
Configures error tracking. autoCapture defaults to false; set it to true to autocapture uncaught exceptions when project settings also enable error tracking. |
surveys |
false |
Internal/experimental native Android survey support. Native Android survey UI is not fully supported or documented yet. |
surveysConfig |
PostHogSurveysConfig() |
Internal/experimental survey display delegate configuration, primarily for hybrid SDKs. |
bootstrap |
null |
Seeds identity (distinctId, isIdentifiedId) and feature-flag state (featureFlags, featureFlagPayloads) before the first /flags response. Bootstrapped identity applies to the first session; only enabled flags are served, until the first /flags response replaces them. See SDK bootstrapping (/docs/libraries/bootstrapping.md#behavior-on-mobile-sdks). |
Event filtering with beforeSend
Use addBeforeSend to redact, modify, or drop events before they are queued. Return null to drop an event.
Kotlin
config.addBeforeSend { event ->
event.properties?.remove("password")
if (event.event == "internal_debug_event") {
null
} else {
event
}
}Filtering autocaptured screens
You can stop specific screens from being autocaptured by filtering them in your before-send hook. Return null for any $screen event whose $screen_name matches a screen you don't want to track, and it's dropped before being sent – keeping unwanted screen views out of your event log.
Because it's just a function, you can filter however you like – an ignorelist (drop the screens you name), an allowlist (invert the check to capture only the screens you name), or any custom rule such as a name prefix, a regex, or a check against the event's properties.
Kotlin
val ignoredScreens = setOf("Splash", "Debug")
config.addBeforeSend { event ->
val screenName = event.properties?.get("$screen_name") as? String
if (event.event == "$screen" && screenName in ignoredScreens) {
null
} else {
event
}
}Push notifications
The Android SDK can register a device for Workflows (/docs/workflows.md) push notifications and capture when a user opens one. For setup, including automatic and manual registration, capturing opens, opting out, and identity verification, see Push notifications (/docs/workflows/push-notifications.md).
FAQ
What Android API level is required?
The Android SDK supports Android API 23 and newer.
Do I need to declare permissions in the AndroidManifest.xml?
Usually, no. The SDK declares android.permission.INTERNET and android.permission.ACCESS_NETWORK_STATE, and Android's manifest merger adds them to your app. The SDK does not declare or require an Android Service.
Still have questions?
Ask PostHog AI
Was this page useful?
HelpfulCould be better
references/angular.md
AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt
Angular
PostHog makes it easy to get data about traffic and usage of your Angular app. Integrating PostHog into your site enables analytics about user behavior, custom events capture, session recordings, feature flags, and more.
This guide walks you through integrating PostHog into your Angular app using the JavaScript Web SDK (/docs/libraries/js.md).
Installation
Install posthog-js using your package manager:
npm
npm install --save posthog-jsYarn
yarn add posthog-jspnpm
pnpm add posthog-jsBun
bun add posthog-jsIf your site sets a Content-Security-Policy, it needs to allow PostHog. This applies to the snippet and to package installs alike: the SDK lazy-loads extra bundles (session replay, surveys) from PostHog's CDN, and sends events to the ingestion host. PostHog serves from subdomains of
posthog.comthat change over time, so allow the wildcard:script-src 'self' https://*.posthog.com; connect-src 'self' https://*.posthog.com; worker-src 'self' blob: data:;
script-srccovers the snippet and the lazy-loaded bundles,connect-srccovers event ingestion and feature flags, andworker-srccovers session replay. The toolbar needs a few more (/docs/advanced/content-security-policy.md), or use a reverse proxy (/docs/advanced/proxy.md) so everything is first-party. Failing to do so causes silent failures wherecaptureandidentifycalls never send, so the integration looks complete while zero events arrive. Rememberconnect-srcfalls back todefault-src, sodefault-src 'self'blocks event delivery even when the script itself is bundled.
Initialize the PostHog client
Generate environment files for your project with ng g environments. Configure the following environment variables:
posthogKey: Your project token from your project settings.posthogHost: Your project's client API host. Usuallyhttps://us.i.posthog.comfor US-based projects andhttps://eu.i.posthog.comfor EU-based projects.
Angular v17+
For Angular v17 and above, you can set up PostHog as a singleton service. To do this, start by creating and injecting a PosthogService instance.
Create a service by running ng g service services/posthog. The service should look like this:
posthog.service.ts
// src/app/services/posthog.service.ts
import { Injectable, NgZone } from "@angular/core";
import posthog from "posthog-js";
import { environment } from "../../environments/environment";
@Injectable({ providedIn: "root" })
export class PosthogService {
constructor(
private ngZone: NgZone,
) {
this.initPostHog();
}
private initPostHog() {
this.ngZone.runOutsideAngular(() => {
posthog.init(environment.posthogKey, {
api_host: environment.posthogHost,
defaults: '2026-05-30',
});
});
}
}The service is initialized outside of the Angular zone to reduce change detection cycles. This is important to avoid performance issues with session recording.
Then, inject the service in your app's root component app.component.ts. This will make sure PostHog is initialized before any other component is rendered.
app.component.ts
// src/app/app.component.ts
import { Component } from "@angular/core";
import { RouterOutlet } from "@angular/router";
import { PosthogService } from "./services/posthog.service";
@Component({
selector: "app-root",
styleUrls: ["./app.component.scss"],
template: `
<router-outlet />`,
imports: [RouterOutlet],
})
export class AppComponent {
title = "angular-app";
constructor(posthogService: PosthogService) {}
}Angular v16 and below
In your src/main.ts, initialize PostHog using your project token and instance address. You can find both in your project settings.
main.ts
// src/main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
import { environment } from "./environments/environment";
import posthog from 'posthog-js'
posthog.init(environment.posthogKey, {
api_host: environment.posthogHost,
defaults: '2026-05-30'
})
bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err));Identifying users
Identifying users is required. Call
posthog.identify('your-user-id')after login to link events to a known user. This is what connects frontend event captures, session replays (/docs/session-replay.md), LLM traces (/docs/ai-engineering.md), and error tracking (/docs/error-tracking.md) to the same person — and lets backend events link back too.Use a stable ID from your auth system when possible, not an email or display name. Send those as person properties instead. If your app has no other stable key, email works as a fallback if they are unique. Never a shared literal like
"anonymous"or"user", which pools many people onto one person and corrupts their data. When no ID is available at all, skip the identify and retain the anonymous distinct ID that's automatically assigned.Call
posthog.reset()on logout, so the next person to use the browser doesn't inherit the last one's identity.See our guide on identifying users (/docs/getting-started/identify-users.md) for how to set this up.
If your app calls your own backend, tracing_headers adds X-POSTHOG-DISTINCT-ID and X-POSTHOG-SESSION-ID to matching fetch and XMLHttpRequest requests. This lets server-side SDKs link backend events, errors, and LLM traces back to frontend sessions and replays. Use hostnames only, without protocols or paths.
JavaScript
posthog.init('<ph_project_token>', {
api_host: 'https://us.i.posthog.com',
// Optional: send PostHog session/user context to your backend
tracing_headers: ['api.example.com'],
})This works in local development too, but match on the hostname alone: use 'localhost', not 'localhost:3000'. Ports are never part of a hostname, so a value with one in it never matches anything. localhost and 127.0.0.1 are also different hostnames — use whichever your app actually calls.
Tracing headers help you attribute events across front and backend consistently. When this isn't available, use your server-side stable IDs to deduce the matching distinctId, and pass it in when capturing the event.
Note: If you're using Typescript, you might have some trouble getting your types to compile because we depend on
rrwebbut don't ship all of their types. To accommodate that, you'll need to add@rrweb/types@2.0.0-alpha.17andrrweb-snapshot@2.0.0-alpha.17as a dependency if you want your Angular compiler to typecheck correctly.Given the nature of this library, you might need to completely clear your
.npmcache to get this to work as expected. Make sure your clear your CI's cache as well.In the rare case the versions above get out-of-date, you can check our JavaScript SDK's
package.jsonto understand what's the exact version you need to depend on.
Set up a reverse proxy (recommended)
We recommend setting up a reverse proxy (/docs/advanced/proxy.md), so that events are less likely to be intercepted by tracking blockers.
We have our own managed reverse proxy service (/docs/advanced/proxy/managed-reverse-proxy.md), which is free for all PostHog Cloud users, routes through our infrastructure, and makes setting up your proxy easy.
If you don't want to use our managed service then there are several other options for creating a reverse proxy, including using Cloudflare (/docs/advanced/proxy/cloudflare.md), AWS Cloudfront (/docs/advanced/proxy/cloudfront.md), and Vercel (/docs/advanced/proxy/vercel.md).
Grouping products in one project (recommended)
If you have multiple customer-facing products (e.g. a marketing website + mobile app + web app), it's best to install PostHog on them all and group them in one project (/docs/settings/projects.md).
This makes it possible to track users across their entire journey (e.g. from visiting your marketing website to signing up for your product), or how they use your product across multiple platforms.
Add IPs to Firewall/WAF allowlists (recommended)
For certain features like heatmaps (/docs/toolbar/heatmaps.md), your Web Application Firewall (WAF) may be blocking PostHog's requests to your site. Add these IP addresses to your WAF allowlist or rules to let PostHog access your site.
EU: 3.75.65.221, 18.197.246.42, 3.120.223.253
US: 44.205.89.55, 52.4.194.122, 44.208.188.173
These are public, stable IPs used by PostHog services.
PostHog captures heatmap screenshots using Browserless, which has its own IP addresses. Browserless publishes the current list here.
An allowlist does not help when your app has a private address. For apps on an internal network, see internal and intranet applications (/docs/session-replay/troubleshooting.md#internal-and-intranet-applications).
Tracking pageviews
PostHog automatically tracks your pageviews by hooking up to the browser's navigator API as long as you initialize PostHog with the defaults config option set after 2026-01-30.
Capture custom events
To capture custom events (/docs/product-analytics/capture-events.md), import posthog and call posthog.capture(). Below is an example of how to do this in a component:
app.component.ts
import { Component } from '@angular/core';
import posthog from 'posthog-js'
@Component({
// existing component code
})
export class AppComponent {
handleClick() {
posthog.capture(
'home_button_clicked',
)
}
}Session replay
Session replay uses change detection to record the DOM. This can clash with Angular's change detection.
The recorder tool attempts to detect when an Angular zone is present and avoid the clash but might not always succeed.
- If you followed the installation instructions for Angular v17 and above, you don't need to do anything.
- If you followed the installation instructions for Angular v16 and below and you see performance impact from recording in an Angular project, ensure that you use
ngZone.runOutsideAngular.
posthog.service.ts
import { Injectable } from '@angular/core';
import posthog from 'posthog-js'
@Injectable({ providedIn: 'root' })
export class PostHogSessionRecordingService {
constructor(private ngZone: NgZone) {}
initPostHog() {
this.ngZone.runOutsideAngular(() => {
posthog.init(
/* your config */
)
})
}
}Angular with SSR
To use PostHog with Angular server-side rendering (SSR), you need to:
- Update the PostHog web JS client to only initialize on the client-side.
- Initialize PostHog Node on the server-side.
1. Update the PostHog web JS client
Update your posthog.service.ts to restrict the initialization of the PostHog web JS client to the client-side. The web SDK uses methods that are not available on the server side, so we need to check if we're on the client side before initializing PostHog.
posthog.service.ts
import { PLATFORM_ID } from "@angular/core";
@Injectable({ providedIn: "root" })
export class PosthogService {
constructor(
private ngZone: NgZone,
@Inject(PLATFORM_ID) private platformId: Object
) {
// Only initialize PostHog in browser environment
if (isPlatformBrowser(this.platformId)) {
this.initPostHog(); //+
}
}
private initPostHog() {
this.ngZone.runOutsideAngular(() => {
posthog.init(environment.posthogKey, {2. Add server-side initialization
Angular SSR uses a server.ts file to handle requests. We can add any server-side initialization code to this file.
First, install the posthog-node package to run on the server side.
npm
npm install posthog-node --saveYarn
yarn add posthog-nodepnpm
pnpm add posthog-nodeBun
bun add posthog-nodeThen, add the following code to the server.ts file:
server.ts
// src/server.ts
import { environment } from './environments/environment';
import { PostHog } from 'posthog-node'
/**
* Extract distinct ID from PostHog cookie
*/
function getDistinctIdFromCookie(cookieHeader: string | undefined): string | null {
if (!cookieHeader) return null;
const cookieMatch = cookieHeader.match(`ph_${environment.posthogKey}_posthog=([^;]+)`);
if (cookieMatch) {
try {
const parsed = JSON.parse(decodeURIComponent(cookieMatch[1]));
return parsed?.distinct_id || null;
} catch (error) {
console.error('Error parsing PostHog cookie:', error);
return null;
}
}
return null;
}
/**
* Handle all other requests by rendering the Angular application.
*/
app.get('**', async (req, res, next) => {
const { protocol, originalUrl, baseUrl, headers } = req;
const distinctId = getDistinctIdFromCookie(headers.cookie);
let isFeatureEnabled = false;
const client = new PostHog(
environment.posthogKey,
{ host: environment.posthogHost }
);
if (distinctId) {
client.capture({
distinctId: distinctId,
event: 'test_ssr_event',
properties: {
message: 'Hello from Angular SSR!'
}
})
isFeatureEnabled = await client.isFeatureEnabled(
'your_feature_flag_key', distinctId) || false;
}
commonEngine
.render({
bootstrap,
documentFilePath: indexHtml,
url: `${protocol}://${headers.host}${originalUrl}`,
publicPath: browserDistFolder,
providers: [
{ provide: APP_BASE_HREF, useValue: baseUrl },
{ provide: 'FEATURE_FLAG_ENABLED', useValue: isFeatureEnabled }
],
})
.then((html) => res.send(html))
.catch((err) => next(err));
await client.shutdown()
});This code does the following:
- Extracts the distinct ID from the cookie header. This is set by the web JS client.
- Captures an event on the server side.
- Evaluates a feature flag on the server side. This can be passed as a provider to the Angular application.
- Calls
shutdownon the PostHog Node client to ensure all events are flushed.
Using PostHog in server-side code
Angular SSR does not allow Node.js code to be bundled into client-side components. Even though resolvers and other server-side code can be written along with client-side components, you cannot use PostHog Node in those components.
Next steps
For any technical questions for how to integrate specific PostHog features into Angular (such as feature flags, A/B testing, surveys, etc.), have a look at our JavaScript Web SDK docs (/docs/libraries/js/usage.md).
Alternatively, the following tutorials can help you get started:
- How to set up Angular analytics, feature flags, and more (/tutorials/angular-analytics.md)
- How to set up A/B tests in Angular (/tutorials/angular-ab-tests.md)
- How to set up surveys in Angular (/tutorials/angular-surveys.md)
Still have questions?
Ask PostHog AI
Was this page useful?
HelpfulCould be better
references/astro.md
AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt
Astro
PostHog makes it easy to get data about traffic and usage of your Astro app. Integrating PostHog into your site enables analytics about user behavior, custom events capture, session recordings, feature flags, and more.
This guide walks you through integrating PostHog into your Astro app using the JavaScript Web SDK (/docs/libraries/js.md).
Beta: integration via LLM
Install PostHog for Astro in seconds with our wizard by running this prompt with LLM coding agents (/blog/envoy-wizard-llm-agent.md) like Cursor and Bolt, or by running it in your terminal.
npx @posthog/wizard
Learn more (/wizard.md)
Or, to integrate manually, continue with the rest of this guide.
Installation
In your src/components folder, create a posthog.astro file:
Terminal
cd ./src/components
# or 'cd ./src && mkdir components && cd ./components' if your components folder doesnt exist
touch posthog.astroIn this file, add your Web snippet which you can find in your project settings. Be sure to include the is:inline directive to prevent Astro from processing it, or you will get Typescript and build errors that property 'posthog' does not exist on type 'Window & typeof globalThis'.
posthog.astro
---
// src/components/posthog.astro
---
<script is:inline>
!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],Object.defineProperty(u,"toString",{configurable:!0,enumerable:!0,writable:!0,value:function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e}}),Object.defineProperty(u.people,"toString",{configurable:!0,enumerable:!0,writable:!0,value:function(){return u.toString(1)+".people (stub)"}}),o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagResult reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys getNextSurveyStep onSessionId".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
posthog.init('<ph_project_token>', {
api_host:'https://us.i.posthog.com',
defaults: '2026-05-30'
})
</script>If your site sets a Content-Security-Policy, it needs to allow PostHog. This applies to the snippet and to package installs alike: the SDK lazy-loads extra bundles (session replay, surveys) from PostHog's CDN, and sends events to the ingestion host. PostHog serves from subdomains of
posthog.comthat change over time, so allow the wildcard:script-src 'self' https://*.posthog.com; connect-src 'self' https://*.posthog.com; worker-src 'self' blob: data:;
script-srccovers the snippet and the lazy-loaded bundles,connect-srccovers event ingestion and feature flags, andworker-srccovers session replay. The toolbar needs a few more (/docs/advanced/content-security-policy.md), or use a reverse proxy (/docs/advanced/proxy.md) so everything is first-party. Failing to do so causes silent failures wherecaptureandidentifycalls never send, so the integration looks complete while zero events arrive. Rememberconnect-srcfalls back todefault-src, sodefault-src 'self'blocks event delivery even when the script itself is bundled.
Using with Astro's view transitions (ClientRouter)
If you've opted in to Astro's <ClientRouter> component for client-side navigation, you'll need to add an initialization guard to prevent PostHog from running multiple times during page transitions.
Update your posthog.astro file to wrap the snippet with a check:
posthog.astro
---
// src/components/posthog.astro
---
<script is:inline>
if (!window.__posthog_initialized) {
window.__posthog_initialized = true;
!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],Object.defineProperty(u,"toString",{configurable:!0,enumerable:!0,writable:!0,value:function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e}}),Object.defineProperty(u.people,"toString",{configurable:!0,enumerable:!0,writable:!0,value:function(){return u.toString(1)+".people (stub)"}}),o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagResult reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys getNextSurveyStep onSessionId".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
posthog.init('<ph_project_token>', {
api_host: 'https://us.i.posthog.com',
defaults: '2026-05-30',
capture_pageview: 'history_change'
})
}
</script>Without this guard, ClientRouter's soft navigation can re-execute the inline script during page transitions, causing a stack overflow error. The capture_pageview: 'history_change' option ensures pageviews are tracked automatically as users navigate.
The next step is to a create a Layout where we will use posthog.astro. Create a new file PostHogLayout.astro in your src/layouts folder:
Terminal
cd .. && cd .. # move back to your base directory if you're still in src/components/posthog.astro
cd ./src/layouts
# or 'cd ./src && mkdir layouts && cd ./layouts' if your layouts folder doesn't exist yet
touch PostHogLayout.astroAdd the following code to PostHogLayout.astro:
PostHogLayout.astro
---
import PostHog from '../components/posthog.astro'
---
<head>
<PostHog />
</head>Lastly, update index.astro to wrap your existing app components with the new Layout:
index.astro
---
import PostHogLayout from '../layouts/PostHogLayout.astro';
---
<PostHogLayout>
<!-- your existing app components -->
</PostHogLayout>Identifying users
Identifying users is required. Call
posthog.identify('your-user-id')after login to link events to a known user. This is what connects frontend event captures, session replays (/docs/session-replay.md), LLM traces (/docs/ai-engineering.md), and error tracking (/docs/error-tracking.md) to the same person — and lets backend events link back too.Use a stable ID from your auth system when possible, not an email or display name. Send those as person properties instead. If your app has no other stable key, email works as a fallback if they are unique. Never a shared literal like
"anonymous"or"user", which pools many people onto one person and corrupts their data. When no ID is available at all, skip the identify and retain the anonymous distinct ID that's automatically assigned.Call
posthog.reset()on logout, so the next person to use the browser doesn't inherit the last one's identity.See our guide on identifying users (/docs/getting-started/identify-users.md) for how to set this up.
If your app calls your own backend, tracing_headers adds X-POSTHOG-DISTINCT-ID and X-POSTHOG-SESSION-ID to matching fetch and XMLHttpRequest requests. This lets server-side SDKs link backend events, errors, and LLM traces back to frontend sessions and replays. Use hostnames only, without protocols or paths.
JavaScript
posthog.init('<ph_project_token>', {
api_host: 'https://us.i.posthog.com',
// Optional: send PostHog session/user context to your backend
tracing_headers: ['api.example.com'],
})This works in local development too, but match on the hostname alone: use 'localhost', not 'localhost:3000'. Ports are never part of a hostname, so a value with one in it never matches anything. localhost and 127.0.0.1 are also different hostnames — use whichever your app actually calls.
Tracing headers help you attribute events across front and backend consistently. When this isn't available, use your server-side stable IDs to deduce the matching distinctId, and pass it in when capturing the event.
Set up a reverse proxy (recommended)
We recommend setting up a reverse proxy (/docs/advanced/proxy.md), so that events are less likely to be intercepted by tracking blockers.
We have our own managed reverse proxy service (/docs/advanced/proxy/managed-reverse-proxy.md), which is free for all PostHog Cloud users, routes through our infrastructure, and makes setting up your proxy easy.
If you don't want to use our managed service then there are several other options for creating a reverse proxy, including using Cloudflare (/docs/advanced/proxy/cloudflare.md), AWS Cloudfront (/docs/advanced/proxy/cloudfront.md), and Vercel (/docs/advanced/proxy/vercel.md).
Grouping products in one project (recommended)
If you have multiple customer-facing products (e.g. a marketing website + mobile app + web app), it's best to install PostHog on them all and group them in one project (/docs/settings/projects.md).
This makes it possible to track users across their entire journey (e.g. from visiting your marketing website to signing up for your product), or how they use your product across multiple platforms.
Add IPs to Firewall/WAF allowlists (recommended)
For certain features like heatmaps (/docs/toolbar/heatmaps.md), your Web Application Firewall (WAF) may be blocking PostHog's requests to your site. Add these IP addresses to your WAF allowlist or rules to let PostHog access your site.
EU: 3.75.65.221, 18.197.246.42, 3.120.223.253
US: 44.205.89.55, 52.4.194.122, 44.208.188.173
These are public, stable IPs used by PostHog services.
PostHog captures heatmap screenshots using Browserless, which has its own IP addresses. Browserless publishes the current list here.
An allowlist does not help when your app has a private address. For apps on an internal network, see internal and intranet applications (/docs/session-replay/troubleshooting.md#internal-and-intranet-applications).
Next steps
For any technical questions for how to integrate specific PostHog features into Astro (such as analytics, feature flags, A/B testing, surveys, etc.), have a look at our JavaScript Web SDK docs (/docs/libraries/js/usage.md).
Alternatively, the following tutorials can help you get started:
- How to set up Astro analytics, feature flags, and more (/tutorials/astro-analytics.md)
- How to set up A/B tests in Astro (/tutorials/astro-ab-tests.md)
- How to set up surveys in Astro (/tutorials/astro-surveys.md)
Still have questions?
Ask PostHog AI
Was this page useful?
HelpfulCould be better
references/configuration.md
AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt
iOS SDK configuration
Autocapture configuration
You can enable or disable autocapture through the PostHogConfig object.
Tracing headers
Use tracingHeaders to connect iOS network requests to backend events, errors, and LLM traces captured by a server-side PostHog SDK:
Swift
let configuration = PostHogConfig(projectToken: "<ph_project_token>", host: "https://us.i.posthog.com")
configuration.tracingHeaders = ["api.example.com"]
PostHogSDK.shared.setup(configuration)Hostnames are matched exactly and should not include protocols, paths, ports, or wildcard subdomains. Matching URLSession requests include X-POSTHOG-DISTINCT-ID and X-POSTHOG-SESSION-ID when those values are available.
Tracing headers require method swizzling, so configuration.enableSwizzling must remain true.
Flush configuration
The iOS SDK uses an internal queue to make calls fast and non-blocking. It also batches requests and flushes asynchronously, making it perfect to use in any part of your mobile app.
You can configure how many events queue before flushing with flushAt. Setting this to 1 will send events immediately and will use more battery. The default is 20.
You can also configure the flush interval with flushIntervalSeconds (default 30), after which queued events are sent regardless of how many have been gathered:
Swift
configuration.flushAt = 1
configuration.flushIntervalSeconds = 30You can also manually flush the queue to start sending events immediately instead of waiting for the next batch:
Swift
PostHogSDK.shared.capture("logged_out")
PostHogSDK.shared.flush()Flushing is best-effort and asynchronous – it starts sending queued events in the background but doesn't wait for the request to finish, so it isn't a delivery guarantee.
Amending, dropping or sampling events
Since version 3.28.0, you can provide a BeforeSendBlock function when initializing the SDK to amend, drop or sample events before they are sent to PostHog.
⚠️ Note: This replaces the deprecated
propertiesSanitizeroption and provides more flexibility in modifying events. You can achieve the same functionality aspropertiesSanitizerby using aBeforeSendBlockthat mutates the event's properties in place.
🚨 Warning: Amending and sampling events is advanced functionality that requires careful implementation. Core PostHog features may require 100% of unmodified events to function properly. We recommend only modifying or sampling your own custom events if possible, and preserving all PostHog internal events in their original form.
Redacting information in events
BeforeSendBlock gives you one place to edit or redact information before it is sent to PostHog. For example:
Redact URLs in event properties
Swift
let config = PostHogConfig(projectToken: "<ph_project_token>", host: "<ph_api_client_host>")
config.setBeforeSend { event in
// Redact URLs
if let url = event.properties["url"] as? String {
event.properties["url"] = url.map { _ in "*" }.joined()
}
return event
}Redact sensitive information from event properties
Swift
let config = PostHogConfig(projectToken: "<ph_project_token>", host: "<ph_api_client_host>")
config.setBeforeSend { event in
// Redact sensitive information
if let email = event.properties["email"] as? String {
event.properties["email"] = email.map { _ in "*" }.joined()
}
return event
}Drop events by event name
Swift
let config = PostHogConfig(projectToken: "<ph_project_token>", host: "<ph_api_client_host>")
config.setBeforeSend { event in
// Drop all events named "Stale Event"
if event.event == "Stale Event" {
return nil
}
return event
}Filter autocaptured screen views
You can stop specific screens from being autocaptured by filtering them in your before-send hook. Return null for any $screen event whose $screen_name matches a screen you don't want to track, and it's dropped before being sent – keeping unwanted screen views out of your event log.
Because it's just a function, you can filter however you like – an ignorelist (drop the screens you name), an allowlist (invert the check to capture only the screens you name), or any custom rule such as a name prefix, a regex, or a check against the event's properties.
Swift
let config = PostHogConfig(projectToken: "<ph_project_token>", host: "<ph_api_client_host>")
let ignoredScreens: Set<String> = ["Splash", "Debug"]
config.setBeforeSend { event in
if event.event == "$screen",
let screenName = event.properties["$screen_name"] as? String,
ignoredScreens.contains(screenName) {
return nil
}
return event
}Sampling events
Sampling lets you choose to send only a percentage of events to PostHog. It is a good way to control your costs without having to completely turn off features of the SDK.
Sample events by event name
Swift
let config = PostHogConfig(projectToken: "<ph_project_token>", host: "<ph_api_client_host>")
config.setBeforeSend { event in
// Sample 10% of Sampled Event events
if event.event == "Sampled Event" {
if Double.random(in: 0...1) < 0.1 {
event.properties["$sample_type"] = ["sampleByEvent"]
event.properties["$sample_threshold"] = 0.1
event.properties["$sampled_events"] = ["Sampled Event"]
return event
}
return nil
}
return event
}Chaining multiple BeforeSendBlocks
You can provide an array of BeforeSendBlock functions to be called one after the other:
Swift
let config = PostHogConfig(projectToken: "<ph_project_token>", host: "<ph_api_client_host>")
config.setBeforeSend(
// First block: Drop all events named "Stale Event"
{ event in
if event.event == "Stale Event" {
return nil
}
return event
},
// Second block: Redact sensitive information
{ event in
if let email = event.properties["email"] as? String {
event.properties["email"] = email.map { _ in "*" }.joined()
}
return event
}
)Note: When chaining beforeSend blocks, order is important. The first block is executed first and the mutated event is passed along to the second block, and so on. If at any point in the chain the event is dropped, any subsequent blocks will not be executed.
Setting up app groups
- Configure App Groups: Set up an App Group in Xcode for your main app and extension targets
- Configure PostHog: Use the same App Group identifier in all targets:
Swift
let config = PostHogConfig(projectToken: "<ph_project_token>", host: "<ph_api_client_host>")
config.appGroupIdentifier = "group.com.yourcompany.yourapp"
PostHogSDK.shared.setup(config)Method swizzling
Method swizzling is a technique that enables the SDK to intercept and modify method calls at runtime to provide advanced features like screen view tracking, element interactions, session replay, surveys, and more.
Method swizzling is enabled by default, but can be disabled by setting the relevant config option to false in the PostHogConfig object:
| Feature | Description | Config option |
|---|---|---|
| Screen view tracking | Automatically captures when view controllers are presented | config.captureScreenViews |
| Element interactions | Automatically tracks user interactions with UI elements | config.captureElementInteractions |
| Rage clicks | Automatically captures $rageclick events for rapid repeated taps in the same area (iOS/macCatalyst, UIKit) |
config.rageClickConfig.enabled |
| Session replay | Records user sessions | config.sessionReplay |
| Surveys | Displays surveys at appropriate times | config.surveys |
| Advanced metrics tracking | Provides more precise session ID calculation and rotation by detecting user activity and idleness | N/A |
Disabling all method swizzling
Since version 3.34.0, you can opt out of all swizzling using the enableSwizzling configuration option. When you disable swizzling, the SDK disables the features listed above.
Swift
let config = PostHogConfig(projectToken: "<ph_project_token>", host: "<ph_api_client_host>")
config.enableSwizzling = false
PostHogSDK.shared.setup(config)Note: When method swizzling is disabled, features that depend on it will not work even if they are individually enabled in the config. For example, if you set
config.sessionReplay = trueandconfig.enableSwizzling = false, session replay will not be enabled.
Session metrics management
Method swizzling is particularly important for accurate session metrics tracking (/tutorials/session-metrics.md). With swizzling enabled, the SDK can better detect user activity and idle times to provide a better session rotation.
With swizzling disabled, the SDK only uses application open/backgrounded events to detect user activity, which can lead to a sub-optimal session calculation.
Custom keyboard extensions
Custom keyboard extensions have stricter security rules than other extension types. To use PostHog in a custom keyboard, the keyboard must have Open Access permission enabled. This permission is required for network requests and write access to shared containers.
Users must explicitly grant Open Access in Settings > General > Keyboard > Keyboards > [Your Keyboard] > Allow Full Access.
All configuration options
The PostHogConfig object contains several other settings you can toggle:
| Attribute | Description |
|---|---|
flushAt Type: Integer Default: 20 (5 on tvOS) |
The number of queued events that the posthog client should flush at. Setting this to 1 will not queue any events and will use more battery. |
flushIntervalSeconds Type: TimeInterval Default: 30 |
The amount of time to wait before each tick of the flush timer, in seconds. Smaller values will make events delivered in a more real-time manner and also use more battery. A value smaller than 10 seconds will seriously degrade overall performance. |
maxQueueSize Type: Integer Default: 1000 (100 on tvOS) |
The maximum number of items to queue before starting to drop old ones. This should be a value greater than zero, the behavior is undefined otherwise. |
maxBatchSize Type: Integer Default: 50 |
Number of maximum events in a batch call. |
maxRetries Type: Integer Default: 3 |
Maximum number of consecutive flush attempts before the entire queue is dropped to avoid infinite retries against a permanently-broken backend (e.g. wrong API key, exhausted quota, deterministic 5xx). Increments on every retriable failure including HTTP 413 cap halving; resets on a successful 2xx response. |
captureApplicationLifecycleEvents Type: Boolean Default: true |
Whether the posthog client should automatically make a capture call for application lifecycle events, such as "Application Installed", "Application Updated" and "Application Opened". |
captureScreenViews Type: Boolean Default: true |
Whether the posthog client should automatically make a screen call when a view controller is added to a view hierarchy. Because the underlying implementation uses method swizzling, we recommend initializing the posthog client as early as possible (before any screens are displayed), ideally during the Application delegate's applicationDidFinishLaunching method. |
enableSwizzling Type: Boolean Default: true |
Enable method swizzling for SDK functionality that depends on it. When disabled, functionality that requires swizzling (like autocapture, screen views, session replay, surveys) will not be installed. |
captureElementInteractions Type: Boolean Default: false |
(UIKit only) Whether the posthog client should automatically make a capture call when the user interacts with an element in a screen. |
rageClickConfig Type: Object Default: .init() |
(iOS/macCatalyst, UIKit) Rage click detection configuration. Includes enabled (default true), minimumTapCount (default 3), thresholdPoints (default 30), and timeoutInterval (default 1.0). Works independently of captureElementInteractions. Available in version 3.51.0+. |
sendFeatureFlagEvent Type: Boolean Default: true |
Send a $feature_flag_called event when a feature flag is used automatically. |
preloadFeatureFlags Type: Boolean Default: true |
Preload feature flags automatically. |
evaluationContexts Type: Array of Strings Default: undefined |
Evaluation context tags that constrain which feature flags are evaluated. When set, only flags with matching evaluation context tags (or no evaluation context tags) will be returned. See evaluation contexts documentation (/docs/feature-flags/evaluation-contexts.md) for more details. Available in version 3.38.0+. The legacy parameter evaluationEnvironments (version 3.33.0+) is also supported for backward compatibility. |
debug Type: Boolean Default: false |
Logs the SDK messages to the Xcode console. |
optOut Type: Boolean Default: false |
Prevents capturing any data if enabled. |
getAnonymousId Type: Function Default: undefined |
Hook that allows for modification of the default mechanism for generating anonymous id (which as of now is just random UUID v7). |
dataMode Type: Enum Default: .any |
Controls when queued data is flushed. Use .wifi to flush only on Wi-Fi; .cellular is a legacy value and behaves like .any. |
personProfiles Type: Enum Default: .identifiedOnly |
Determines the behavior for processing user profiles. |
setDefaultPersonProperties Type: Boolean Default: true |
Automatically set common device and app properties (such as $app_version, $os_name, and $device_type) as person properties for feature flag evaluation. See property overrides (/docs/feature-flags/property-overrides.md) for more details. |
sessionReplay Type: Boolean Default: false |
Enable Recording of Session Replays. |
sessionReplayConfig Type: Object Default: .init() |
Session Replay configuration. See Session Replay installation (/docs/session-replay/installation/ios.md) for more details. |
tracingHeaders Type: Array of Strings Default: nil |
Exact hostnames that should receive PostHog tracing headers when the SDK instruments URLSession requests. |
errorTrackingConfig Type: Object Default: .init() |
Error Tracking configuration. See the error tracking docs (/docs/error-tracking.md) for more details. |
logs Type: Object Default: .init() |
Structured Logs configuration. See Logs installation (/docs/logs/installation/ios.md) for more details. |
surveysConfig Type: Object Default: .init() |
Surveys configuration, including custom survey delegates and display language overrides. |
urlSessionConfiguration Type: URLSessionConfiguration Default: .default |
Custom URLSessionConfiguration used by the SDK for PostHog API requests. |
appGroupIdentifier Type: String Default: nil |
The identifier of the App Group that should be used to store shared analytics data. PostHog will try to get the physical location of the App Group's shared container, otherwise fallback to the default location. |
reuseAnonymousId Type: Boolean Default: false |
Whether the SDK should reuse the anonymous Id between user changes. When enabled, a single Id will be used for all anonymous users on this device. |
surveys Type: Boolean Default: true |
Enable Surveys. |
setBeforeSend Type: Function Default: undefined |
Hook that allows for amending, sampling, or dropping events before they are sent to PostHog. |
bootstrap Type: PostHogBootstrapConfig Default: nil |
Seeds identity (distinctId, isIdentifiedId) and feature-flag state (featureFlags, featureFlagPayloads) before the first /flags response. Bootstrapped identity applies to the first session; only enabled flags are served, until the first /flags response replaces them. See SDK bootstrapping (/docs/libraries/bootstrapping.md#behavior-on-mobile-sdks). |
Still have questions?
Ask PostHog AI
Was this page useful?
HelpfulCould be better
references/django.md
AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt
Django
PostHog makes it easy to get data about traffic and usage of your Django app. Integrating PostHog enables analytics, custom events capture, feature flags, error tracking, and more.
This guide walks you through integrating PostHog into your Django app using the Python SDK (/docs/libraries/python.md).
Beta: integration via LLM
Install PostHog for Django in seconds with our wizard by running this prompt with LLM coding agents (/blog/envoy-wizard-llm-agent.md) like Cursor and Bolt, or by running it in your terminal.
npx @posthog/wizard
Learn more (/wizard.md)
Or, to integrate manually, continue with the rest of this guide.
These docs cover version
7.xof the Python SDK, which requires Python 3.10 or higher. On Python 3.9? See supported versions (#supported-versions).
Installation
To start, run pip install posthog to install PostHog’s Python SDK.
Then, configure PostHog in your app config so it's initialized when Django starts:
your_app/apps.py
from django.apps import AppConfig
import posthog
class YourAppConfig(AppConfig):
name = 'your_app_name'
def ready(self):
posthog.api_key = '<ph_project_token>'
posthog.host = 'https://us.i.posthog.com'Next, if you haven't done so already, add your AppConfig to INSTALLED_APPS in settings.py:
settings.py
INSTALLED_APPS = [
# ... other apps
'your_app_name.apps.YourAppConfig',
]You can find your project token and instance address in your project settings.
To capture events from any file, import posthog and call the method you need. For example:
Python
import posthog
from posthog import identify_context
def some_request(request):
with posthog.new_context():
# Django includes request.user for anonymous visitors too. Only identify
# the context when the visitor is logged in.
if request.user.is_authenticated:
identify_context(str(request.user.pk))
posthog.capture('event_name')Events captured without a context or explicit distinct_id are sent as anonymous events (/docs/data/anonymous-vs-identified-events.md) with an auto-generated distinct_id. See the Python SDK docs (/docs/libraries/python.md#person-profiles-and-properties) for more details.
Identifying users
Identifying users is required. Backend events need a
distinct_idto associate events with the correct user.In Python, you can do this through a context. All event captures in the same context will be tagged automatically with the correct
distinct_id. Typically, you would set a fresh context and identify at the top of each route.Python
from posthog import new_context, identify_context, capture @app.get("/foo") def foo(current_user: User = Depends(get_current_user)): with new_context(): # Set context at the top of a route identify_context(current_user.id) capture("foo_viewed") return {"status": "ok"}When possible, write a small piece of middleware that resolves your authenticated user, wrap a context around the request, and identifies it. Every
capture()downstream is then attributed automatically. The SDK's Django middleware does this automatically and you can replicate it when using the plain Python SDK.
Django contexts middleware
The Python SDK provides a Django middleware that automatically wraps all requests with a context (/docs/libraries/python.md#contexts). This middleware extracts session and user information from each request and tags all events captured during that request with relevant metadata.
Basic setup
Add the middleware to your Django settings. If your app uses Django authentication, place it after django.contrib.auth.middleware.AuthenticationMiddleware so the middleware can use the authenticated Django user as a distinct ID fallback and capture the user's email.
Python
MIDDLEWARE = [
# ... other middleware
'posthog.integrations.django.PosthogContextMiddleware',
# ... other middleware
]The middleware uses the globally configured posthog client by default, so you don't need to create or pass it a separate client instance.
The middleware automatically extracts and uses:
- Session ID from the
X-POSTHOG-SESSION-IDheader, if present - Distinct ID from the
X-POSTHOG-DISTINCT-IDheader, if present, falling back to the authenticated Django user'spk(Django's primary-key alias, which works with custom user models) - User email from the authenticated Django user's
emailasemail - Current URL as
$current_url - Request method as
$request_method - Request path as
$request_path - Forwarded IP address from
X-Forwarded-Foras$ip - User agent from
User-Agentas$user_agent
The session and distinct ID headers are sanitized before use. Empty values are ignored, control characters are removed, values are trimmed, and values are capped at 1000 characters.
All events captured during the request (including exceptions) include these properties and are associated with the extracted session and distinct ID.
Login and signup views
The middleware reads request.user once, before your view runs. On a login or signup request the visitor is still anonymous at that point, so the request's context has no distinct ID. Calling login() inside the view doesn't change that. Everything captured during that request stays anonymous, including the login event itself.
Identify the context from inside the request once you know who the user is. Django's auth signals are the natural place:
Python
from django.contrib.auth.signals import user_logged_in
from django.dispatch import receiver
from posthog import identify_context
@receiver(user_logged_in)
def identify_posthog_user(sender, request, user, **kwargs):
identify_context(str(user.pk))Every capture later in that request is then attributed to the user who just logged in. Requests made after login don't need this. The middleware sees the authenticated user from the start.
If you're using PostHog JavaScript Web (/docs/libraries/js.md) on the frontend, configure tracing_headers (/docs/libraries/js/config.md#tracing-headers) for your Django backend hostname so browser requests include the session and distinct ID headers.
Exception capture
By default, the middleware captures exceptions and sends them to PostHog's error tracking using the globally configured posthog client. This includes Django view exceptions that Django converts into error responses.
Disable this by setting:
Python
# settings.py
POSTHOG_MW_CAPTURE_EXCEPTIONS = FalseAdding custom tags
Use POSTHOG_MW_EXTRA_TAGS to add custom properties to all requests:
Python
# settings.py
def add_user_tags(request):
# type: (HttpRequest) -> Dict[str, Any]
tags = {}
if hasattr(request, 'user') and request.user.is_authenticated:
# Use pk instead of id so this works with custom User primary keys.
tags['user_id'] = str(request.user.pk)
tags['email'] = request.user.email
return tags
POSTHOG_MW_EXTRA_TAGS = add_user_tagsFiltering requests
Skip tracking for certain requests using POSTHOG_MW_REQUEST_FILTER:
Python
# settings.py
def should_track_request(request):
# type: (HttpRequest) -> bool
# Don't track health checks or admin requests
if request.path.startswith('/health') or request.path.startswith('/admin'):
return False
return True
POSTHOG_MW_REQUEST_FILTER = should_track_requestModifying default tags
Use POSTHOG_MW_TAG_MAP to modify or remove default tags:
Python
# settings.py
def customize_tags(tags):
# type: (Dict[str, Any]) -> Dict[str, Any]
# Remove URL for privacy
tags.pop('$current_url', None)
# Add custom prefix to method
if '$request_method' in tags:
tags['http_method'] = tags.pop('$request_method')
return tags
POSTHOG_MW_TAG_MAP = customize_tagsComplete configuration example
Python
# settings.py
def add_request_context(request):
# type: (HttpRequest) -> Dict[str, Any]
tags = {}
if hasattr(request, 'user') and request.user.is_authenticated:
tags['user_type'] = 'authenticated'
# Use pk instead of id so this works with custom User primary keys.
tags['user_id'] = str(request.user.pk)
else:
tags['user_type'] = 'anonymous'
# Add request info
tags['user_agent'] = request.META.get('HTTP_USER_AGENT', '')
return tags
def filter_tracking(request):
# type: (HttpRequest) -> bool
# Skip internal endpoints
return not request.path.startswith(('/health', '/metrics', '/admin'))
def clean_tags(tags):
# type: (Dict[str, Any]) -> Dict[str, Any]
# Remove sensitive data
tags.pop('user_agent', None)
return tags
POSTHOG_MW_EXTRA_TAGS = add_request_context
POSTHOG_MW_REQUEST_FILTER = filter_tracking
POSTHOG_MW_TAG_MAP = clean_tags
POSTHOG_MW_CAPTURE_EXCEPTIONS = TrueAll events captured within the request context automatically include the configured tags and are associated with the session and user identified from the request headers or Django authentication.
The middleware supports both sync (WSGI) and async (ASGI) Django applications. In async mode, it uses Django's request.auser() API when available to avoid synchronous user access.
Next steps
For any technical questions for how to integrate specific PostHog features into Django (such as analytics, feature flags, A/B testing, etc.), have a look at our Python SDK docs (/docs/libraries/python.md).
Alternatively, the following tutorials can help you get started:
- Setting up Django analytics, feature flags, and more (/tutorials/django-analytics.md)
- How to set up A/B tests in Django (/tutorials/django-ab-tests.md)
Supported versions
These docs cover version 7.x of the PostHog Python SDK, which requires Python 3.10 or higher. Python 3.9 is no longer supported on 7.x.x and higher — pin to the 6.x line with pip install 'posthog<7', where 6.9.3 is the final release.
Everything on this page works the same way on 6.9.3. Event capture, the context API (new_context, identify_context, set_context_session), and PosthogContextMiddleware are identical on 6.9.3 and 7.0.0 — 7.0.0 only dropped Python 3.9 and bumped the optional LLM provider SDKs. That includes the middleware identifying the request context from the X-POSTHOG-DISTINCT-ID header and falling back to the authenticated user, which behaves the same across both lines.
Later 7.x releases add what the 6.x line does not receive, such as the Celery integration, tracing header sanitization, and set_context_device_id. They also changed the middleware's own captured properties: 7.x sends the request IP as $ip, where 6.9.3 sends it as $ip_address, and 7.x additionally captures $request_path, $raw_user_agent, and the authenticated user's email.
Still have questions?
Ask PostHog AI
Was this page useful?
HelpfulCould be better
references/dotnet.md
AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt
.NET
This is an optional library you can install if you're working with .NET Core. It uses an internal queue to make calls fast and non-blocking. It also batches requests and flushes asynchronously, making it perfect to use in any part of your web app or other server side application that needs performance.
Installation
The PostHog package supports any .NET platform that targets .NET Standard 2.1 or .NET 8+, including MAUI, Blazor, and console applications. The PostHog.AspNetCore package provides additional conveniences for ASP.NET Core applications such as streamlined registration, request-scoped caching, and integration with .NET Feature Management.
Note: We actively test with ASP.NET Core. Other platforms should work but haven't been specifically tested. If you encounter issues, please report them on GitHub.
Not supported: Classic UWP (requires .NET Standard 2.0 only). Microsoft has deprecated UWP in favor of the Windows App SDK. For Unity projects, see our dedicated Unity SDK (/docs/libraries/unity.md).
Terminal
dotnet add package PostHog.AspNetCoreIn your Program.cs (or Startup.cs for ASP.NET Core 2.x) file, add the following code:
C#
using PostHog;
var builder = WebApplication.CreateBuilder(args);
// Add PostHog to the dependency injection container as a singleton.
builder.AddPostHog();Make sure to configure PostHog with your project token, instance address, and optional personal API key. For example, in appsettings.json:
JSON
{
"PostHog": {
"ProjectToken": "<ph_project_token>",
"HostUrl": "https://us.i.posthog.com"
}
}Note: If the host is not specified, the default host
https://us.i.posthog.comis used.
Use a secrets manager to store your personal API key. For example, when developing locally you can use the UserSecrets feature of the dotnet CLI:
Terminal
dotnet user-secrets init
dotnet user-secrets set "PostHog:PersonalApiKey" "phx_..."You can find your project token and instance address in the project settings page in PostHog.
Working with .NET Feature Management
PostHog.AspNetCore supports .NET Feature Management. This enables you to use the <feature /> tag helper and the FeatureGateAttribute in your ASP.NET Core applications to gate access to certain features using PostHog feature flags.
To use feature flags with the .NET Feature Management library, you'll need to implement the IPostHogFeatureFlagContextProvider interface. The quickest way to do that is to inherit from the PostHogFeatureFlagContextProvider class and override the GetDistinctId and GetFeatureFlagOptionsAsync methods.
C#
public class MyFeatureFlagContextProvider(IHttpContextAccessor httpContextAccessor)
: PostHogFeatureFlagContextProvider
{
protected override string? GetDistinctId()
=> httpContextAccessor.HttpContext?.User.Identity?.Name;
protected override ValueTask<FeatureFlagOptions> GetFeatureFlagOptionsAsync()
{
// In a real app, you might get this information from a
// database or other source for the current user.
return ValueTask.FromResult(
new FeatureFlagOptions
{
PersonProperties = new Dictionary<string, object?>
{
["email"] = "some-test@example.com"
},
OnlyEvaluateLocally = true
});
}
}Then, register your implementation in Program.cs (or Startup.cs):
C#
var builder = WebApplication.CreateBuilder(args);
builder.AddPostHog(options => {
options.UseFeatureManagement<MyFeatureFlagContextProvider>();
});With this in place, you can now use feature tag helpers in your Razor views:
HTML
<feature name="awesome-new-feature">
<p>This is the new feature!</p>
</feature>
<feature name="awesome-new-feature" negate="true">
<p>Sorry, no awesome new feature for you.</p>
</feature>Multivariate feature flags are also supported:
HTML
<feature name="awesome-new-feature" value="variant-a">
<p>This is the new feature variant A!</p>
</feature>
<feature name="awesome-new-feature" value="variant-b">
<p>This is the new feature variant B!</p>
</feature>You can also use the FeatureGateAttribute to gate access to controllers or actions:
C#
[FeatureGate("awesome-new-feature")]
public class NewFeatureController : Controller
{
public IActionResult Index()
{
return View();
}
}Using the core package without ASP.NET Core
If you're not using ASP.NET Core (for example, in a console application, MAUI app, or Blazor WebAssembly), install the PostHog package instead of PostHog.AspNetCore. This package has no ASP.NET Core dependencies and can be used in any .NET project targeting .NET Standard 2.1 or .NET 8+.
Terminal
dotnet add package PostHogThe PostHogClient class must be implemented as a singleton in your project. For PostHog.AspNetCore, this is handled by the builder.AddPostHog(); method. For the PostHog package, you can do the following if you're using dependency injection:
C#
builder.Services.AddPostHog();If you're not using a builder (such as in a console application), you can do the following:
C#
using PostHog;
var services = new ServiceCollection();
services.AddPostHog();
var serviceProvider = services.BuildServiceProvider();
var posthog = serviceProvider.GetRequiredService<IPostHogClient>();The AddPostHog methods accept an optional Action<PostHogOptions> parameter that you can use to configure the client.
If you're not using dependency injection, you can create a static instance of the PostHogClient class and use that everywhere in your project:
C#
using PostHog;
public static readonly PostHogClient PostHog = new(new PostHogOptions {
ProjectToken = "<ph_project_token>",
HostUrl = new Uri("https://us.i.posthog.com"),
PersonalApiKey = Environment.GetEnvironmentVariable(
"PostHog__PersonalApiKey")
});Debug mode
If you're not seeing the expected events being captured, the feature flags being evaluated, or the surveys being shown, you can enable debug mode to see what's happening.
To see detailed logging, set the log level to Debug or Trace in appsettings.json:
JSON
{
"DetailedErrors": true,
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"PostHog": "Trace"
}
},
...
}Identifying users
Identifying users is required. Backend events need a
distinct_idthat matches the ID your frontend uses when callingposthog.identify(). Without this, backend events are orphaned — they can't be linked to frontend event captures, session replays (/docs/session-replay.md), LLM traces (/docs/ai-engineering.md), or error tracking (/docs/error-tracking.md).See our guide on identifying users (/docs/getting-started/identify-users.md) for how to set this up.
Capturing events
You can send custom events using capture:
C#
posthog.Capture("distinct_id_of_the_user", "user_signed_up");Tip: We recommend using a
[object] [verb]format for your event names, where[object]is the entity that the behavior relates to, and[verb]is the behavior itself. For example,project created,user signed up, orinvite sent.
Setting event properties
Optionally, you can include additional information with the event by including a properties (/docs/data/events.md#event-properties) object:
C#
posthog.Capture(
"distinct_id_of_the_user",
"user_signed_up",
properties: new() {
["login_type"] = "email",
["is_free_trial"] = "true"
}
);Sending page views
If you're aiming for a backend-only implementation of PostHog and won't be capturing events from your frontend, you can send $pageview events from your backend like so:
C#
using PostHog;
using Microsoft.AspNetCore.Http.Extensions;
posthog.CapturePageView(
"distinct_id_of_the_user",
HttpContext.Request.GetDisplayUrl());Request context
For ASP.NET Core apps using PostHog.AspNetCore, add request context middleware before routes that call PostHog. This reads incoming PostHog tracing headers and attaches request metadata to captures, exceptions, and feature flag evaluation inside the request.
Program.cs
using PostHog;
using PostHog.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.AddPostHog();
var app = builder.Build();
app.UsePostHogRequestContext();If you're using PostHog JS (/docs/libraries/js.md) on the frontend, configure tracing_headers (/docs/libraries/js/config.md#tracing-headers) for your ASP.NET Core backend hostname so browser requests include the session and distinct ID headers.
The middleware reads X-PostHog-Distinct-Id and X-PostHog-Session-Id as request-scoped analytics context. It also adds request metadata such as $current_url, $request_method, $request_path, $user_agent, and $ip. Explicit distinct IDs and event properties always override request context.
Tracing headers are client-controlled analytics context, not authentication or authorization. For security-sensitive server-side decisions, pass an authenticated distinct ID explicitly. You can ignore tracing headers while still collecting request metadata:
C#
app.UsePostHogRequestContext(options =>
{
options.UseTracingHeaders = false;
});Request-context overloads like posthog.Capture("checkout started") and posthog.EvaluateFlagsAsync() use the current request distinct ID when one is available.
Error tracking
You can manually capture exceptions using CaptureException. This sends a $exception event with stack frames, inner exceptions, aggregate exceptions, source context when available, and .NET runtime metadata.
File names, line numbers, and source context depend on debug information already available from the captured .NET stack trace. PostHog doesn't support uploading .NET PDB files yet, so production builds without runtime-accessible debug information may show less detailed stack frames.
C#
try
{
ProcessOrder(orderId);
}
catch (Exception exception)
{
posthog.CaptureException(exception, "user_distinct_id");
}Add custom properties to include request, tenant, or domain context:
C#
posthog.CaptureException(
exception,
"user_distinct_id",
new Dictionary<string, object>
{
["order_id"] = orderId,
["environment"] = "production",
}
);For the full setup guide, see the .NET error tracking installation docs (/docs/error-tracking/installation/dotnet.md).
Automatic exception capture is not available in the .NET SDK yet.
Logs
PostHog Logs (/docs/logs.md) doesn't use this SDK. Logs are ingested over OpenTelemetry, so you attach an OTLP exporter to the standard ILogger pipeline instead — see the .NET logs installation guide (/docs/logs/installation/dotnet.md).
Person profiles and properties
The .NET SDK captures identified events by default. These create person profiles (/docs/data/persons.md). To set person properties (/docs/product-analytics/person-properties.md) in these profiles, include them when capturing an event:
C#
posthog.Capture(
"distinct_id",
"event_name",
personPropertiesToSet: new() { ["name"] = "Max Hedgehog" },
personPropertiesToSetOnce: new() { ["initial_url"] = "/blog" }
);For more details on the difference between $set and $set_once, see our person properties docs (/docs/product-analytics/person-properties.md#what-is-the-difference-between-set-and-set_once).
To capture anonymous events (/docs/data/anonymous-vs-identified-events.md) without person profiles, set the event's $process_person_profile property to false:
C#
posthog.Capture(
"distinct_id",
"event_name",
properties: new() {
["$process_person_profile"] = false
}
)Alias
Sometimes, you want to assign multiple distinct IDs to a single user. This is helpful when your primary distinct ID is inaccessible. For example, if a distinct ID used on the frontend is not available in your backend.
In this case, you can use alias to assign another distinct ID to the same user.
C#
await posthog.AliasAsync("current_distinct_id", "new_distinct_id");We strongly recommend reading our docs on alias (/docs/product-analytics/identify.md#alias-assigning-multiple-distinct-ids-to-the-same-user) to best understand how to correctly use this method.
Group analytics
Group analytics allows you to associate an event with a group (e.g. teams, organizations, etc.). Read the group analytics (/docs/product-analytics/group-analytics.md) guide for more information.
Note: This is a paid feature and is not available on the open-source or free cloud plan. Learn more on our pricing page (/pricing.md).
To capture an event and associate it with a group, add the groups argument to your Capture call:
C#
posthog.Capture(
"user_distinct_id",
"some_event",
groups: [new Group("company", "company_id_in_your_db")]);Update properties on a group, use the GroupIdentifyAsync method:
C#
await posthog.GroupIdentifyAsync(
type: "company",
key: "company_id_in_your_db",
name: "Awesome Inc.",
properties: new()
{
["employees"] = 11
}
);The name is a special property which is used in the PostHog UI for the name of the group. If you don't specify a name property, the group ID will be used instead.
Feature flags
PostHog's feature flags (/docs/feature-flags.md) enable you to safely deploy and roll back new features as well as target specific users and groups with them.
There are two steps to implement feature flags in .NET:
Step 1: Evaluate flags once
Call EvaluateFlagsAsync() once for the user, then read values from the returned snapshot.
Boolean feature flags
C#
var flags = await posthog.EvaluateFlagsAsync("distinct_id_of_your_user");
if (flags.IsEnabled("flag-key"))
{
// Do something differently for this user
// Optional: fetch the payload
var matchedPayload = flags.GetFlagPayload("flag-key");
}Multivariate feature flags
C#
var flags = await posthog.EvaluateFlagsAsync("distinct_id_of_your_user");
var enabledVariant = flags.GetFlag("flag-key")?.VariantKey;
if (enabledVariant == "variant-key") // replace "variant-key" with the key of your variant
{
// Do something differently for this user
// Optional: fetch the payload
var matchedPayload = flags.GetFlagPayload("flag-key");
}flags.GetFlag() returns a nullable FeatureFlag object. Check VariantKey for multivariate flags and IsEnabled for boolean flags. It returns null when the flag wasn't returned by the evaluation.
Note:
posthog.IsFeatureEnabledAsync(),posthog.GetFeatureFlagAsync(), andCapture(..., sendFeatureFlags: true, ...)still work during the migration period, but they're deprecated. PreferEvaluateFlagsAsync()for new code.
Step 2: Include feature flag information when capturing events
If you want use your feature flag to breakdown or filter events in your insights (/docs/product-analytics/insights.md), you'll need to include feature flag information in those events. This ensures that the feature flag value is attributed correctly to the event.
Note: This step is only required for events captured using our server-side SDKs or API (/docs/api.md).
There are two methods you can use to include feature flag information in your events:
Method 1: Pass the evaluated flags snapshot to Capture()
Pass the same flags object that you used for branching. This attaches the exact flag values from that evaluation and doesn't make another /flags request.
C#
var flags = await posthog.EvaluateFlagsAsync("distinct_id_of_your_user");
if (flags.IsEnabled("flag-key"))
{
// Do something differently for this user
}
posthog.Capture(
"distinct_id_of_your_user",
"event_name",
properties: null,
groups: null,
flags: flags
);By default, this attaches every flag in the snapshot using $feature/<flag-key> properties and $active_feature_flags.
To reduce event property bloat, pass a filtered snapshot:
C#
// Attach only flags accessed with IsEnabled() or GetFlag() before this call
posthog.Capture(
"distinct_id_of_your_user",
"event_name",
properties: null,
groups: null,
flags: flags.OnlyAccessed()
);
// Attach only specific flags
posthog.Capture(
"distinct_id_of_your_user",
"event_name",
properties: null,
groups: null,
flags: flags.Only("checkout-flow", "new-dashboard")
);Method 2: Include the $feature/feature_flag_name property manually
In the event properties, include $feature/feature_flag_name: variant_key:
C#
posthog.Capture(
"distinct_id_of_your_user",
"event_name",
properties: new()
{
// Replace feature-flag-key with your flag key and "variant-key" with the key of your variant
["$feature/feature-flag-key"] = "variant-key",
}
);Evaluating only specific flags
By default, EvaluateFlagsAsync() evaluates every flag for the user. If you only need a few flags, pass FlagKeysToEvaluate to request only those flags:
C#
var flags = await posthog.EvaluateFlagsAsync(
"distinct_id_of_your_user",
options: new AllFeatureFlagsOptions
{
FlagKeysToEvaluate = new[] { "checkout-flow", "new-dashboard" },
}
);Sending $feature_flag_called events
Capturing $feature_flag_called events enables PostHog to know when a flag was accessed by a user and provide analytics and insights (/docs/product-analytics/insights.md) on the flag. With EvaluateFlagsAsync(), the SDK sends this event when you call flags.IsEnabled() or flags.GetFlag() for a flag.
The SDK deduplicates these events per (distinct_id, flag, value) in a local cache. If you reinitialize the PostHog client, the cache resets and $feature_flag_called events may be sent again. PostHog handles duplicates, so duplicate $feature_flag_called events don't affect your analytics.
flags.GetFlagPayload() doesn't send $feature_flag_called events and doesn't count as an access for OnlyAccessed().
Advanced: Overriding server properties
Sometimes, you may want to evaluate feature flags using person properties (/docs/product-analytics/person-properties.md), groups (/docs/product-analytics/group-analytics.md), or group properties that haven't been ingested yet, or were set incorrectly earlier.
You can provide properties to evaluate the flag with by using the person properties, groups, and group properties arguments. PostHog will then use these values to evaluate the flag, instead of any properties currently stored on your PostHog server.
For example:
C#
var flags = await posthog.EvaluateFlagsAsync(
"distinct_id_of_the_user",
options: new AllFeatureFlagsOptions
{
PersonProperties = new()
{
["property_name"] = "value",
},
Groups = new()
{
new Group("your_group_type", "your_group_id")
{
["group_property_name"] = "value",
},
new Group("another_group_type", "another_group_id")
{
["group_property_name"] = "another value",
},
},
}
);
if (flags.IsEnabled("flag-key"))
{
// Do something differently for this user
}Overriding GeoIP properties
By default, a user's GeoIP properties are set using the IP address they use to capture events on the frontend. You may want to override the these properties when evaluating feature flags. A common reason to do this is when you're not using PostHog on your frontend, so the user has no GeoIP properties.
You can override GeoIP properties by including them in the person_properties parameter when evaluating feature flags. This is useful when you're evaluating flags on your backend and want to use the client's location instead of your server's location.
The following GeoIP properties can be overridden:
$geoip_country_code$geoip_country_name$geoip_city_name$geoip_city_confidence$geoip_continent_code$geoip_continent_name$geoip_latitude$geoip_longitude$geoip_postal_code$geoip_subdivision_1_code$geoip_subdivision_1_name$geoip_subdivision_2_code$geoip_subdivision_2_name$geoip_subdivision_3_code$geoip_subdivision_3_name$geoip_time_zone
Simply include any of these properties in the person_properties parameter alongside your other person properties when calling feature flags.
Evaluation contexts
Configure evaluation contexts so this SDK only evaluates flags intended for the matching application, platform, or product area. For ASP.NET Core apps using PostHog.AspNetCore, add them to the PostHog configuration section:
JSON
{
"PostHog": {
"ProjectToken": "<ph_project_token>",
"HostUrl": "https://us.i.posthog.com",
"EvaluationContexts": ["main-app", "api", "backend"]
}
}For code-based configuration, set EvaluationContexts on PostHogOptions:
C#
var posthog = new PostHogClient(new PostHogOptions
{
ProjectToken = "<ph_project_token>",
HostUrl = new Uri("https://us.i.posthog.com"),
EvaluationContexts = ["main-app", "api", "backend"],
});Remote /flags requests from EvaluateFlagsAsync() include evaluation_contexts when configured.
For more details, see the evaluation contexts guide (/docs/feature-flags/evaluation-contexts.md).
Local evaluation
Evaluating feature flags requires making a request to PostHog for each flag. However, you can improve performance by evaluating flags locally. Instead of making a request for each flag, PostHog will periodically request and store feature flag definitions locally, enabling you to evaluate flags without making additional requests.
It is best practice to use local evaluation flags when possible, since this enables you to resolve flags faster and with fewer API calls.
For details on how to implement local evaluation, see our local evaluation guide (/docs/feature-flags/local-evaluation.md).
Experiments (A/B tests)
Since experiments (/docs/experiments/start-here.md) use feature flags, the code for running an experiment is very similar to the feature flags code:
C#
var flags = await posthog.EvaluateFlagsAsync("user_distinct_id");
var variant = flags.GetFlag("experiment-feature-flag-key")?.VariantKey;
if (variant == "variant-name")
{
// Do something
}It's also possible to run experiments without using feature flags (/docs/experiments/running-experiments-without-feature-flags.md).
AI observability
PostHog.AI adds AI observability (/docs/ai-observability.md) for .NET applications using OpenAI or Azure OpenAI. It is currently pre-release, so expect breaking changes before a stable release.
For installation instructions, see the OpenAI guide for .NET (/docs/ai-observability/installation/openai.md#net-support) or the Azure OpenAI guide for .NET (/docs/ai-observability/installation/azure-openai.md#net-support).
GeoIP properties
The posthog-dotnet library disregards the server IP, does not add the GeoIP properties, and does not use the values for feature flag evaluations.
Serverless environments (Azure Functions/Render/Lambda/...)
By default, the library buffers events before sending them to the /batch endpoint for better performance. This can lead to lost events in serverless environments if the .NET process is terminated by the platform before the buffer is fully flushed.
To avoid this, call await posthog.FlushAsync() after processing every request by adding it as a middleware to your server. This allows posthog.Capture() to remain asynchronous for better performance.
Still have questions?
Ask PostHog AI
Was this page useful?
HelpfulCould be better
references/elixir.md
AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt
Elixir
This library provides an Elixir HTTP client for PostHog. See the repository for more information.
Installation
This library was built by the community but it's being maintained by the PostHog core team since v1.0.0. Thank you to Nick Kezhaya for building it originally. Thank you to Alex Martsinovich for contributing v2.0.0.
The package can be installed by adding posthog to your list of dependencies in mix.exs:
Elixir
def deps do
[
{:posthog, "~> 2.0"}
]
endConfiguration
config/config.exs
config :posthog,
enable: true,
api_host: "https://us.i.posthog.com",
api_key: "<ph_project_token>",
in_app_otp_apps: [:my_app]You can see all the available configuration options in the PostHog.Config module.
Optionally, you might want to enable the Plug integration to attach request metadata and tracing context in Plug-based applications including Phoenix. You still need to capture events explicitly with PostHog.capture/2 or PostHog.capture/3.
Development/Test mode
For a test environment, you can pass in test_mode: true value to the config. This causes events to be dropped instead of sent to PostHog.
Identifying users
Identifying users is required. Backend events need a
distinct_idthat matches the ID your frontend uses when callingposthog.identify(). Without this, backend events are orphaned — they can't be linked to frontend event captures, session replays (/docs/session-replay.md), LLM traces (/docs/ai-engineering.md), or error tracking (/docs/error-tracking.md).See our guide on identifying users (/docs/getting-started/identify-users.md) for how to set this up.
Capturing events
To capture an event, use PostHog.capture/2:
Elixir
PostHog.capture("user_signed_up", %{distinct_id: "distinct_id_of_the_user"})Tip: We recommend using a
[object] [verb]format for your event names, where[object]is the entity that the behavior relates to, and[verb]is the behavior itself. For example,project created,user signed up, orinvite sent.
Setting event properties
Optionally, you can include additional information with the event by including a properties (/docs/data/events.md#event-properties) object:
Elixir
PostHog.capture("user_signed_up", %{
distinct_id: "distinct_id_of_the_user",
login_type: "email",
is_free_trial: true
})Context
Carrying distinct_id around all the time might not be the most convenient approach, so PostHog lets you store it and other properties in a context.
The context is stored in the Logger metadata and PostHog automatically attaches these properties to any events you capture with PostHog.capture/2, as long as they happen in the same process.
Elixir
PostHog.set_context(%{distinct_id: "distinct_id_of_the_user"})
PostHog.capture("page_opened")You can also scope the context to a specific event name:
Elixir
PostHog.set_event_context("sensitive_event", %{"$process_person_profile": false})Batching events
Events are automatically batched and sent to PostHog via a background job.
Special events
PostHog.capture/2 is very powerful and enables you to send events that have special meaning.
In other libraries you'll usually find helpers for these special events, but they must be explicitly sent in Elixir.
For example:
Create alias
Elixir
PostHog.capture("$create_alias", %{distinct_id: "frontend_id", alias: "backend_id"})Group analytics
Elixir
PostHog.capture("$groupidentify", %{
distinct_id: "static_string_used_for_all_group_events",
"$group_type": "company",
"$group_key": "company_id_in_your_db"
})Request context
For Phoenix or Plug apps, add PostHog.Integrations.Plug before your router to attach request metadata and PostHog tracing headers to events captured during the request.
lib/my_app_web/endpoint.ex
plug PostHog.Integrations.Plug
plug MyAppWeb.RouterFor plain Plug routers, add it before :match and :dispatch:
Elixir
defmodule MyRouter do
use Plug.Router
plug PostHog.Integrations.Plug
plug :match
plug :dispatch
# ... routes
endThe plug adds request metadata such as $current_url, $host, $pathname, $request_method, $user_agent, and $ip. It also reads X-PostHog-Distinct-Id and X-PostHog-Session-Id as analytics context so backend events and errors can be linked to frontend users and sessions.
If you're using PostHog JS (/docs/libraries/js.md) on the frontend, configure tracing_headers (/docs/libraries/js/config.md#tracing-headers) for your Phoenix or Plug backend hostname so browser requests include these headers.
Tracing headers are client-controlled analytics context, not authentication or authorization. Pass an authenticated distinct_id explicitly for security-sensitive server-side decisions.
Feature flags
PostHog's feature flags (/docs/feature-flags.md) enable you to safely deploy and roll back new features as well as target specific users and groups with them.
There are two steps to implement feature flags in Elixir:
Step 1: Evaluate flags once
Call PostHog.FeatureFlags.evaluate_flags/1 once for the user, then read values from the returned snapshot.
Boolean feature flags
Elixir
{:ok, snapshot} = PostHog.FeatureFlags.evaluate_flags("distinct_id_of_your_user")
if PostHog.FeatureFlags.Evaluations.enabled?(snapshot, "flag-key") do
# Do something differently for this user
# Optional: fetch the payload
payload = PostHog.FeatureFlags.Evaluations.get_flag_payload(snapshot, "flag-key")
endMultivariate feature flags
Elixir
{:ok, snapshot} = PostHog.FeatureFlags.evaluate_flags("distinct_id_of_your_user")
enabled_variant = PostHog.FeatureFlags.Evaluations.get_flag(snapshot, "flag-key")
if enabled_variant == "variant-key" do
# Do something differently for this user
# Optional: fetch the payload
payload = PostHog.FeatureFlags.Evaluations.get_flag_payload(snapshot, "flag-key")
endPostHog.FeatureFlags.Evaluations.get_flag/2 returns the variant string for multivariate flags, true for enabled boolean flags, false for disabled flags, and nil when the flag wasn't returned by the evaluation.
Note:
PostHog.FeatureFlags.check/2,PostHog.FeatureFlags.check!/2,PostHog.FeatureFlags.get_feature_flag_result/2, andPostHog.FeatureFlags.get_feature_flag_result!/2still work during the migration period, but they're deprecated. Preferevaluate_flags/1for new code.
Step 2: Include feature flag information when capturing events
If you want use your feature flag to breakdown or filter events in your insights (/docs/product-analytics/insights.md), you'll need to include feature flag information in those events. This ensures that the feature flag value is attributed correctly to the event.
Note: This step is only required for events captured using our server-side SDKs or API (/docs/api.md).
There are two methods you can use to include feature flag information in your events:
Method 1: Put the evaluated flags snapshot in context
Put the same snapshot object that you used for branching into context. Subsequent captures from the same process attach the exact flag values from that evaluation and don't make another /flags request.
Elixir
{:ok, snapshot} = PostHog.FeatureFlags.evaluate_flags("distinct_id_of_your_user")
if PostHog.FeatureFlags.Evaluations.enabled?(snapshot, "flag-key") do
# Do something differently for this user
end
PostHog.FeatureFlags.set_in_context(snapshot)
PostHog.capture("event_name", %{distinct_id: "distinct_id_of_your_user"})By default, this attaches every flag in the snapshot using $feature/<flag-key> properties and $active_feature_flags.
To reduce event property bloat, put a filtered snapshot in context:
Elixir
{:ok, snapshot} = PostHog.FeatureFlags.evaluate_flags("distinct_id_of_your_user")
# Attach only flags accessed with enabled?/2 or get_flag/2 before this call
PostHog.FeatureFlags.Evaluations.enabled?(snapshot, "flag-key")
PostHog.FeatureFlags.set_in_context(
PostHog.FeatureFlags.Evaluations.only_accessed(snapshot)
)
# Or attach only specific flags
PostHog.FeatureFlags.set_in_context(
PostHog.FeatureFlags.Evaluations.only(snapshot, ["checkout-flow", "new-dashboard"])
)only_accessed/1 is order-dependent. If you call it before accessing any flags with enabled?/2 or get_flag/2, no feature flag properties are attached.
Method 2: Include the $feature/feature_flag_name property manually
In the event properties, include $feature/feature_flag_name: variant_key:
Elixir
PostHog.capture("event_name", %{
"$feature/feature-flag-key" => "variant-key",
distinct_id: "distinct_id_of_your_user"
})Evaluating only specific flags
By default, evaluate_flags/1 evaluates every flag for the user. If you only need a few flags, pass flag_keys to request only those flags:
Elixir
{:ok, snapshot} =
PostHog.FeatureFlags.evaluate_flags(%{
distinct_id: "distinct_id_of_your_user",
flag_keys: ["checkout-flow", "new-dashboard"]
})Sending $feature_flag_called events
Capturing $feature_flag_called events enables PostHog to know when a flag was accessed by a user and provide analytics and insights (/docs/product-analytics/insights.md) on the flag. With evaluate_flags/1, the SDK sends this event when you call PostHog.FeatureFlags.Evaluations.enabled?/2 or PostHog.FeatureFlags.Evaluations.get_flag/2 for a flag.
PostHog.FeatureFlags.Evaluations.get_flag_payload/2 doesn't send $feature_flag_called events.
Local feature flag evaluation
Local evaluation is available in version 2.15.0 and later. Follow the server-side local evaluation guide (/docs/feature-flags/local-evaluation.md) to find your secure key and see how to pass the person properties, groups, and group properties that your flag conditions require.
Store the secure key in a server-side environment variable. Don't expose it to client-side applications:
config/runtime.exs
config :posthog,
api_host: "https://us.i.posthog.com",
api_key: "<ph_project_token>",
secret_key: System.fetch_env!("POSTHOG_FEATURE_FLAGS_SECURE_API_KEY")When secret_key is set, the SDK fetches definitions when it starts and polls for updates every 30 seconds. PostHog.FeatureFlags.evaluate_flags/1 evaluates each flag locally first. If a flag can't be evaluated locally, the SDK makes one /flags request to resolve the remaining flags. Set only_evaluate_locally: true in the evaluation map to prevent this remote fallback. The SDK then omits unresolved flags from the snapshot.
Use these configuration options to control local evaluation:
| Option | Default | Purpose |
|---|---|---|
enable_local_evaluation |
true |
Starts local evaluation when secret_key is set. |
feature_flags_poll_interval_ms |
30_000 |
Sets the interval between definition refreshes. |
flag_definition_request_timeout_ms |
10_000 |
Sets the timeout for each definition request. |
flag_definition_cache_provider_timeout_ms |
5_000 |
Sets the timeout for each shared cache provider callback. |
For multiple server instances, you can implement PostHog.FeatureFlags.FlagDefinitionCacheProvider and set flag_definition_cache_provider: {module, state}. This optional provider shares definitions and coordinates which instance polls PostHog. See local evaluation in distributed environments (/docs/feature-flags/local-evaluation/distributed-environments?tab=Elixir.md) for the callback contract and configuration.
Error tracking
Error tracking is enabled by default. It will automatically captures exceptions thrown by the application.
As a matter of fact, since this is built on top of Elixir's Logger module, it automatically captures any Logger.error calls.
You can always disable it by setting enable_error_tracking to false:
Elixir
config :posthog,
enable_error_tracking: falseAdvanced configuration
By default, PostHog starts its own supervision tree and attaches a logger handler.
In certain cases, you might want to run this supervision tree yourself. You can do this by disabling the default supervisor and adding PostHog.Supervisor to your application tree with its own configuration:
config.exs
config :posthog, enable: false
config :my_app, :posthog,
api_host: "https://us.i.posthog.com",
api_key: "<ph_project_token>"application.ex
defmodule MyApp.Application do
use Application
def start(_type, _args) do
posthog_config = Application.fetch_env!(:my_app, :posthog) |> PostHog.Config.validate!()
:logger.add_handler(:posthog, PostHog.Handler, %{config: posthog_config})
children = [
{PostHog.Supervisor, posthog_config}
]
Supervisor.start_link(children, strategy: :one_for_one)
end
endMultiple instances
In even more advanced cases, you might want to interact with more than one PostHog project. In this case, you can run multiple PostHog supervision trees, one of which can be the default one:
config.exs
config :posthog,
api_host: "https://us.i.posthog.com",
api_key: "<ph_project_token>"
config :my_app, :another_posthog,
api_host: "https://us.i.posthog.com",
api_key: "a_different_project_api_key",
supervisor_name: AnotherPostHogapplication.ex
defmodule MyApp.Application do
use Application
def start(_type, _args) do
posthog_config = Application.fetch_env!(:my_app, :another_posthog) |> PostHog.Config.validate!()
children = [
{PostHog.Supervisor, posthog_config}
]
Supervisor.start_link(children, strategy: :one_for_one)
end
endThen, each function in the PostHog module accepts an optional first argument with the name of the PostHog supervisor tree that will process the capture:
Elixir
PostHog.capture(AnotherPostHog, "user_signed_up", %{distinct_id: "user123"})Thanks
The library is maintained by the PostHog team since February 2025. Thanks to nkezhaya for contributing v0.1.0. Thanks to martosaur for contributing v2.0.0.
Still have questions?
Ask PostHog AI
Was this page useful?
HelpfulCould be better
references/flask.md
AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt
Flask
PostHog makes it easy to get data about traffic and usage of your Flask app. Integrating PostHog enables analytics, custom events capture, feature flags, error tracking, and more.
This guide walks you through integrating PostHog into your Flask app using the Python SDK (/docs/libraries/python.md).
These docs cover version
7.xof the Python SDK, which requires Python 3.10 or higher. On Python 3.9? See supported versions (#supported-versions).
Installation
To start, run pip install posthog to install PostHog’s Python SDK.
Then, initialize PostHog where you'd like to use it. For example, here's how to capture an event in a simple route:
app.py
from flask import Flask
from posthog import Posthog
app = Flask(__name__)
posthog = Posthog(
'<ph_project_token>',
host='https://us.i.posthog.com',
)
@app.route('/api/dashboard', methods=['POST'])
def api_dashboard():
posthog.capture(
'dashboard_api_called',
distinct_id='distinct_id_of_your_user',
)
return '', 204You can find your project token and instance address in your project settings.
Identifying users
Identifying users is required. Backend events need a
distinct_idto associate events with the correct user.In Python, you can do this through a context. All event captures in the same context will be tagged automatically with the correct
distinct_id. Typically, you would set a fresh context and identify at the top of each route.Python
from posthog import new_context, identify_context, capture @app.get("/foo") def foo(current_user: User = Depends(get_current_user)): with new_context(): # Set context at the top of a route identify_context(current_user.id) capture("foo_viewed") return {"status": "ok"}When possible, write a small piece of middleware that resolves your authenticated user, wrap a context around the request, and identifies it. Every
capture()downstream is then attributed automatically. The SDK's Django middleware does this automatically and you can replicate it when using the plain Python SDK.
Request contexts
Use contexts (/docs/libraries/python.md#contexts) to share identity, session IDs, and tags across multiple captures during a request.
If you're using PostHog JavaScript Web (/docs/libraries/js.md) on the frontend, configure tracing_headers (/docs/libraries/js/config.md#tracing-headers) for your Flask backend hostname so browser requests include the session and distinct ID headers.
Then read the incoming headers in your Flask request handler. Tracing headers are client-controlled analytics context, not authentication or authorization, so prefer your authenticated user ID when one is available:
Python
from flask import request, session
from posthog import identify_context, set_context_session, tag
@app.route('/api/dashboard', methods=['POST'])
def api_dashboard():
with posthog.new_context(fresh=True):
distinct_id = session.get('user_id') or request.headers.get('X-POSTHOG-DISTINCT-ID')
if distinct_id:
identify_context(str(distinct_id))
session_id = request.headers.get('X-POSTHOG-SESSION-ID')
if session_id:
set_context_session(session_id)
tag('$current_url', request.url)
tag('$request_method', request.method)
tag('$request_path', request.path)
posthog.capture('dashboard_api_called')
return '', 204Events captured without a context or explicit distinct_id are sent as anonymous events (/docs/data/anonymous-vs-identified-events.md) with an auto-generated distinct_id. See the Python SDK docs (/docs/libraries/python.md#person-profiles-and-properties) for more details.
Error tracking
Flask has built-in error handlers. This means PostHog’s default exception autocapture won’t work and we need to manually capture errors instead using capture_exception():
Python
from flask import Flask, jsonify
from posthog import Posthog
app = Flask(__name__)
posthog = Posthog('<ph_project_token>', host='https://us.i.posthog.com')
@app.errorhandler(Exception)
def handle_exception(e):
# Capture methods, including capture_exception, return the UUID of the captured event,
# which you can use to find specific errors users encountered
event_id = posthog.capture_exception(e)
# You can show the event ID to your user, and ask them to include it in bug reports
response = jsonify({'message': str(e), 'error_id': event_id})
response.status_code = 500
return responseNext steps
For any technical questions for how to integrate specific PostHog features into Flask (such as analytics, feature flags, A/B testing, etc.), have a look at our Python SDK docs (/docs/libraries/python.md).
Alternatively, the following tutorials can help you get started:
- How to set up analytics in Python and Flask (/tutorials/python-analytics.md)
- How to set up feature flags in Python and Flask (/tutorials/python-feature-flags.md)
- How to set up A/B tests in Python and Flask (/tutorials/python-ab-testing.md)
Supported versions
These docs cover version 7.x of the PostHog Python SDK, which requires Python 3.10 or higher. Python 3.9 is no longer supported on 7.x.x and higher — pin to the 6.x line with pip install 'posthog<7', where 6.9.3 is the final release.
Everything on this page works the same way on 6.9.3. Event capture, the context API (new_context, identify_context, set_context_session), and PosthogContextMiddleware are identical on 6.9.3 and 7.0.0 — 7.0.0 only dropped Python 3.9 and bumped the optional LLM provider SDKs. That includes the middleware identifying the request context from the X-POSTHOG-DISTINCT-ID header and falling back to the authenticated user, which behaves the same across both lines.
Later 7.x releases add what the 6.x line does not receive, such as the Celery integration, tracing header sanitization, and set_context_device_id. They also changed the middleware's own captured properties: 7.x sends the request IP as $ip, where 6.9.3 sends it as $ip_address, and 7.x additionally captures $request_path, $raw_user_agent, and the authenticated user's email.
Still have questions?
Ask PostHog AI
Was this page useful?
HelpfulCould be better
references/flutter.md
AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt
Flutter
This is an optional library you can install if you're working with Flutter. It uses an internal queue to make calls fast and non-blocking. It also batches requests and flushes asynchronously, making it perfect to use in any part of your mobile app.
PostHog supports the iOS, macOS, Android, and Web platforms.
Installation
PostHog is available for install via Pub.
Configuration
Set your PostHog project token and enable automatic event tracking if you want the library to capture lifecycle events for you.
Remember that the application lifecycle events won't have any special context set for you by the time it is initialized. If you are using a self-hosted instance of PostHog you will need to have the public hostname or IP for your instance as well.
To start, add posthog_flutter to your pubspec.yaml:
pubspec.yaml
# rest of your code
dependencies:
flutter:
sdk: flutter
posthog_flutter: ^5.26.0
# rest of your codeThen complete the setup for each platform:
For Session Replay and Surveys, you must set up the SDK manually by disabling the
com.posthog.posthog.AUTO_INITmode.
Android setup
There are 2 ways of initializing the SDK, automatically and manually.
Automatically:
Add your PostHog configuration to your AndroidManifest.xml file located in the android/app/src/main:
android/app/src/main/AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="your.package.name">
<application>
<!-- ... other configuration ... -->
<meta-data android:name="com.posthog.posthog.PROJECT_TOKEN" android:value="<ph_project_token>" />
<meta-data android:name="com.posthog.posthog.POSTHOG_HOST" android:value="https://us.i.posthog.com" /> <!-- usually 'https://us.i.posthog.com' or 'https://eu.i.posthog.com' -->
<!-- com.posthog.posthog.CAPTURE_APPLICATION_LIFECYCLE_EVENTS is enabled by default since version 5.23.0 (previously named TRACK_APPLICATION_LIFECYCLE_EVENTS, which still works as an alias) -->
<meta-data android:name="com.posthog.posthog.DEBUG" android:value="true" />
</application>
</manifest>Or manually (more control and more configurations available):
Add your PostHog configuration to your AndroidManifest.xml file located in the android/app/src/main:
android/app/src/main/AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="your.package.name">
<application>
<!-- ... other configuration ... -->
<meta-data android:name="com.posthog.posthog.AUTO_INIT" android:value="false" />
</application>
</manifest>In both cases, you'll also need to update the minimum Android SDK version to 23 in android/app/build.gradle:
android/app/build.gradle
// rest of your config
defaultConfig {
minSdkVersion 23
// rest of your config
}
// rest of your configiOS setup
There are 2 ways of initializing the SDK, automatically and manually.
The SDK supports both CocoaPods and Swift Package Manager (SPM). Flutter 3.44 and later enable SPM by default. On earlier versions, or if you disabled SPM, enable it with flutter config --enable-swift-package-manager.
Automatically:
Add your PostHog configuration to the Info.plist file located in the ios/Runner directory:
ios/Runner/Info.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- rest of your configuration -->
<key>com.posthog.posthog.PROJECT_TOKEN</key>
<string><ph_project_token></string>
<key>com.posthog.posthog.POSTHOG_HOST</key>
<string>https://us.i.posthog.com</string>
<!-- com.posthog.posthog.CAPTURE_APPLICATION_LIFECYCLE_EVENTS is enabled by default since version 5.23.0 -->
<key>com.posthog.posthog.DEBUG</key>
<true/>
</dict>
</plist>Or manually (more control and more configurations available):
Add your PostHog configuration to the Info.plist file located in the ios/Runner directory:
ios/Runner/Info.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- rest of your configuration -->
<key>com.posthog.posthog.AUTO_INIT</key>
<false/>
</dict>
</plist>In both cases, you'll need to set the minimum platform version to iOS 13.0.
For CocoaPods projects, set it in your Podfile:
ios/Podfile
platform :ios, '13.0'
# rest of your configFor Swift Package Manager projects without a Podfile, set the Minimum Deployments version to iOS 13.0 for the Runner target in Xcode (Runner > General > Minimum Deployments). After you change Minimum Deployments, regenerate the iOS project's configuration files:
Terminal
flutter build ios --config-onlyDart setup (For manual step only)
If you followed the automatic SDK setup, then there's no more configuration needed in Dart.
If you followed the manual SDK setup:
Dart
import 'package:flutter/material.dart';
import 'package:posthog_flutter/posthog_flutter.dart';
Future<void> main() async {
// init WidgetsFlutterBinding if not yet
WidgetsFlutterBinding.ensureInitialized();
final config = PostHogConfig('<ph_project_token>');
config.debug = true;
// captureApplicationLifecycleEvents is enabled by default since version 5.23.0
config.host = 'https://us.i.posthog.com';
await Posthog().setup(config);
runApp(MyApp());
}Web setup
If your project has a web/ directory, this step is required. Posthog().setup() is a no-op on web, so a web build without the snippet below captures nothing.
Add your Web snippet (which you can find in your project settings) in the <header> of your web/index.html file. Write your project token into the snippet as a literal string. It's public, the same token ships to every visitor, and it needs no build-time or deploy-time injection:
web/index.html
<!DOCTYPE html>
<html>
<head>
<!-- ... other head elements ... -->
<script async>
!(function (t, e) {
var o, n, p, r;
e.__SV ||
((window.posthog = e),
(e._i = []),
(e.init = function (i, s, a) {
function g(t, e) {
var o = e.split(".");
(2 == o.length && ((t = t[o[0]]), (e = o[1])),
(t[e] = function () {
t.push([e].concat(Array.prototype.slice.call(arguments, 0)));
}));
}
(((p = t.createElement("script")).type = "text/javascript"),
(p.crossOrigin = "anonymous"),
(p.async = !0),
(p.src = s.api_host + "/static/array.js"),
(r = t.getElementsByTagName("script")[0]).parentNode.insertBefore(p, r));
var u = e;
for (
void 0 !== a ? (u = e[a] = []) : (a = "posthog"),
u.people = u.people || [],
Object.defineProperty(u, "toString", {
configurable: !0,
enumerable: !0,
writable: !0,
value: function (t) {
var e = "posthog";
return ("posthog" !== a && (e += "." + a), t || (e += " (stub)"), e);
},
}),
Object.defineProperty(u.people, "toString", {
configurable: !0,
enumerable: !0,
writable: !0,
value: function () {
return u.toString(1) + ".people (stub)";
},
}),
o =
"capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagResult reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys getNextSurveyStep onSessionId".split(
" ",
),
n = 0;
n < o.length;
n++
)
g(u, o[n]);
e._i.push([i, s, a]);
}),
(e.__SV = 1));
})(document, window.posthog || []);
posthog.init("<ph_project_token>", {
api_host: "https://us.i.posthog.com", // 'https://us.i.posthog.com' or 'https://eu.i.posthog.com'
defaults: "2026-05-30",
});
</script>
</head>
<!-- other elements -->
</html>For more information please check: /docs/libraries/js
Capturing events
You can send custom events using capture:
Dart
await Posthog().capture(
eventName: 'user_signed_up',
);Tip: We recommend using a
[object] [verb]format for your event names, where[object]is the entity that the behavior relates to, and[verb]is the behavior itself. For example,project created,user signed up, orinvite sent.
Setting event properties
Optionally, you can include additional information with the event by including a properties (/docs/data/events.md#event-properties) object:
Dart
await Posthog().capture(
eventName: 'user_signed_up',
properties: {
'login_type': 'email',
'is_free_trial': true
}
);Autocapture
PostHog autocapture automatically tracks the following events for you:
- Application Opened - when the app is opened from a closed state or when the app comes to the foreground (e.g. from the app switcher)
- Application Backgrounded - when the app is sent to the background by the user
- Application Installed - when the app is installed.
- Application Updated - when the app is updated.
- $screen - when the user navigates, once you add the
PosthogObserver - $exception - when the app throws exceptions.
Capturing screen views
Screen views aren't captured automatically. Add the PosthogObserver to your app yourself. Without it, your app sends no $screen events at all.
This works with any routing package, not just the plain Navigator API. Add the observer wherever your router takes navigator observers, as shown below for MaterialApp and go_router.
Note: Screen names come from each route's
RouteSettings.name. Most routing packages set this for you. If yours doesn't, name your routes so$screenevents are readable.
Using navigatorObservers
Add the PosthogObserver to record screen views automatically:
Dart
import 'package:flutter/material.dart';
import 'package:posthog_flutter/posthog_flutter.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
// If you're using session replay, `PostHogWidget` has to be the root, and `MaterialApp` must be the child.
return MaterialApp(
navigatorObservers: [
// The PosthogObserver records screen views automatically
PosthogObserver(),
],
...
);
}
}Name your routes:
Dart
...
MaterialPageRoute(builder: (context) => const HomeScreenRoute(),
settings: const RouteSettings(name: 'Home Screen'),
),
...Using go_router
Add the PosthogObserver to record screen views automatically:
Dart
import 'package:flutter/material.dart';
import 'package:posthog_flutter/posthog_flutter.dart';
import 'package:go_router/go_router.dart';
// GoRouter configuration
final _router = GoRouter(
routes: [
...
],
// The PosthogObserver records screen views automatically
observers: [PosthogObserver()],
);
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
// If you're using session replay, `PostHogWidget` has to be the root, and `MaterialApp` must be the child.
return MaterialApp.router(
routerConfig: _router,
);
}
}Name your routes:
Dart
...
GoRoute(
name: 'Home Screen',
...
),
...Identifying users
We highly recommend reading our section on Identifying users (/docs/integrate/identifying-users.md) to better understand how to correctly use this method.
Using identify, you can associate events with specific users. This enables you to gain full insights as to how they're using your product across different sessions, devices, and platforms.
An identify call has the following arguments:
- userId: Required. A unique identifier for your user. Typically either their email or database ID.
- userProperties: Optional. A dictionary with key:value pairs to set the person properties (/docs/product-analytics/person-properties.md)
- userPropertiesSetOnce: Optional. Similar to
userProperties. See the difference betweenuserPropertiesanduserPropertiesSetOnce(/docs/product-analytics/person-properties.md#what-is-the-difference-between-set-and-set_once)
Dart
await Posthog().identify(
userId: emailController.text,
userProperties: {"name": "Peter Griffin", "email": "peter@familyguy.com"},
userPropertiesSetOnce: {"date_of_first_log_in": "2024-03-01"}
);You should call identify as soon as you're able to. Typically, this is after your user logs in. This ensures that events sent during your user's sessions are correctly associated with them.
When you call identify, all previously tracked anonymous events will be linked to the user.
Get the current user's distinct ID
You may find it helpful to get the current user's distinct ID. For example, to check whether you've already called identify for a user or not.
To do this, call Posthog().getDistinctId(). This returns either the ID automatically generated by PostHog or the ID that has been passed by a call to identify().
Alias
Sometimes, you want to assign multiple distinct IDs to a single user. This is helpful when your primary distinct ID is inaccessible. For example, if a distinct ID used on the frontend is not available in your backend.
In this case, you can use alias to assign another distinct ID to the same user.
Dart
await Posthog().alias(
alias: 'distinct_id',
);We strongly recommend reading our docs on alias (/docs/product-analytics/identify.md#alias-assigning-multiple-distinct-ids-to-the-same-user) to best understand how to correctly use this method.
Anonymous vs identified events
PostHog captures two types of events: anonymous and identified (/docs/data/anonymous-vs-identified-events.md)
Identified events enable you to attribute events to specific users, and attach person properties (/docs/product-analytics/person-properties.md). They're best suited for logged-in users.
Scenarios where you want to capture identified events are:
- Tracking logged-in users in B2B and B2C SaaS apps
- Doing user segmented product analysis
- Growth and marketing teams wanting to analyze the complete conversion lifecycle
Anonymous events are events without individually identifiable data. They're best suited for web analytics (/docs/web-analytics.md) or apps where users aren't logged in.
Scenarios where you want to capture anonymous events are:
- Tracking a marketing website
- Content-focused sites
- B2C apps where users don't sign up or log in
Under the hood, the key difference between identified and anonymous events is that for identified events we create a person profile (/docs/data/persons.md) for the user, whereas for anonymous events we do not.
Important: Due to the reduced cost of processing them, anonymous events can be up to 4x cheaper than identified ones, so we recommended you only capture identified events when needed.
How to capture anonymous events
The Flutter SDK captures anonymous events by default. However, this may change depending on your personProfiles config (/docs/libraries/flutter.md#person-profiles-anonymous-vs-identified-persons) when initializing PostHog:
personProfiles: PostHogPersonProfiles.identifiedOnly(recommended) (default) - Anonymous events are captured by default. PostHog only captures identified events for users where person profiles (/docs/data/persons.md) have already been created.personProfiles: PostHogPersonProfiles.always- Capture identified events for all events.personProfiles: PostHogPersonProfiles.never- Capture anonymous events for all events.
For example:
Dart
final config = PostHogConfig('<ph_project_token>');
config.host = 'https://us.i.posthog.com';
config.personProfiles = PostHogPersonProfiles.identifiedOnly;How to capture identified events
If you've set the personProfiles config (/docs/libraries/flutter.md#person-profiles-anonymous-vs-identified-persons) to PostHogPersonProfiles.identifiedOnly (the default option), anonymous events are captured by default. Then, to capture identified events, call any of the following functions:
identify()(/docs/product-analytics/identify.md)alias()(/docs/product-analytics/identify.md#alias-assigning-multiple-distinct-ids-to-the-same-user)group()(/docs/product-analytics/group-analytics.md)
When you call any of these functions, it creates a person profile (/docs/data/persons.md) for the user. Once this profile is created, all subsequent events for this user will be captured as identified events.
Alternatively, you can set personProfiles to PostHogPersonProfiles.always to capture identified events by default.
Super properties
Super properties are properties associated with events that are set once and then sent with every capture call, be it a $screen, or anything else.
They are set using Posthog().register, which takes a key and value, and they persist across sessions.
For example, take a look at the following call:
Dart
import 'package:posthog_flutter/posthog_flutter.dart';
await Posthog().register("team_id", 22);The call above ensures that every event sent by the user will include "team_id": 22. This way, if you filtered events by property using team_id = 22, it would display all events captured on that user after the Posthog().register call, since they all include the specified super property.
However, please note that this does not store properties against the User, only against their events. To store properties against the User object, you should use Posthog().identify. More information on this can be found on the Sending User Information section (#sending-user-information).
Removing stored super properties
Super properties are persisted across sessions so you have to explicitly remove them if they are no longer relevant. In order to stop sending a super property with events, you can use Posthog().unregister, like so:
Dart
import 'package:posthog_flutter/posthog_flutter.dart';
await Posthog().unregister("team_id");This will remove the super property and subsequent events will not include it.
If you are doing this as part of a user logging out you can instead simply use Posthog().reset() which takes care of clearing all stored super properties and more.
Group analytics
Group analytics allows you to associate the events for that person's session with a group (e.g. teams, organizations, etc.). See Group Analytics (/docs/product-analytics/group-analytics.md) for Flutter examples and implementation details.
Note: This is a paid feature and is not available on the open-source or free cloud plan. Learn more on the pricing page (/pricing.md).
Feature flags
PostHog's feature flags (/docs/feature-flags.md) enable you to safely deploy and roll back new features as well as target specific users and groups with them.
Boolean feature flags
Dart
final result = await Posthog().getFeatureFlagResult('flag-key');
if (result != null && result.enabled) {
// Do something differently for this user
// Optional: fetch the payload from the same evaluation result
final matchedFlagPayload = result.payload;
}Multivariate feature flags
Dart
final result = await Posthog().getFeatureFlagResult('flag-key');
if (result != null && result.variant == 'variant-key') { // replace 'variant-key' with the key of your variant
// Do something differently for this user
// Optional: fetch the payload from the same evaluation result
final matchedFlagPayload = result.payload;
}Ensuring flags are loaded before usage
To use the
onFeatureFlagscallback, you must set up the SDK manually (#installation). On Android and iOS, disablecom.posthog.posthog.AUTO_INITfirst.
Every time a user opens the app, we send a request in the background to fetch the feature flags that apply to that user. We store those flags in the storage.
This means that for most screens, the feature flags are available immediately – except for the first time a user visits.
To handle this, you can use the onFeatureFlags callback in your config to be notified when flags are loaded:
Dart
final config = PostHogConfig('<ph_project_token>');
config.host = 'https://us.i.posthog.com';
config.onFeatureFlags = () async {
if (await Posthog().isFeatureEnabled('flag-key')) {
// do something
}
};
await Posthog().setup(config);Reloading feature flags
Feature flag values are cached. If something has changed with your user and you'd like to refetch their flag values, call:
Dart
await Posthog().reloadFeatureFlags();Bootstrapping flags
Since there is a delay between initializing PostHog and fetching feature flags, feature flags are not always available immediately. This makes them unusable if you want to do something like redirecting a user to a different page based on a feature flag.
To have your feature flags available immediately, you can initialize PostHog with precomputed values until it has had a chance to fetch them. This is called bootstrapping. After the SDK fetches feature flags from PostHog, it will use those flag values instead of bootstrapped ones.
Set config.bootstrap before calling setup() to seed identity and flag values before the first /flags response (requires the Flutter SDK 5.31.0+):
Dart
final config = PostHogConfig('<ph_project_token>');
config.host = 'https://us.i.posthog.com';
config.bootstrap = PostHogBootstrapConfig(
distinctId: 'distinct_id_of_your_user',
isIdentifiedId: true,
featureFlags: {
'flag-1': true,
'variant-flag': 'control',
},
);
await Posthog().setup(config);The values are forwarded to the native iOS and Android SDKs:
- Bootstrapped identity applies during setup. On a fresh install, setting it before
setup()means events captured synchronously during initialization (likeApplication Installed) carry your distinct ID instead of the SDK-generated UUID.- An anonymous bootstrap (
isIdentifiedId: false, the default) seeds the anonymous ID only when none is persisted yet. Once an anonymous ID exists on disk, or the person has been identified, the SDK ignores it. - An identified bootstrap (
isIdentifiedId: true) is for a signed-in identity available to your app (for example, from a backend session token). On a fresh install, it seeds the distinct ID, marks the person identified, and generates a separate device ID. On a returning install, a matching anonymous ID is marked identified without emitting$identify; a different anonymous ID is merged viaidentify()when person profiles are enabled. This emits$identifyunless capturing is opted out. A different, already-identified person is left untouched.
- An anonymous bootstrap (
- Bootstrapped flags are served until the first
/flagsresponse, then replaced. A complete/flagsresponse takes over entirely, so bootstrapped-only keys don't persist past it. Only enabled flags are seeded: atrueboolean or a non-empty variant string. Afalseor empty value is dropped, matching posthog-js. Seed payloads with the separatefeatureFlagPayloadsoption. Flag values and payloads must be JSON-serializable, or they're dropped. Bootstrapped flags are cleared onreset().
The feature-flags-loaded signal fires as soon as bootstrapped flags are applied, so startup logic can read them immediately. These SDKs don't support the sessionID bootstrap option. When person profiles are set to never, the SDK preserves a different anonymous identity instead of merging it into an identified bootstrap.
On Flutter web, bootstrap is not applied, so configure it in your posthog.init({...}) snippet instead. See the SDK bootstrapping guide (/docs/libraries/bootstrapping.md) for the cross-SDK overview.
Setting properties for flag evaluation
If a flag targets person or group properties, you can send those properties inline with the next flag evaluation request instead of waiting for a $set event to be ingested. This avoids the race where a flag returns a stale value right after you set a property.
Dart
// Person properties — included in the next flag evaluation request
await Posthog().setPersonPropertiesForFlags({
'storefront_country': 'US',
'is_beta_user': true,
});
// Group properties
await Posthog().setGroupPropertiesForFlags('company', {'plan': 'enterprise'});By default these reload feature flags, and the returned Future completes once the reload finishes, so the next getFeatureFlag reflects the new properties. Pass reloadFeatureFlags: false to set several properties before reloading. Use resetPersonPropertiesForFlags() and resetGroupPropertiesForFlags() to clear them. See property overrides for flag evaluation (/docs/feature-flags/property-overrides.md) for details.
Experiments (A/B tests)
Since experiments (/docs/experiments/start-here.md) use feature flags, the code for running an experiment is very similar to the feature flags code. See feature flag code examples (/docs/feature-flags/adding-feature-flag-code?tab=Flutter.md) for Flutter implementation details.
It's also possible to run experiments without using feature flags (/docs/experiments/running-experiments-without-feature-flags.md).
Error tracking
To set up error tracking in your project, see the error tracking docs (/docs/error-tracking.md).
Logs
To set up logs (/docs/logs.md) in your Flutter app, follow the Flutter logs installation guide (/docs/logs/installation/flutter.md). The SDK exposes Posthog().logger.{trace,debug,info,warn,error,fatal} (and Posthog().captureLog for full control) for sending structured records to PostHog Logs, with batching, offline persistence, and a rate cap built in.
Session replay
Note: Session replay is supported on Flutter Web, Android, and iOS.
To set up session replay web (/docs/session-replay.md) or mobile session replay (/docs/session-replay/mobile.md) in your project, all you need to do is install the Flutter SDK, follow the additional installation instructions (/docs/session-replay/installation/flutter.md), and enable "Record user sessions" in your project settings and enable the sessionReplay option.
If you're using Flutter Web, also enable the Canvas capture (/docs/session-replay/canvas-recording.md) in your project settings. This is needed as Flutter renders your app using a browser canvas element.
On Flutter Web, masking (maskAllTexts, maskAllImages, PostHogMaskWidget) applies inside that canvas too — declare session_recording.canvasCapture.maskRegionsFn in the posthog.init call in your web/index.html to enable it (requires PostHog Flutter SDK 5.34.0+ and posthog-js 1.408.0+). See masking on Flutter Web (/docs/session-replay/privacy.md) under the Flutter tab.
Surveys
Note: Surveys are supported in Flutter for Web, iOS, and Android platforms.
Surveys (/docs/surveys.md) launched with popover presentation (/docs/surveys/creating-surveys.md#presentation) are automatically shown to users matching the display conditions (/docs/surveys/creating-surveys.md#display-conditions) you set up.
Push notifications
The Flutter SDK can register a device for Workflows (/docs/workflows.md) push notifications and capture when a user opens one. For setup, including automatic and manual registration, capturing opens, opting out, and identity verification, see Push notifications (/docs/workflows/push-notifications.md).
Flush
You can configure how many events queue before flushing with flushAt. Setting this to 1 will send events immediately and will use more battery. The default is 20.
You can also configure the flush interval with flushInterval (default 30 seconds), after which queued events are sent regardless of how many have been gathered:
Dart
final config = PostHogConfig('<ph_project_token>');
config.flushAt = 20;
config.flushInterval = const Duration(seconds: 30);You can also manually flush the queue to start sending events immediately instead of waiting for the next batch:
Dart
await Posthog().flush();Flushing is best-effort and asynchronous – it starts sending queued events in the background but doesn't wait for the request to finish, so it isn't a delivery guarantee.
Offline behavior
The PostHog Flutter SDK will continue to capture events when the device is offline for Android and Apple platforms. The events are stored in a queue in the device's file storage and are flushed when the device is online.
- The queue has a maximum size defined by
maxQueueSizein the configuration. - When the queue is full, the oldest event is deleted first.
- The queue is flushed when the app is restarted and the device is online.
Opt out of data capture
You can disable data collection for a user at any time using the disable() method:
Dart
await Posthog().disable();This prevents any future events from being sent. It doesn't remove events already captured for the user. To opt the user back in:
Dart
await Posthog().enable();To check if a user is opted out:
Dart
await Posthog().isOptOut();Amending or dropping events
Since version 5.13.0, you can provide beforeSend callbacks when initializing the SDK to amend or drop events before they are sent to PostHog.
Redacting information in events
beforeSend gives you one place to edit or redact information before it is sent to PostHog. For example:
Dart
final config = PostHogConfig('<ph_project_token>');
config.host = 'https://us.i.posthog.com';
config.beforeSend = [
(event) {
// Redact email from properties
if (event.properties?['email'] != null) {
event.properties?['email'] = '***@***.***';
}
return event;
},
];
await Posthog().setup(config);Dropping events
Return null from the callback to drop the event:
Dart
config.beforeSend = [
(event) {
// Drop events you don't want to send
if (event.event == 'ignored_event') {
return null;
}
return event;
},
];Filtering autocaptured screens
You can stop specific screens from being autocaptured by filtering them in your before-send hook. Return null for any $screen event whose $screen_name matches a screen you don't want to track, and it's dropped before being sent – keeping unwanted screen views out of your event log.
Because it's just a function, you can filter however you like – an ignorelist (drop the screens you name), an allowlist (invert the check to capture only the screens you name), or any custom rule such as a name prefix, a regex, or a check against the event's properties.
Dart
const ignoredScreens = {'Splash', 'Debug'};
config.beforeSend = [
(event) {
final screenName = event.properties?['$screen_name'];
if (event.event == '$screen' && ignoredScreens.contains(screenName)) {
return null;
}
return event;
},
];Limitations
The beforeSend callbacks only apply to events captured via Dart APIs:
Posthog().capture()- custom eventsPosthog().screen()- screen events (event name is$screen)Posthog().captureException()- exception events (event name is$exception)
They do not intercept native-initiated events such as:
- Session replay events (
$snapshot) - Application lifecycle events (
Application Opened, etc.)
Additionally, only user-provided properties are available in the callback. System properties (like $device_type, $session_id) are added by the native SDK at a later stage.
Debug mode
If you're not seeing the expected events being captured, the feature flags being evaluated, or the surveys being shown, you can enable debug mode to see what's happening.
You can enable debug mode during initialization by setting the debug option to true in the PostHogConfig object. A common pattern is to set this to true in development environments only using environment variables.
Dart
final config = PostHogConfig('<ph_project_token>');
config.host = 'https://us.i.posthog.com';
config.debug = true;
await Posthog().setup(config);This will enable verbose logs about the inner workings of the SDK.
You can also enable debug by calling the Posthog().debug() method in your code.
Dart
await Posthog().debug(true);
await Posthog().debug(false);Still have questions?
Ask PostHog AI
Was this page useful?
HelpfulCould be better
references/go.md
AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt
Go
This library uses an internal queue to make calls fast and non-blocking. It also batches requests and flushes asynchronously, making it perfect to use in any part of your web app or other server-side application that needs performance.
Installation
Terminal
go get github.com/posthog/posthog-goGo
package main
import (
"os"
"github.com/posthog/posthog-go"
)
func main() {
client, _ := posthog.NewWithConfig(
os.Getenv("POSTHOG_API_KEY"),
posthog.Config{
PersonalApiKey: "your personal API key", // Optional, but much more performant. If this token is not supplied, then fetching feature flag values will be slower.
Endpoint: "https://us.i.posthog.com",
},
)
defer client.Close()
// run commands
}Identifying users
Identifying users is required. Backend events need a
distinct_idthat matches the ID your frontend uses when callingposthog.identify(). Without this, backend events are orphaned — they can't be linked to frontend event captures, session replays (/docs/session-replay.md), LLM traces (/docs/ai-engineering.md), or error tracking (/docs/error-tracking.md).See our guide on identifying users (/docs/getting-started/identify-users.md) for how to set this up.
Capturing events
You can send custom events using capture:
Go
client.Enqueue(posthog.Capture{
DistinctId: "distinct_id_of_the_user",
Event: "user_signed_up",
})Tip: We recommend using a
[object] [verb]format for your event names, where[object]is the entity that the behavior relates to, and[verb]is the behavior itself. For example,project created,user signed up, orinvite sent.
Tip: You can define event schemas with typed properties and generate type-safe code using schema management (/docs/product-analytics/schema-management.md).
Setting event properties
Optionally, you can include additional information with the event by including a properties (/docs/data/events.md#event-properties) object:
Go
client.Enqueue(posthog.Capture{
DistinctId: "distinct_id_of_the_user",
Event: "user_signed_up",
Properties: posthog.NewProperties().
Set("login_type", "email").
Set("is_free_trial", true),
})Capturing pageviews
If you're aiming for a backend-only implementation of PostHog and won't be capturing events from your frontend, you can send pageviews from your backend like so:
Go
client.Enqueue(posthog.Capture{
DistinctId: "distinct_id_of_the_user",
Event: "$pageview",
Properties: posthog.NewProperties().
Set("$current_url", "https://example.com"),
})Person profiles and properties
For backward compatibility, the Go SDK captures identified events by default. These create person profiles (/docs/data/persons.md). To set person properties (/docs/product-analytics/person-properties.md) in these profiles, include them when capturing an event:
Go
client.Enqueue(posthog.Capture{
DistinctId: "distinct_id",
Event: "event_name",
Properties: map[string]interface{}{
"$set": map[string]interface{}{
"name": "Max Hedgehog",
},
"$set_once": map[string]interface{}{
"initial_url": "/blog",
},
},
})For more details on the difference between $set and $set_once, see our person properties docs (/docs/product-analytics/person-properties.md#what-is-the-difference-between-set-and-set_once).
To capture anonymous events (/docs/data/anonymous-vs-identified-events.md) without person profiles, set the event's $process_person_profile property to false:
Go
client.Enqueue(posthog.Capture{
DistinctId: "distinct_id",
Event: "event_name",
Properties: map[string]interface{}{
"$process_person_profile": false,
},
})Alias
Sometimes, you want to assign multiple distinct IDs to a single user. This is helpful when your primary distinct ID is inaccessible. For example, if a distinct ID used on the frontend is not available in your backend.
In this case, you can use alias to assign another distinct ID to the same user.
Go
client.Enqueue(posthog.Alias{
DistinctId: "distinct_id",
Alias: "alias_id",
})We strongly recommend reading our docs on alias (/docs/product-analytics/identify.md#alias-assigning-multiple-distinct-ids-to-the-same-user) to best understand how to correctly use this method.
Request context
Use request context to apply a distinct ID, session ID, and common request properties to capture and exception events inside a net/http request. This is useful when connecting frontend activity to backend events, session replay, error tracking, and feature flag evaluation.
If you're using PostHog JS (/docs/libraries/js.md) on the frontend, configure tracing_headers (/docs/libraries/js/config.md#tracing-headers) for your Go backend hostname so browser requests include the session and distinct ID headers. Then wrap your handler with NewRequestContextMiddleware and use the context-aware helpers:
Go
handler := posthog.NewRequestContextMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
flags, err := posthog.EvaluateFlagsWithContext(r.Context(), client, posthog.EvaluateFlagsPayload{})
if err != nil {
// If neither the request context nor payload has a distinct ID,
// err is posthog.ErrNoDistinctID.
}
_ = posthog.EnqueueWithContext(r.Context(), client, posthog.Capture{
Event: "checkout started",
Flags: flags,
})
}))The middleware adds $current_url, $request_method, $request_path, $user_agent, and $ip properties. By default, it also reads X-PostHog-Distinct-Id and X-PostHog-Session-Id as request-scoped defaults. Explicit DistinctId values and $session_id properties passed to captures take precedence over request context.
If request context is attached but no distinct ID is available, capture and exception events are sent as personless events (/docs/data/anonymous-vs-identified-events.md) with an auto-generated UUID and $process_person_profile: false. Calls to Enqueue without request context still require DistinctId. EvaluateFlagsWithContext uses the request-scoped distinct ID when EvaluateFlagsPayload.DistinctId is empty, but it never generates personless IDs and returns ErrNoDistinctID when no distinct ID is available.
Tracing headers are client-controlled analytics context, not authentication or authorization. For security-sensitive server-side decisions, pass an authenticated DistinctId explicitly or attach one to the request context:
Go
ctx := posthog.WithRequestContext(r.Context(), posthog.RequestContext{
DistinctId: user.ID,
})To ignore tracing headers while keeping request metadata, disable tracing header capture:
Go
handler := posthog.NewRequestContextMiddleware(
next,
posthog.WithCaptureTracingHeaders(false),
)Feature flags
PostHog's feature flags (/docs/feature-flags.md) enable you to safely deploy and roll back new features as well as target specific users and groups with them.
There are two steps to implement feature flags in Go:
Step 1: Evaluate flags once
Call client.EvaluateFlags() once for the user, then read values from the returned snapshot.
Boolean feature flags
Go
flags, err := client.EvaluateFlags(posthog.EvaluateFlagsPayload{
DistinctId: "distinct_id_of_your_user",
})
if err != nil {
// Handle error (e.g. capture error and fallback to default behavior)
}
if flags.IsEnabled("flag-key") {
// Do something differently for this user
// Optional: fetch the payload
matchedFlagPayload := flags.GetFlagPayload("flag-key")
}Multivariate feature flags
Go
flags, err := client.EvaluateFlags(posthog.EvaluateFlagsPayload{
DistinctId: "distinct_id_of_your_user",
})
if err != nil {
// Handle error (e.g. capture error and fallback to default behavior)
}
enabledVariant := flags.GetFlag("flag-key")
if enabledVariant == "variant-key" { // replace "variant-key" with the key of your variant
// Do something differently for this user
// Optional: fetch the payload
matchedFlagPayload := flags.GetFlagPayload("flag-key")
}flags.GetFlag() returns the variant string for multivariate flags, true for enabled boolean flags, false for disabled flags, and nil when the flag wasn't returned by the evaluation.
Note:
client.IsFeatureEnabled(),client.GetFeatureFlag(),client.GetFeatureFlagPayload(), andCapture.SendFeatureFlagsstill work during the migration period, but they're deprecated. PreferEvaluateFlags()for new code.
Step 2: Include feature flag information when capturing events
If you want use your feature flag to breakdown or filter events in your insights (/docs/product-analytics/insights.md), you'll need to include feature flag information in those events. This ensures that the feature flag value is attributed correctly to the event.
Note: This step is only required for events captured using our server-side SDKs or API (/docs/api.md).
There are two methods you can use to include feature flag information in your events:
Method 1: Pass the evaluated flags snapshot to Capture
Pass the same flags object that you used for branching. This attaches the exact flag values from that evaluation and doesn't make another /flags request.
Go
flags, err := client.EvaluateFlags(posthog.EvaluateFlagsPayload{
DistinctId: "distinct_id_of_your_user",
})
if err != nil {
// Handle error
}
if flags.IsEnabled("flag-key") {
// Do something differently for this user
}
client.Enqueue(posthog.Capture{
DistinctId: "distinct_id_of_your_user",
Event: "event_name",
Flags: flags,
})By default, this attaches every flag in the snapshot using $feature/<flag-key> properties and $active_feature_flags.
To reduce event property bloat, pass a filtered snapshot:
Go
// Attach only flags accessed with IsEnabled() or GetFlag() before this call
client.Enqueue(posthog.Capture{
DistinctId: "distinct_id_of_your_user",
Event: "event_name",
Flags: flags.OnlyAccessed(),
})
// Attach only specific flags
client.Enqueue(posthog.Capture{
DistinctId: "distinct_id_of_your_user",
Event: "event_name",
Flags: flags.Only([]string{"checkout-flow", "new-dashboard"}),
})OnlyAccessed() is order-dependent. If you call it before accessing any flags with IsEnabled() or GetFlag(), no feature flag properties are attached.
Method 2: Include the $feature/feature_flag_name property manually
In the event properties, include $feature/feature_flag_name: variant_key:
Go
client.Enqueue(posthog.Capture{
DistinctId: "distinct_id_of_your_user",
Event: "event_name",
Properties: posthog.NewProperties().
Set("$feature/feature-flag-key", "variant-key"), // replace feature-flag-key with your flag key. Replace "variant-key" with the key of your variant
})Evaluating only specific flags
By default, EvaluateFlags() evaluates every flag for the user. If you only need a few flags, pass FlagKeys to request only those flags:
Go
flags, err := client.EvaluateFlags(posthog.EvaluateFlagsPayload{
DistinctId: "distinct_id_of_your_user",
FlagKeys: []string{"checkout-flow", "new-dashboard"},
})Sending $feature_flag_called events
Capturing $feature_flag_called events enables PostHog to know when a flag was accessed by a user and provide analytics and insights (/docs/product-analytics/insights.md) on the flag. With EvaluateFlags(), the SDK sends this event when you call flags.IsEnabled() or flags.GetFlag() for a flag.
The SDK deduplicates these events per (distinct_id, flag, value) in a local cache. If you reinitialize the PostHog client, the cache resets and $feature_flag_called events may be sent again. PostHog handles duplicates, so duplicate $feature_flag_called events don't affect your analytics.
flags.GetFlagPayload() doesn't send $feature_flag_called events and doesn't count as an access for OnlyAccessed().
Advanced: Overriding server properties
Sometimes, you may want to evaluate feature flags using person properties (/docs/product-analytics/person-properties.md), groups (/docs/product-analytics/group-analytics.md), or group properties that haven't been ingested yet, or were set incorrectly earlier.
You can provide properties to evaluate the flag with by using the person properties, groups, and group properties arguments. PostHog will then use these values to evaluate the flag, instead of any properties currently stored on your PostHog server.
For example:
Go
flags, err := client.EvaluateFlags(posthog.EvaluateFlagsPayload{
DistinctId: "distinct_id_of_the_user",
Groups: posthog.NewGroups().
Set("your_group_type", "your_group_id").
Set("another_group_type", "your_group_id"),
PersonProperties: posthog.NewProperties().
Set("property_name", "value"),
GroupProperties: map[string]posthog.Properties{
"your_group_type": posthog.NewProperties().
Set("group_property_name", "value"),
"another_group_type": posthog.NewProperties().
Set("group_property_name", "value"),
},
})
if err != nil {
// Handle error
}
if flags.IsEnabled("flag-key") {
// Do something differently for this user
}Overriding GeoIP properties
By default, a user's GeoIP properties are set using the IP address they use to capture events on the frontend. You may want to override the these properties when evaluating feature flags. A common reason to do this is when you're not using PostHog on your frontend, so the user has no GeoIP properties.
You can override GeoIP properties by including them in the person_properties parameter when evaluating feature flags. This is useful when you're evaluating flags on your backend and want to use the client's location instead of your server's location.
The following GeoIP properties can be overridden:
$geoip_country_code$geoip_country_name$geoip_city_name$geoip_city_confidence$geoip_continent_code$geoip_continent_name$geoip_latitude$geoip_longitude$geoip_postal_code$geoip_subdivision_1_code$geoip_subdivision_1_name$geoip_subdivision_2_code$geoip_subdivision_2_name$geoip_subdivision_3_code$geoip_subdivision_3_name$geoip_time_zone
Simply include any of these properties in the person_properties parameter alongside your other person properties when calling feature flags.
Request timeout
You can configure the FeatureFlagRequestTimeout parameter when initializing your PostHog client to set a flag request timeout. This helps prevent your code from being blocked if PostHog's servers are too slow to respond. By default, this is set to 3 seconds.
Go
// import "time"
client, _ := posthog.NewWithConfig(
os.Getenv("<ph_project_token>"),
posthog.Config{
PersonalApiKey: "your personal API key", // Optional, but much more performant. If this token is not supplied, then fetching feature flag values will be slower.
Endpoint: "https://us.i.posthog.com",
FeatureFlagRequestTimeout: 3 * time.Second, // Defaults to 3 seconds.
},
)Local Evaluation
Evaluating feature flags requires making a request to PostHog for each flag. However, you can improve performance by evaluating flags locally. Instead of making a request for each flag, PostHog will periodically request and store feature flag definitions locally, enabling you to evaluate flags without making additional requests.
It is best practice to use local evaluation flags when possible, since this enables you to resolve flags faster and with fewer API calls.
For details on how to implement local evaluation, see our local evaluation guide (/docs/feature-flags/local-evaluation.md).
Experiments (A/B tests)
Since experiments (/docs/experiments/start-here.md) use feature flags, the code for running an experiment is very similar to the feature flags code:
Go
flags, err := client.EvaluateFlags(posthog.EvaluateFlagsPayload{
DistinctId: "user_distinct_id",
})
if err != nil {
// Handle error (e.g. capture error and fallback to default behavior)
}
variant := flags.GetFlag("experiment-feature-flag-key")
if variant == "variant-name" {
// Do something
}It's also possible to run experiments without using feature flags (/docs/experiments/running-experiments-without-feature-flags.md).
Error tracking
You can capture exceptions and errors using the Go SDK. There are two approaches:
Direct capture using NewDefaultException, which automatically generates a stack trace:
Go
exception := posthog.NewDefaultException(
time.Now(),
"user_distinct_id",
"DatabaseError", // type - rendered as title in the UI
"connection refused", // value - rendered as description in the UI
)
client.Enqueue(exception)Automatic capture using the SlogCaptureHandler, which wraps Go's log/slog and sends log records at warning level and above as exceptions:
Go
baseHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})
logger := slog.New(posthog.NewSlogCaptureHandler(baseHandler, client,
posthog.WithDistinctIDFn(func(ctx context.Context, r slog.Record) string {
return "user_distinct_id"
}),
))
// Automatically captured as an exception in PostHog
logger.Warn("Something broke", "error", fmt.Errorf("connection refused"))For the full setup guide, see the Go error tracking installation docs (/docs/error-tracking/installation/go.md).
Group analytics
Group analytics allows you to associate an event with a group (e.g. teams, organizations, etc.). Read the Group Analytics (/docs/user-guides/group-analytics.md) guide for more information.
Note: This is a paid feature and is not available on the open-source or free cloud plan. Learn more on the pricing page (/pricing.md).
- Send an event associated with a group
Go
client.Enqueue(posthog.Capture{
DistinctId: "user_distinct_id",
Event: "some_event",
Groups: posthog.NewGroups().
Set("company", "company_id_in_your_db"),
})- Update properties on a group
Go
client.Enqueue(posthog.GroupIdentify{
Type: "company",
Key: "company_id_in_your_db",
Properties: posthog.NewProperties().
Set("name", "Awesome Inc.").
Set("employees", 11),
})The name is a special property which is used in the PostHog UI for the name of the group. If you don't specify a name property, the group ID will be used instead.
Thank you
This library is largely based on the analytics-go package.
Still have questions?
Ask PostHog AI
Was this page useful?
HelpfulCould be better
references/identify-users.md
AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt
Identify users
Linking events to specific users enables you to build a full picture of how they're using your product across different sessions, devices, and platforms.
This is straightforward to do when capturing backend events (/docs/product-analytics/capture-events?tab=Node.js.md), as you associate events to a specific user using a distinct_id, which is a required argument.
However, in the frontend of a web (/docs/libraries/js/usage.md#capturing-events) or mobile app (/docs/libraries/ios.md#capturing-events), a distinct_id is not a required argument — PostHog's SDKs will generate an anonymous distinct_id for you automatically and you can capture events anonymously, provided you use the appropriate configuration (/docs/libraries/js/usage.md#capturing-anonymous-events).
To link events to specific users, call identify:
Web
posthog.identify(
'distinct_id', // Replace 'distinct_id' with your user's unique identifier
{ email: 'max@hedgehogmail.com', name: 'Max Hedgehog' } // optional: set additional person properties
);Android
PostHog.identify(
distinctId = distinctID, // Replace 'distinctID' with your user's unique identifier
// optional: set additional person properties
userProperties = mapOf(
"name" to "Max Hedgehog",
"email" to "max@hedgehogmail.com"
)
)iOS
PostHogSDK.shared.identify("distinct_id", // Replace "distinct_id" with your user's unique identifier
userProperties: ["name": "Max Hedgehog", "email": "max@hedgehogmail.com"]) // optional: set additional person propertiesReact Native
posthog.identify('distinct_id', { // Replace "distinct_id" with your user's unique identifier
email: 'max@hedgehogmail.com', // optional: set additional person properties
name: 'Max Hedgehog'
})Dart
await Posthog().identify(
userId: 'distinct_id', // Replace "distinct_id" with your user's unique identifier
userProperties: {
'email': 'max@hedgehogmail.com', // optional: set additional person properties
'name': 'Max Hedgehog',
},
);Events captured after calling identify are identified events and this creates a person profile if one doesn't exist already.
Due to the cost of processing them, anonymous events can be up to 4x cheaper than identified events, so it's recommended you only capture identified events when needed.
How identify works
When a user starts browsing your website or app, PostHog automatically assigns them an anonymous ID, which is stored locally.
Provided you've configured persistence (/docs/libraries/js/persistence.md) to use cookies or localStorage, this enables us to track anonymous users – even across different sessions.
By calling identify with a distinct_id of your choice (usually the user's ID in your database, or their email), you link the anonymous ID and distinct ID together.
Thus, all past and future events made with that anonymous ID are now associated with the distinct ID.
This enables you to do things like associate events with a user from before they log in for the first time, or associate their events across different devices or platforms.
Using identify in the backend
Although you can call identify using our backend SDKs, it is used most in frontends. This is because there is no concept of anonymous sessions in the backend SDKs, so calling identify only updates person profiles.
Best practices when using identify
1. Call identify as soon as you're able to
In your frontend, you should call identify as soon as you're able to.
Typically, this is every time your app loads for the first time, and directly after your users log in.
This ensures that events sent during your users' sessions are correctly associated with them.
You only need to call identify once per session, and you should avoid calling it multiple times unnecessarily.
If you call identify multiple times with the same data without reloading the page in between, PostHog will ignore the subsequent calls.
Identify users when the web SDK loads
If your app already knows the signed-in user when you initialize the JavaScript web SDK, the loaded callback (/docs/libraries/js/config.md) is a convenient place to call identify. This identifies the user as soon as the SDK has loaded:
Web
posthog.init('<ph_project_token>', {
api_host: 'https://us.i.posthog.com',
defaults: '2026-05-30',
loaded: (posthog) => {
if (currentUser?.id) {
posthog.identify(currentUser.id, {
email: currentUser.email,
name: currentUser.name,
})
}
},
})In this example, currentUser represents user data already available from your authentication system. If your app loads the user asynchronously, call posthog.identify() as soon as that data becomes available instead.
2. Use unique strings for distinct IDs
If two users have the same distinct ID, their data is merged and they are considered one user in PostHog. Two common ways this can happen are:
- Your logic for generating IDs does not generate sufficiently strong IDs and you can end up with a clash where 2 users have the same ID.
- There's a bug, typo, or mistake in your code leading to most or all users being identified with generic IDs like
null,true, ordistinctId.
PostHog also has built-in protections to stop the most common distinct ID mistakes.
3. Reset after logout
If a user logs out on your frontend, you should call reset() to unlink any future events made on that device with that user.
This is important if your users are sharing a computer, as otherwise all of those users are grouped together into a single user due to shared cookies between sessions.
We strongly recommend you call reset on logout even if you don't expect users to share a computer.
You can do that like so:
Web
posthog.reset()iOS
PostHogSDK.shared.reset()Android
PostHog.reset()React Native
posthog.reset()Dart
await Posthog().reset();If you also want to reset the device_id so that the device will be considered a new device in future events, you can pass true as an argument:
Web
posthog.reset(true)4. Person profiles and properties
You'll notice that one of the parameters in the identify method is a properties object.
This enables you to set person properties (/docs/product-analytics/person-properties.md).
Whenever possible, we recommend passing in all person properties you have available each time you call identify, as this ensures their person profile on PostHog is up to date.
Person properties can also be set being adding a $set property to a event capture call.
`$set` and `$set_once` aren't stored on events
These properties only tell PostHog how to update person data during ingestion — they aren't kept on the stored event, so you can't filter, break down, or query events by them. To query the values you set, use person properties (/docs/product-analytics/person-properties.md) instead.
See our person properties docs (/docs/product-analytics/person-properties.md) for more details on how to work with them and best practices.
5. Use deep links between platforms
We recommend you call identify as soon as you're able (#1-call-identify-as-soon-as-youre-able), typically when a user signs up or logs in.
This doesn't work if one or both platforms are unauthenticated. Some examples of such cases are:
- Onboarding and signup flows before authentication.
- Unauthenticated web pages redirecting to authenticated mobile apps.
- Authenticated web apps prompting an app download.
In these cases, you can use a deep link on Android and universal links on iOS to identify users.
- Use
posthog.get_distinct_id()to get the current distinct ID. Even if you cannot call identify because the user is unauthenticated, this will return an anonymous distinct ID generated by PostHog. - Add the distinct ID to the deep link as query parameters, along with other properties like UTM parameters.
- When the user is redirected to the app, parse the deep link and handle the following cases:
- The mobile app is already authenticated. In this case, call
posthog.alias()(/docs/libraries/js/usage.md#alias) with the distinct ID from the web. This associates the two distinct IDs as a single person. - The mobile app is unauthenticated. In this case, call
posthog.identify()(/docs/libraries/js/usage.md#identifying-users) with the distinct ID from the web so pre-login mobile events stay connected to the web session. When the user later logs in on mobile, callidentify()again with your canonical user ID.
As long as you associate the distinct IDs with posthog.identify() or posthog.alias(), you can track events generated across platforms.
Here's an example implementation for handling deep links from web to mobile:
iOS
import PostHog
class DeepLinkIdentityManager {
static let shared = DeepLinkIdentityManager()
// MARK: - Deep Link Received
func handleDeepLink(_ url: URL, isAuthenticatedOnMobile: Bool) {
guard let webDistinctId = URLComponents(url: url, resolvingAgainstBaseURL: true)?
.queryItems?.first(where: { $0.name == "ph_distinct_id" })?.value else {
return
}
if isAuthenticatedOnMobile {
// The mobile app already knows the current user.
// Alias the incoming web distinct ID to that user.
PostHogSDK.shared.alias(webDistinctId)
} else {
// Reuse the web distinct ID until login on mobile.
PostHogSDK.shared.identify(webDistinctId)
}
}
// MARK: - Login/Signup
func handleLogin(canonicalUserId: String) {
// Switch from the web distinct ID (or a mobile anon ID)
// to your canonical user ID.
PostHogSDK.shared.identify(canonicalUserId)
// Set user properties, track signup event, etc.
}
func handleLogout() {
PostHogSDK.shared.reset()
}
}Android
import android.net.Uri
import com.posthog.PostHog
object DeepLinkIdentityManager {
// Deep Link Received
fun handleDeepLink(uri: Uri, isAuthenticatedOnMobile: Boolean) {
val webDistinctId = uri.getQueryParameter("ph_distinct_id") ?: return
if (isAuthenticatedOnMobile) {
// The mobile app already knows the current user.
// Alias the incoming web distinct ID to that user.
PostHog.alias(webDistinctId)
} else {
// Reuse the web distinct ID until login on mobile.
PostHog.identify(webDistinctId)
}
}
// Login/Signup
fun handleLogin(canonicalUserId: String) {
// Switch from the web distinct ID (or a mobile anon ID)
// to your canonical user ID.
PostHog.identify(canonicalUserId)
// Set user properties, track signup event, etc.
}
fun handleLogout() {
PostHog.reset()
}
}Further reading
- Identifying users docs (/docs/product-analytics/identify.md)
- How person processing works (/docs/how-posthog-works/ingestion-pipeline.md#2-person-processing)
- An introductory guide to identifying users in PostHog (/tutorials/identifying-users-guide.md)
Still have questions?
Ask PostHog AI
Was this page useful?
HelpfulCould be better
references/ios.md
AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt
iOS
The PostHog iOS SDK is a library that you can use to track events, identify users, record session replays, evaluate feature flags, run experiments, build surveys, and more.
This page shows you how to install the SDK and get started with it. If you've already installed the SDK, you can skip ahead to learn about using the features (/docs/libraries/ios/usage.md) and configuring the SDK (/docs/libraries/ios/configuration.md).
Installation
PostHog is available through CocoaPods or you can add it as a Swift Package Manager based dependency.
CocoaPods
Podfile
pod "PostHog", "~> 3.59.3"Swift Package Manager
Add PostHog as a dependency in your Xcode project "Package Dependencies" and select the project target for your app, as appropriate.
For a Swift Package Manager based project, add PostHog as a dependency in your Package.swift file's Package dependencies section:
Package.swift
dependencies: [
.package(url: "https://github.com/PostHog/posthog-ios.git", from: "3.59.3")
],and then as a dependency for the Package target utilizing PostHog:
Package.swift
.target(
name: "myApp",
dependencies: [.product(name: "PostHog", package: "posthog-ios")]),Configuration
Configuration is done through the PostHogConfig object. Here's a basic configuration example to get you started.
You can find more advanced configuration options in the configuration page (/docs/libraries/ios/configuration.md).
UIKit
Swift
import Foundation
import PostHog
import UIKit
class AppDelegate: NSObject, UIApplicationDelegate {
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
let POSTHOG_PROJECT_TOKEN = "<ph_project_token>"
// usually 'https://us.i.posthog.com' or 'https://eu.i.posthog.com'
let POSTHOG_HOST = "https://us.i.posthog.com"
let config = PostHogConfig(projectToken: POSTHOG_PROJECT_TOKEN, host: POSTHOG_HOST)
PostHogSDK.shared.setup(config)
return true
}
}SwiftUI
Swift
import SwiftUI
import PostHog
@main
struct YourGreatApp: App {
// Add PostHog to your app's initializer.
// If using UIApplicationDelegateAdaptor, see the UIKit tab.
init() {
let POSTHOG_PROJECT_TOKEN = "<ph_project_token>"
// usually 'https://us.i.posthog.com' or 'https://eu.i.posthog.com'
let POSTHOG_HOST = "https://us.i.posthog.com"
let config = PostHogConfig(projectToken: POSTHOG_PROJECT_TOKEN, host: POSTHOG_HOST)
PostHogSDK.shared.setup(config)
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}Identifying users
Identifying users is required. Call
posthog.identify('your-user-id')after login to link events to a known user. This is what connects frontend event captures, session replays (/docs/session-replay.md), LLM traces (/docs/ai-engineering.md), and error tracking (/docs/error-tracking.md) to the same person — and lets backend events link back too.Use a stable ID from your auth system when possible, not an email or display name. Send those as person properties instead. If your app has no other stable key, email works as a fallback if they are unique. Never a shared literal like
"anonymous"or"user", which pools many people onto one person and corrupts their data. When no ID is available at all, skip the identify and retain the anonymous distinct ID that's automatically assigned.Call
posthog.reset()on logout, so the next person to use the browser doesn't inherit the last one's identity.See our guide on identifying users (/docs/getting-started/identify-users.md) for how to set this up.
Offline behavior
The PostHog iOS SDK will continue to capture events when the device is offline. The events are stored in a queue in the device's file storage and are flushed when the device is online.
- The queue has a maximum size defined by
maxQueueSizein the configuration. - When the queue is full, the oldest event is deleted first.
- The queue is flushed only when the device is online.
You can find the options for configuring the offline behavior in the configuration page (/docs/libraries/ios/configuration.md#all-configuration-options).
Using PostHog with application extensions
PostHog supports sharing analytics data between your main app and application extensions (such as widgets, app clips, share extensions, and custom keyboards) through App Groups. This ensures that users maintain the same identity across all parts of your app ecosystem.
By default, each iOS app target stores its data in its own sandboxed directory. This means that if a user interacts with your main app and then uses a widget or extension, PostHog would treat them as two different anonymous users. This can lead to:
- Inflated user counts in your analytics
- Fragmented user journeys
- Difficulty tracking feature adoption across your app ecosystem
Learn more about setting up app groups (/docs/libraries/ios/configuration.md#setting-up-app-groups).
Method swizzling
The PostHog iOS SDK uses method swizzling to intercept and modify method calls at runtime to provide advanced features like screen view tracking, element interactions, session replay, surveys, and more.
Method swizzling is particularly important for accurate session metrics tracking. When disabled, the SDK cannot capture optimal session metrics.
You can learn more about configuring method swizzling in the configuration page (/docs/libraries/ios/configuration.md#method-swizzling).
Push notifications
The iOS SDK can register a device for Workflows (/docs/workflows.md) push notifications and capture when a user opens one. For setup, including automatic and manual registration, capturing opens, and identity verification, see Push notifications (/docs/workflows/push-notifications.md).
Next steps
Now that you've installed the SDK, explore the configuration and usage options:
- Learn about using all of the features of PostHog with iOS SDK (/docs/libraries/ios/usage.md)
- Learn about configuration options for the iOS SDK (/docs/libraries/ios/configuration.md)
Still have questions?
Ask PostHog AI
Was this page useful?
HelpfulCould be better
references/laravel.md
AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt
Laravel
PostHog integrates with Laravel through the PostHog PHP SDK (/docs/libraries/php.md). This page covers Laravel-specific setup. For SDK features such as event capture, identifying users, feature flags, group analytics, and configuration options, see the PHP SDK docs (/docs/libraries/php.md).
Installation
Install the PHP SDK as described in the PHP installation guide (/docs/libraries/php.md#installation), then add your project token and host to .env:
.env
POSTHOG_API_KEY=<ph_project_token>
POSTHOG_HOST=https://us.i.posthog.comAdd PostHog to Laravel's services config:
config/services.php
'posthog' => [
'api_key' => env('POSTHOG_API_KEY'),
'host' => env('POSTHOG_HOST', 'https://us.i.posthog.com'),
],Initialize PostHog in the boot method of app/Providers/AppServiceProvider.php:
app/Providers/AppServiceProvider.php
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use PostHog\PostHog;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
if (! config('services.posthog.api_key')) {
return;
}
PostHog::init(
config('services.posthog.api_key'),
[
'host' => config('services.posthog.host'),
]
);
}
}Request context middleware
Client SDKs such as PostHog JS (/docs/libraries/js.md) can send tracing headers to your Laravel backend. Configure tracing_headers (/docs/libraries/js/config.md#tracing-headers) for your Laravel backend hostname so browser requests include the session and distinct ID headers.
The PHP SDK can read X-PostHog-Distinct-Id and X-PostHog-Session-Id headers and apply them to events captured during the request. Tracing headers are client-controlled analytics context, not authentication or authorization. For security-sensitive server-side events or decisions, pass an authenticated distinctId explicitly, such as auth()->id(). For the lower-level context APIs, see the PHP request context docs (/docs/libraries/php.md#request-context).
Add middleware like this:
app/Http/Middleware/PostHogRequestContext.php
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use PostHog\PostHog;
use Symfony\Component\HttpFoundation\Response;
final class PostHogRequestContext
{
public function handle(Request $request, Closure $next): Response
{
if (! config('services.posthog.api_key')) {
return $next($request);
}
$context = PostHog::contextFromHeaders($request->headers->all());
$context['properties'] = array_merge(
$context['properties'] ?? [],
array_filter([
'$current_url' => $request->fullUrl(),
'$request_method' => $request->method(),
'$request_path' => $request->getPathInfo(),
'$user_agent' => $request->userAgent(),
'$ip' => $request->ip(),
], static fn ($value): bool => $value !== null && $value !== '')
);
return PostHog::withContext(
$context,
static fn (): Response => $next($request),
['fresh' => true]
);
}
}Register this middleware using your Laravel version's normal middleware registration.
Error tracking in Laravel
The PHP SDK supports error tracking (/docs/libraries/php.md#error-tracking), but Laravel handles most request exceptions before they become uncaught PHP exceptions. Capture Laravel-reported exceptions explicitly.
In Laravel 11 and later, add a report callback in bootstrap/app.php:
bootstrap/app.php
use Illuminate\Foundation\Configuration\Exceptions;
use PostHog\PostHog;
use Throwable;
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->report(function (Throwable $e): void {
if (! config('services.posthog.api_key')) {
return;
}
PostHog::captureException(
$e,
auth()->id() !== null ? (string) auth()->id() : null,
[
'$current_url' => request()->fullUrl(),
'$request_method' => request()->method(),
]
);
});
})For older Laravel versions, call PostHog::captureException() from your exception handler's report method.
Long-running processes
In normal PHP request lifecycles, queued events flush when the client is destroyed. In long-running Laravel processes such as queue workers, Horizon, or Octane, call PostHog::flush() after capturing important events or at the end of a job/request.
If you prefer immediate delivery in queue workers, configure the PHP SDK with batch_size set to 1 for those workers:
PHP
PostHog::init(
'<ph_project_token>',
[
'host' => config('services.posthog.host'),
'batch_size' => 1,
]
);Next steps
See the PHP SDK docs (/docs/libraries/php.md) for usage examples and the full API reference.
Still have questions?
Ask PostHog AI
Was this page useful?
HelpfulCould be better
references/next-js.md
AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt
Next.js
PostHog makes it easy to get data about traffic and usage of your Next.js app. Integrating PostHog into your site enables analytics about user behavior, custom events capture, session recordings, feature flags, and more.
This guide walks you through integrating PostHog into your Next.js app using the React (/docs/libraries/react.md) and the Node.js (/docs/libraries/node.md) SDKs.
You can see a working example of this integration in our Next.js demo app.
Next.js has both client and server-side rendering, as well as pages and app routers. We'll cover all of these options in this guide.
Try
@posthog/next(pre-release): A simplified Next.js integration with synchronized client/server identity, server-side flag bootstrapping, and a built-in API proxy. Read the setup guide → (/docs/libraries/next-js/posthog-next.md)
Prerequisites
To follow this guide along, you need:
- A PostHog instance (either Cloud or self-hosted (/docs/self-host.md))
- A Next.js application
Beta: integration via LLM
Install PostHog for Next.js in seconds with our wizard by running this prompt with LLM coding agents (/blog/envoy-wizard-llm-agent.md) like Cursor and Bolt, or by running it in your terminal.
npx @posthog/wizard
Learn more (/wizard.md)
Or, to integrate manually, continue with the rest of this guide.
Client-side setup
Install posthog-js using your package manager:
npm
npm install --save posthog-jsYarn
yarn add posthog-jspnpm
pnpm add posthog-jsBun
bun add posthog-jsIf your site sets a Content-Security-Policy, it needs to allow PostHog. This applies to the snippet and to package installs alike: the SDK lazy-loads extra bundles (session replay, surveys) from PostHog's CDN, and sends events to the ingestion host. PostHog serves from subdomains of
posthog.comthat change over time, so allow the wildcard:script-src 'self' https://*.posthog.com; connect-src 'self' https://*.posthog.com; worker-src 'self' blob: data:;
script-srccovers the snippet and the lazy-loaded bundles,connect-srccovers event ingestion and feature flags, andworker-srccovers session replay. The toolbar needs a few more (/docs/advanced/content-security-policy.md), or use a reverse proxy (/docs/advanced/proxy.md) so everything is first-party. Failing to do so causes silent failures wherecaptureandidentifycalls never send, so the integration looks complete while zero events arrive. Rememberconnect-srcfalls back todefault-src, sodefault-src 'self'blocks event delivery even when the script itself is bundled.
Add your environment variables to your .env.local file and to your hosting provider (e.g. Vercel, Netlify, AWS). You can find your project token in your project settings.
.env.local
NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN=<ph_project_token>
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.comThese values need to start with NEXT_PUBLIC_ to be accessible on the client-side.
Integration
Next.js provides the instrumentation-client.ts|js file for client-side setup. Add it to the root of your Next.js app (for both app and pages router) and initialize PostHog in it like this:
instrumentation-client.js
import posthog from 'posthog-js'
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
defaults: '2026-05-30'
});instrumentation-client.ts
import posthog from 'posthog-js'
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
defaults: '2026-05-30'
});Bootstrapping with instrumentation-client
When using instrumentation-client, the values you pass to posthog.init remain fixed for the entire session. This means bootstrapping only works if you evaluate flags before your app renders (for example, on the server).
If you need flag values after the app has rendered, you’ll want to:
- Evaluate the flag on the server and pass the value into your app, or
- Evaluate the flag in an earlier page/state, then store and re-use it when needed.
Both approaches avoid flicker and give you the same outcome as bootstrapping, as long as you use the same distinct_id across client and server.
See the bootstrapping guide (/docs/feature-flags/bootstrapping.md) for more information.
Identifying users
Identifying users is required. Call
posthog.identify('your-user-id')after login to link events to a known user. This is what connects frontend event captures, session replays (/docs/session-replay.md), LLM traces (/docs/ai-engineering.md), and error tracking (/docs/error-tracking.md) to the same person — and lets backend events link back too.Use a stable ID from your auth system when possible, not an email or display name. Send those as person properties instead. If your app has no other stable key, email works as a fallback if they are unique. Never a shared literal like
"anonymous"or"user", which pools many people onto one person and corrupts their data. When no ID is available at all, skip the identify and retain the anonymous distinct ID that's automatically assigned.Call
posthog.reset()on logout, so the next person to use the browser doesn't inherit the last one's identity.See our guide on identifying users (/docs/getting-started/identify-users.md) for how to set this up.
Linking client and server events
Next.js apps usually capture on both sides. To keep them on the same person, use the same distinct ID in both, and let the browser tell your server which one that is.
If your app calls your own backend, tracing_headers adds X-POSTHOG-DISTINCT-ID and X-POSTHOG-SESSION-ID to matching fetch and XMLHttpRequest requests. This lets server-side SDKs link backend events, errors, and LLM traces back to frontend sessions and replays. Use hostnames only, without protocols or paths.
JavaScript
posthog.init('<ph_project_token>', {
api_host: 'https://us.i.posthog.com',
// Optional: send PostHog session/user context to your backend
tracing_headers: ['api.example.com'],
})This works in local development too, but match on the hostname alone: use 'localhost', not 'localhost:3000'. Ports are never part of a hostname, so a value with one in it never matches anything. localhost and 127.0.0.1 are also different hostnames — use whichever your app actually calls.
Tracing headers help you attribute events across front and backend consistently. When this isn't available, use your server-side stable IDs to deduce the matching distinctId, and pass it in when capturing the event.
Set up a reverse proxy (recommended)
We recommend setting up a reverse proxy (/docs/advanced/proxy.md), so that events are less likely to be intercepted by tracking blockers.
We have our own managed reverse proxy service (/docs/advanced/proxy/managed-reverse-proxy.md), which is free for all PostHog Cloud users, routes through our infrastructure, and makes setting up your proxy easy.
If you don't want to use our managed service then there are several other options for creating a reverse proxy, including using Cloudflare (/docs/advanced/proxy/cloudflare.md), AWS Cloudfront (/docs/advanced/proxy/cloudfront.md), and Vercel (/docs/advanced/proxy/vercel.md).
Grouping products in one project (recommended)
If you have multiple customer-facing products (e.g. a marketing website + mobile app + web app), it's best to install PostHog on them all and group them in one project (/docs/settings/projects.md).
This makes it possible to track users across their entire journey (e.g. from visiting your marketing website to signing up for your product), or how they use your product across multiple platforms.
Add IPs to Firewall/WAF allowlists (recommended)
For certain features like heatmaps (/docs/toolbar/heatmaps.md), your Web Application Firewall (WAF) may be blocking PostHog's requests to your site. Add these IP addresses to your WAF allowlist or rules to let PostHog access your site.
EU: 3.75.65.221, 18.197.246.42, 3.120.223.253
US: 44.205.89.55, 52.4.194.122, 44.208.188.173
These are public, stable IPs used by PostHog services.
PostHog captures heatmap screenshots using Browserless, which has its own IP addresses. Browserless publishes the current list here.
An allowlist does not help when your app has a private address. For apps on an internal network, see internal and intranet applications (/docs/session-replay/troubleshooting.md#internal-and-intranet-applications).
Accessing PostHog
Once initialized in instrumentation-client.js|ts, import posthog from posthog-js anywhere and call the methods you need on the posthog object.
JavaScript
"use client";
import posthog from "posthog-js";
export default function Home() {
return (
<div>
<button onClick={() => posthog.capture("test_event")}>Click me for an event</button>
</div>
);
}Using React hooks
The React feature flag hooks (/docs/libraries/react.md#feature-flags) work automatically when PostHog is initialized via instrumentation-client.ts. The hooks use the initialized posthog-js singleton:
JavaScript
"use client";
import { useFeatureFlagEnabled } from "@posthog/react";
export default function FeatureComponent() {
const showNewFeature = useFeatureFlagEnabled("new-feature");
return showNewFeature ? <NewFeature /> : <OldFeature />;
}Usage
See the React SDK docs (/docs/libraries/react.md) for examples of how to use:
posthog-jsfunctions like custom event capture, user identification, and more. (/docs/libraries/react.md#using-posthog-js-functions)- Feature flags including variants and payloads. (/docs/libraries/react.md#feature-flags)
You can also read the full posthog-js documentation (/docs/libraries/js/usage.md) for all the usable functions.
Server-side analytics
Next.js enables you to both server-side render pages and add server-side functionality. To integrate PostHog into your Next.js app on the server-side, you can use the Node SDK (/docs/libraries/node.md).
First, install the posthog-node library:
npm
npm install posthog-node --saveYarn
yarn add posthog-nodepnpm
pnpm add posthog-nodeBun
bun add posthog-nodeRouter-specific instructions
App router
For the app router, we can initialize the posthog-node SDK once with a PostHogClient function, and import it into files.
This enables us to send events and fetch data from PostHog on the server – without making client-side requests.
JavaScript
// app/posthog.js
import { PostHog } from 'posthog-node'
export default function PostHogClient() {
const posthogClient = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN, {
host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
flushAt: 1,
flushInterval: 0
})
return posthogClient
}Note: Because server-side functions in Next.js can be short-lived, we set
flushAtto1andflushIntervalto0.
flushAtsets how many capture calls we should flush the queue (in one batch).flushIntervalsets how many milliseconds we should wait before flushing the queue. Setting them to the lowest number ensures events are sent immediately and not batched. We also need to callawait posthog.shutdown()once done.
To use this client, we import it into our pages and call it with the PostHogClient function:
JavaScript
import Link from 'next/link'
import PostHogClient from '../posthog'
export default async function About() {
const posthog = PostHogClient()
const flags = await posthog.getAllFlags(
'user_distinct_id' // replace with a user's distinct ID
);
await posthog.shutdown()
return (
<main>
<h1>About</h1>
<Link href="/">Go home</Link>
{ flags['main-cta'] &&
<Link href="http://posthog.com/">Go to PostHog</Link>
}
</main>
)
}Pages router
For the pages router, we can use the getServerSideProps function to access PostHog on the server-side, send events, evaluate feature flags, and more.
This looks like this:
JavaScript
// pages/posts/[id].js
import { useContext, useEffect, useState } from 'react'
import { getServerSession } from "next-auth/next"
import { authOptions } from '@/lib/auth'
import { PostHog } from 'posthog-node'
export default function Post({ post, flags }) {
const [ctaState, setCtaState] = useState()
useEffect(() => {
if (flags) {
setCtaState(flags['blog-cta'])
}
})
return (
<div>
<h1>{post.title}</h1>
<p>By: {post.author}</p>
<p>{post.content}</p>
{ctaState &&
<p><a href="/">Go to PostHog</a></p>
}
<button onClick={likePost}>Like</button>
</div>
)
}
export async function getServerSideProps(ctx) {
// Pass authOptions, or your session callbacks don't run.
const session = await getServerSession(ctx.req, ctx.res, authOptions)
let flags = null
if (session) {
const client = new PostHog(
process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN,
{
host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
}
)
// A stable ID from your auth system, not an email. See the note below.
const distinctId = session.user.id
flags = await client.getAllFlags(distinctId);
client.capture({
distinctId,
event: 'loaded blog article',
properties: {
$current_url: ctx.req.url,
},
});
await client.shutdown()
}
const { posts } = await import('../../blog.json')
const post = posts.find((post) => post.id.toString() === ctx.params.id)
return {
props: {
post,
flags
},
}
}Note: next-auth doesn't put a user ID on the session by default. Its session is
{ name, email, image }, sosession.user.idisundefineduntil you add it yourself with a session callback in yourauthOptions:JavaScript
// lib/auth.js export const authOptions = { callbacks: { session({ session, token, user }) { // JWT sessions (the default) carry the user ID in token.sub. // Database sessions get it from user.id instead. session.user.id = token?.sub ?? user.id return session }, }, }Capturing with an
undefineddistinct ID creates events that belong to nobody, so check that the ID arrives before relying on it.
Note: Make sure to always call
await client.shutdown()after sending events from the server-side. PostHog queues events into larger batches, and this call forces all batched events to be flushed immediately.
Server-side configuration
Next.js overrides the default fetch behavior on the server to introduce their own cache. PostHog ignores that cache by default, as this is Next.js's default behavior for any fetch call.
You can override that configuration when initializing PostHog, but make sure you understand the pros/cons of using Next.js's cache and that you might get cached results rather than the actual result our server would return. This is important for feature flags, for example.
TSX
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN, {
// ... your configuration
fetch_options: {
cache: 'force-cache', // Use Next.js cache
next_options: { // Passed to the `next` option for `fetch`
revalidate: 60, // Cache for 60 seconds
tags: ['posthog'], // Can be used with Next.js `revalidateTag` function
},
}
})Configuring a reverse proxy to PostHog
To improve the reliability of client-side tracking and make requests less likely to be intercepted by tracking blockers, you can setup a reverse proxy in Next.js. Read more about deploying a reverse proxy using Next.js rewrites (/docs/advanced/proxy/nextjs.md), Next.js middleware (/docs/advanced/proxy/nextjs-middleware.md), and Vercel rewrites (/docs/advanced/proxy/vercel.md).
Further reading
- How to set up Next.js analytics, feature flags, and more (/tutorials/nextjs-analytics.md)
- How to set up Next.js pages router analytics, feature flags, and more (/tutorials/nextjs-pages-analytics.md)
- How to set up Next.js A/B tests (/tutorials/nextjs-ab-tests.md)
Still have questions?
Ask PostHog AI
Was this page useful?
HelpfulCould be better
references/nuxt-js-3-6.md
AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt
Nuxt.js (v3.0 to v3.6)
PostHog makes it easy to get data about usage of your Nuxt.js app. Integrating PostHog into your app enables analytics about user behavior, custom events capture, session replays, feature flags, and more.
These docs are for Nuxt v3.0 to v3.6. You can see a working example of the Nuxt v3.0 integration in our Nuxt.js demo app
Setting up PostHog on the client side
- Install
posthog-jsusing your package manager:
npm
npm install --save posthog-jsYarn
yarn add posthog-jspnpm
pnpm add posthog-jsBun
bun add posthog-jsIf your site sets a Content-Security-Policy, it needs to allow PostHog. This applies to the snippet and to package installs alike: the SDK lazy-loads extra bundles (session replay, surveys) from PostHog's CDN, and sends events to the ingestion host. PostHog serves from subdomains of
posthog.comthat change over time, so allow the wildcard:script-src 'self' https://*.posthog.com; connect-src 'self' https://*.posthog.com; worker-src 'self' blob: data:;
script-srccovers the snippet and the lazy-loaded bundles,connect-srccovers event ingestion and feature flags, andworker-srccovers session replay. The toolbar needs a few more (/docs/advanced/content-security-policy.md), or use a reverse proxy (/docs/advanced/proxy.md) so everything is first-party. Failing to do so causes silent failures wherecaptureandidentifycalls never send, so the integration looks complete while zero events arrive. Rememberconnect-srcfalls back todefault-src, sodefault-src 'self'blocks event delivery even when the script itself is bundled.
- Store your PostHog key and host in environment variables rather than hard-coding them. Add them to a
.envfile (and to your hosting provider). You can find these in your project settings.
.env
NUXT_PUBLIC_POSTHOG_PROJECT_TOKEN=<ph_project_token>
NUXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.comThen reference them in your nuxt.config.js file:
nuxt.config.js
export default defineNuxtConfig({
runtimeConfig: {
public: {
posthogToken: process.env.NUXT_PUBLIC_POSTHOG_PROJECT_TOKEN || '<ph_project_token>',
posthogHost: process.env.NUXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com',
posthogDefaults: '2026-05-30',
},
}
})Keep your personal API key out of the client bundle
Anything shipped to the browser – the token you pass to posthog.init(), anything under Nuxt's runtimeConfig.public, or the @posthog/nuxt module's posthogConfig – ends up in your client-side JavaScript and is visible to anyone who visits your site. This is fine for your project token (<ph_project_token>), which is designed to be public.
Your personal API key (/docs/api.md#authentication) is different. It can grant full access to your PostHog account, so it must never reach the browser. If you need it – for example, for source map uploads (/docs/error-tracking/upload-source-maps/nuxt.md) or server-side local evaluation (/docs/feature-flags/local-evaluation.md) – read it from a server-only environment variable (or top-level runtimeConfig, never runtimeConfig.public) and only use it in server code.
Either way, prefer reading keys from environment variables rather than hard-coding them in nuxt.config, so you can keep them out of source control and use different values per environment.
- Create a new plugin by creating a new file
posthog.client.jsin your plugins directory.
plugins/posthog.client.js
import { defineNuxtPlugin, useRuntimeConfig } from '#imports'
import posthog from 'posthog-js'
export default defineNuxtPlugin(() => {
const runtimeConfig = useRuntimeConfig()
const posthogClient = posthog.init(runtimeConfig.public.posthogToken, {
api_host: runtimeConfig.public.posthogHost,
defaults: runtimeConfig.public.posthogDefaults,
loaded: (posthog) => {
if (import.meta.env.MODE === 'development') posthog.debug()
},
})
return {
provide: {
posthog: () => posthogClient,
},
}
})PostHog can then be accessed throughout your Nuxt.js using the provider accessor, for example:
Vue
<script setup>
const { $posthog } = useNuxtApp()
if ($posthog) {
const posthog = $posthog()
posthog.capture('<event_name>')
}
</script>See the JavaScript SDK docs (/docs/libraries/js/usage.md) for all usable functions, such as:
- Capture custom event capture, identify users, and more. (/docs/libraries/js/usage.md#capturing-events)
- Feature flags including variants and payloads. (/docs/libraries/js/usage.md#feature-flags)
If your app calls your own backend, tracing_headers adds X-POSTHOG-DISTINCT-ID and X-POSTHOG-SESSION-ID to matching fetch and XMLHttpRequest requests. This lets server-side SDKs link backend events, errors, and LLM traces back to frontend sessions and replays. Use hostnames only, without protocols or paths.
JavaScript
posthog.init('<ph_project_token>', {
api_host: 'https://us.i.posthog.com',
// Optional: send PostHog session/user context to your backend
tracing_headers: ['api.example.com'],
})This works in local development too, but match on the hostname alone: use 'localhost', not 'localhost:3000'. Ports are never part of a hostname, so a value with one in it never matches anything. localhost and 127.0.0.1 are also different hostnames — use whichever your app actually calls.
Tracing headers help you attribute events across front and backend consistently. When this isn't available, use your server-side stable IDs to deduce the matching distinctId, and pass it in when capturing the event.
Set up a reverse proxy (recommended)
We recommend setting up a reverse proxy (/docs/advanced/proxy.md), so that events are less likely to be intercepted by tracking blockers.
We have our own managed reverse proxy service (/docs/advanced/proxy/managed-reverse-proxy.md), which is free for all PostHog Cloud users, routes through our infrastructure, and makes setting up your proxy easy.
If you don't want to use our managed service then there are several other options for creating a reverse proxy, including using Cloudflare (/docs/advanced/proxy/cloudflare.md), AWS Cloudfront (/docs/advanced/proxy/cloudfront.md), and Vercel (/docs/advanced/proxy/vercel.md).
Grouping products in one project (recommended)
If you have multiple customer-facing products (e.g. a marketing website + mobile app + web app), it's best to install PostHog on them all and group them in one project (/docs/settings/projects.md).
This makes it possible to track users across their entire journey (e.g. from visiting your marketing website to signing up for your product), or how they use your product across multiple platforms.
Add IPs to Firewall/WAF allowlists (recommended)
For certain features like heatmaps (/docs/toolbar/heatmaps.md), your Web Application Firewall (WAF) may be blocking PostHog's requests to your site. Add these IP addresses to your WAF allowlist or rules to let PostHog access your site.
EU: 3.75.65.221, 18.197.246.42, 3.120.223.253
US: 44.205.89.55, 52.4.194.122, 44.208.188.173
These are public, stable IPs used by PostHog services.
PostHog captures heatmap screenshots using Browserless, which has its own IP addresses. Browserless publishes the current list here.
An allowlist does not help when your app has a private address. For apps on an internal network, see internal and intranet applications (/docs/session-replay/troubleshooting.md#internal-and-intranet-applications).
Setting up PostHog on the server side
Install posthog-node using your package manager:
npm
npm install posthog-node --saveYarn
yarn add posthog-nodepnpm
pnpm add posthog-nodeBun
bun add posthog-nodeAdd your PostHog API key and host to your nuxt.config.js file, reading them from environment variables. If you've already done this when adding PostHog to the client side, you can skip this step.
nuxt.config.js
export default defineNuxtConfig({
runtimeConfig: {
public: {
posthogToken: process.env.NUXT_PUBLIC_POSTHOG_PROJECT_TOKEN || '<ph_project_token>',
posthogHost: process.env.NUXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com',
posthogDefaults: '2026-05-30',
}
}
})Initialize the PostHog Node client where you'd like to use it on the server side. For example, in a server route:
server/api/example.js
const runtimeConfig = useRuntimeConfig()
const posthog = new PostHog(
runtimeConfig.public.posthogToken,
{
host: runtimeConfig.public.posthogHost,
}
);
posthog.capture({
event: 'api_call',
distinctId: distinctID,
properties: {
$current_url: url,
query: query
}
})
posthog.shutdown()
return {
message: "example response"Note: Make sure to always call
posthog.shutdown()after capturing events from the server-side. PostHog queues events into larger batches, and this call forces all batched events to be flushed immediately.
See the Node SDK docs (/docs/libraries/node.md) for all usable functions, such as:
- Capture custom event capture, identify users, and more. (/docs/libraries/node.md#capturing-events)
- Feature flags including variants and payloads. (/docs/libraries/node.md#feature-flags)
Next steps
For any technical questions for how to integrate specific PostHog features into Nuxt (such as analytics, feature flags, A/B testing, surveys, etc.), have a look at our JavaScript Web (/docs/libraries/js.md) and Node (/docs/libraries/node.md) SDK docs.
Alternatively, the following tutorials can help you get started:
- How to set up analytics in Nuxt (/tutorials/nuxt-analytics.md)
- How to set up feature flags in Nuxt (/tutorials/nuxt-feature-flags.md)
- How to set up A/B tests in Nuxt (/tutorials/nuxtjs-ab-tests.md)
- How to set up surveys in Nuxt (/tutorials/nuxt-surveys.md)
Still have questions?
Ask PostHog AI
Was this page useful?
HelpfulCould be better
references/nuxt-js.md
AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt
Nuxt.js
PostHog makes it easy to get data about usage of your Nuxt.js app. Integrating PostHog into your app enables analytics about user behavior, custom events capture, session replays, feature flags, and more.
This guide covers Nuxt v4.x and v3.7+. For these versions, we recommend using @posthog/nuxt module for client-side capture.
The @posthog/nuxt module provides:
- Automatic client-side PostHog initialization
- Auto-imported composables for PostHog and feature flags
- Automatic exception capture for error tracking
- Source map configuration and upload for error tracking
For server-side event capture beyond error tracking, use the posthog-node SDK directly.
Using an older version? See our docs for Nuxt 3.0-3.6 (/docs/libraries/nuxt-js-3-6.md) or Nuxt 2.x (/docs/libraries/nuxt-js-2.md).
Installation
Install the PostHog Nuxt module using your package manager:
npm
npm install @posthog/nuxtYarn
yarn add @posthog/nuxtpnpm
pnpm add @posthog/nuxtBun
bun add @posthog/nuxtIf your site sets a Content-Security-Policy, it needs to allow PostHog. This applies to the snippet and to package installs alike: the SDK lazy-loads extra bundles (session replay, surveys) from PostHog's CDN, and sends events to the ingestion host. PostHog serves from subdomains of
posthog.comthat change over time, so allow the wildcard:script-src 'self' https://*.posthog.com; connect-src 'self' https://*.posthog.com; worker-src 'self' blob: data:;
script-srccovers the snippet and the lazy-loaded bundles,connect-srccovers event ingestion and feature flags, andworker-srccovers session replay. The toolbar needs a few more (/docs/advanced/content-security-policy.md), or use a reverse proxy (/docs/advanced/proxy.md) so everything is first-party. Failing to do so causes silent failures wherecaptureandidentifycalls never send, so the integration looks complete while zero events arrive. Rememberconnect-srcfalls back todefault-src, sodefault-src 'self'blocks event delivery even when the script itself is bundled.
Identifying users
Identifying users is required. Call
posthog.identify('your-user-id')after login to link events to a known user. This is what connects frontend event captures, session replays (/docs/session-replay.md), LLM traces (/docs/ai-engineering.md), and error tracking (/docs/error-tracking.md) to the same person — and lets backend events link back too.Use a stable ID from your auth system when possible, not an email or display name. Send those as person properties instead. If your app has no other stable key, email works as a fallback if they are unique. Never a shared literal like
"anonymous"or"user", which pools many people onto one person and corrupts their data. When no ID is available at all, skip the identify and retain the anonymous distinct ID that's automatically assigned.Call
posthog.reset()on logout, so the next person to use the browser doesn't inherit the last one's identity.See our guide on identifying users (/docs/getting-started/identify-users.md) for how to set this up.
If your app calls your own backend, tracing_headers adds X-POSTHOG-DISTINCT-ID and X-POSTHOG-SESSION-ID to matching fetch and XMLHttpRequest requests. This lets server-side SDKs link backend events, errors, and LLM traces back to frontend sessions and replays. Use hostnames only, without protocols or paths.
JavaScript
posthog.init('<ph_project_token>', {
api_host: 'https://us.i.posthog.com',
// Optional: send PostHog session/user context to your backend
tracing_headers: ['api.example.com'],
})This works in local development too, but match on the hostname alone: use 'localhost', not 'localhost:3000'. Ports are never part of a hostname, so a value with one in it never matches anything. localhost and 127.0.0.1 are also different hostnames — use whichever your app actually calls.
Tracing headers help you attribute events across front and backend consistently. When this isn't available, use your server-side stable IDs to deduce the matching distinctId, and pass it in when capturing the event.
Configuration
Store your PostHog keys in environment variables rather than hard-coding them. Add them to a .env file (and to your hosting provider). You can find these values in your project settings.
.env
NUXT_PUBLIC_POSTHOG_KEY=<ph_project_token>
NUXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.comThen reference them when you add the module to your nuxt.config.ts file:
nuxt.config.ts
export default defineNuxtConfig({
modules: ['@posthog/nuxt'],
posthogConfig: {
publicKey: process.env.NUXT_PUBLIC_POSTHOG_KEY, // Find it in project settings https://app.posthog.com/settings/project
host: process.env.NUXT_PUBLIC_POSTHOG_HOST, // Optional: defaults to https://us.i.posthog.com. Use https://eu.i.posthog.com for EU region
clientConfig: {
// Optional: PostHog client configuration options
},
},
})Keep your personal API key out of the client bundle
Anything shipped to the browser – the token you pass to posthog.init(), anything under Nuxt's runtimeConfig.public, or the @posthog/nuxt module's posthogConfig – ends up in your client-side JavaScript and is visible to anyone who visits your site. This is fine for your project token (<ph_project_token>), which is designed to be public.
Your personal API key (/docs/api.md#authentication) is different. It can grant full access to your PostHog account, so it must never reach the browser. If you need it – for example, for source map uploads (/docs/error-tracking/upload-source-maps/nuxt.md) or server-side local evaluation (/docs/feature-flags/local-evaluation.md) – read it from a server-only environment variable (or top-level runtimeConfig, never runtimeConfig.public) and only use it in server code.
Either way, prefer reading keys from environment variables rather than hard-coding them in nuxt.config, so you can keep them out of source control and use different values per environment.
Usage on the client side
The module provides the usePostHog() composable which is auto-imported and available in all your Vue components:
app/pages/index.vue
<script setup>
const posthog = usePostHog()
// Capture a custom event
posthog?.capture('button_clicked', { button_name: 'signup' })
</script>Note:
usePostHog()returnsundefinedon the server side during SSR, so use optional chaining?.when calling methods.
Usage on the server side
The @posthog/nuxt module initializes a server-side client for error tracking only. For general event capture in Nitro routes, create your own posthog-node SDK client.
The @posthog/nuxt module makes your config available at runtimeConfig.public.posthog.
First, create a server utility to reuse the PostHog client across requests:
server/utils/posthog.ts
import { PostHog } from 'posthog-node'
let client: PostHog | null = null
export function useServerPostHog(): PostHog {
if (!client) {
const config = useRuntimeConfig()
client = new PostHog(config.public.posthog.publicKey, {
host: config.public.posthog.host,
})
}
return client
}Then use it in your server routes:
server/api/example.ts
export default defineEventHandler((event) => {
const posthog = useServerPostHog()
posthog.capture({
distinctId: 'user_123',
event: 'server_event',
})
return { success: true }
})Set up a reverse proxy (recommended)
We recommend setting up a reverse proxy (/docs/advanced/proxy.md), so that events are less likely to be intercepted by tracking blockers.
We have our own managed reverse proxy service (/docs/advanced/proxy/managed-reverse-proxy.md), which is free for all PostHog Cloud users, routes through our infrastructure, and makes setting up your proxy easy.
If you don't want to use our managed service then there are several other options for creating a reverse proxy, including using Cloudflare (/docs/advanced/proxy/cloudflare.md), AWS Cloudfront (/docs/advanced/proxy/cloudfront.md), and Vercel (/docs/advanced/proxy/vercel.md).
Grouping products in one project (recommended)
If you have multiple customer-facing products (e.g. a marketing website + mobile app + web app), it's best to install PostHog on them all and group them in one project (/docs/settings/projects.md).
This makes it possible to track users across their entire journey (e.g. from visiting your marketing website to signing up for your product), or how they use your product across multiple platforms.
Add IPs to Firewall/WAF allowlists (recommended)
For certain features like heatmaps (/docs/toolbar/heatmaps.md), your Web Application Firewall (WAF) may be blocking PostHog's requests to your site. Add these IP addresses to your WAF allowlist or rules to let PostHog access your site.
EU: 3.75.65.221, 18.197.246.42, 3.120.223.253
US: 44.205.89.55, 52.4.194.122, 44.208.188.173
These are public, stable IPs used by PostHog services.
PostHog captures heatmap screenshots using Browserless, which has its own IP addresses. Browserless publishes the current list here.
An allowlist does not help when your app has a private address. For apps on an internal network, see internal and intranet applications (/docs/session-replay/troubleshooting.md#internal-and-intranet-applications).
Feature flags
The module provides auto-imported composables for feature flags. All composables return reactive refs that automatically update when flags are loaded or changed.
Vue
<script setup>
const isEnabled = useFeatureFlagEnabled('new-feature')
// returns true, false, or undefined
</script>
<template>
<div v-if="isEnabled">Feature is enabled!</div>
</template>Vue
<script setup>
const variant = useFeatureFlagVariantKey('experiment')
// returns the variant string, true/false, or undefined
</script>
<template>
<div v-if="variant === 'control'">Control group</div>
<div v-else-if="variant === 'test'">Test group</div>
</template>Vue
<script setup>
const payload = useFeatureFlagPayload('config-flag')
// returns any JSON value or undefined
</script>
<template>
<div v-if="payload">Config: {{ payload.value }}</div>
</template>Error Tracking
For a detailed error tracking installation guide, including automatic exception capture and source map configuration, see the Nuxt error tracking installation docs (/docs/error-tracking/installation/nuxt-3-7.md).
Troubleshooting
TypeScript errors in posthog config: Remove the .nuxt directory and rebuild your project to regenerate config types.
PostHog not capturing events: Ensure you're using optional chaining (posthog?.capture()) since usePostHog() returns undefined during server-side rendering.
Next steps
For any technical questions for how to integrate specific PostHog features into Nuxt (such as analytics, feature flags, A/B testing, surveys, etc.), have a look at our JavaScript Web (/docs/libraries/js.md) and Node (/docs/libraries/node.md) SDK docs.
Alternatively, the following tutorials can help you get started:
- How to set up analytics in Nuxt (/tutorials/nuxt-analytics.md)
- How to set up feature flags in Nuxt (/tutorials/nuxt-feature-flags.md)
- How to set up A/B tests in Nuxt (/tutorials/nuxtjs-ab-tests.md)
- How to set up surveys in Nuxt (/tutorials/nuxt-surveys.md)
Still have questions?
Ask PostHog AI
Was this page useful?
HelpfulCould be better
references/php.md
AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt
PHP
This is an optional library you can install if you're working with PHP. It uses an internal queue to batch requests, flushes at the end of the request, and optionally does so in an async manner.
Installation
Install the package with Composer:
Terminal
composer require posthog/posthog-phpIn your app, set your project token before making any calls.
PHP
PostHog\PostHog::init("<ph_project_token>",
['host' => 'https://us.i.posthog.com']
);Note: As a rule of thumb, we do not recommend having API keys or tokens in plaintext. Setting them as environment variables is best. The PHP SDK reads
POSTHOG_API_KEYandPOSTHOG_HOSTwhen you omit the project token or host.
You can find your project token and instance address in the project settings page in PostHog.
Identifying users
Identifying users is required. Backend events need a
distinct_idthat matches the ID your frontend uses when callingposthog.identify(). Without this, backend events are orphaned — they can't be linked to frontend event captures, session replays (/docs/session-replay.md), LLM traces (/docs/ai-engineering.md), or error tracking (/docs/error-tracking.md).See our guide on identifying users (/docs/getting-started/identify-users.md) for how to set this up.
Capturing events
You can send custom events using capture:
PHP
PostHog::capture([
'distinctId' => 'distinct_id_of_the_user',
'event' => 'user_signed_up'
]);Tip: We recommend using a
[object] [verb]format for your event names, where[object]is the entity that the behavior relates to, and[verb]is the behavior itself. For example,project created,user signed up, orinvite sent.
Setting event properties
Optionally, you can include additional information with the event by including a properties (/docs/data/events.md#event-properties) object:
PHP
PostHog::capture([
'distinctId' => 'distinct_id_of_the_user',
'event' => 'user_signed_up',
'properties' => [
'login_type' => 'email',
'is_free_trial' => 'true'
]
]);Sending page views
If you're aiming for a backend-only implementation of PostHog and won't be capturing events from your frontend, you can send pageviews from your backend like so:
PHP
PostHog::capture([
'distinctId' => 'distinct_id_of_the_user',
'event' => '$pageview',
'properties' => [
'$current_url' => 'https://example.com'
]
]);Person profiles and properties
The PHP SDK captures identified events by default. These create person profiles (/docs/data/persons.md). To set person properties (/docs/product-analytics/person-properties.md), call identify with the user's distinct ID and properties:
PHP
PostHog::identify([
'distinctId' => 'distinct_id',
'properties' => [
'email' => 'max@example.com',
'name' => 'Max Hedgehog',
],
]);You can also include person properties when capturing an event:
PHP
PostHog::capture([
'distinctId' => 'distinct_id',
'event' => 'event_name',
'properties' => [
'$set' => [
'name' => 'Max Hedgehog'
],
'$set_once' => [
'initial_url' => '/blog'
]
]
]);For more details on the difference between $set and $set_once, see our person properties docs (/docs/product-analytics/person-properties.md#what-is-the-difference-between-set-and-set_once).
To capture anonymous events (/docs/data/anonymous-vs-identified-events.md) without person profiles, set the event's $process_person_profile property to false:
PHP
PostHog::capture([
'distinctId' => 'distinct_id',
'event' => 'event_name',
'properties' => [
'$process_person_profile' => false
]
]);Alias
Sometimes, you want to assign multiple distinct IDs to a single user. This is helpful when your primary distinct ID is inaccessible. For example, if a distinct ID used on the frontend is not available in your backend.
In this case, you can use alias to assign another distinct ID to the same user.
PHP
PostHog::alias([
'distinctId' => 'distinct_id',
'alias' => 'alias_id'
]);We strongly recommend reading our docs on alias (/docs/product-analytics/identify.md#alias-assigning-multiple-distinct-ids-to-the-same-user) to best understand how to correctly use this method.
Feature flags
PostHog's feature flags (/docs/feature-flags.md) enable you to safely deploy and roll back new features as well as target specific users and groups with them.
There are two steps to implement feature flags in PHP:
Step 1: Evaluate flags once
Call PostHog::evaluateFlags() once for the user, then read values from the returned snapshot.
Boolean feature flags
PHP
$flags = PostHog::evaluateFlags('distinct_id_of_your_user');
if ($flags->isEnabled('flag-key')) {
// Do something differently for this user
// Optional: fetch the payload
$matchedFlagPayload = $flags->getFlagPayload('flag-key');
}Multivariate feature flags
PHP
$flags = PostHog::evaluateFlags('distinct_id_of_your_user');
$enabledVariant = $flags->getFlag('flag-key');
if ($enabledVariant === 'variant-key') { // replace 'variant-key' with the key of your variant
// Do something differently for this user
// Optional: fetch the payload
$matchedFlagPayload = $flags->getFlagPayload('flag-key');
}$flags->getFlag() returns the variant string for multivariate flags, true for enabled boolean flags, false for disabled flags, and null when the flag wasn't returned by the evaluation.
You can also call $flags->getKeys() to list the evaluated flag keys, or $flags->getEventProperties() to get the $feature/<flag-key> and $active_feature_flags properties that would be attached to a captured event.
Note:
PostHog::isFeatureEnabled(),PostHog::getFeatureFlag(),PostHog::getFeatureFlagPayload(), andcapture(['send_feature_flags' => true])still work during the migration period, but they're deprecated. PreferevaluateFlags()for new code.
Step 2: Include feature flag information when capturing events
If you want use your feature flag to breakdown or filter events in your insights (/docs/product-analytics/insights.md), you'll need to include feature flag information in those events. This ensures that the feature flag value is attributed correctly to the event.
Note: This step is only required for events captured using our server-side SDKs or API (/docs/api.md).
There are two methods you can use to include feature flag information in your events:
Method 1: Pass the evaluated flags snapshot to capture()
Pass the same flags object that you used for branching. This attaches the exact flag values from that evaluation and doesn't make another /flags request.
PHP
$flags = PostHog::evaluateFlags('distinct_id_of_your_user');
if ($flags->isEnabled('flag-key')) {
// Do something differently for this user
}
PostHog::capture([
'distinctId' => 'distinct_id_of_your_user',
'event' => 'event_name',
'flags' => $flags,
]);By default, this attaches every flag in the snapshot using $feature/<flag-key> properties and $active_feature_flags.
To reduce event property bloat, pass a filtered snapshot:
PHP
// Attach only flags accessed with isEnabled() or getFlag() before this call
PostHog::capture([
'distinctId' => 'distinct_id_of_your_user',
'event' => 'event_name',
'flags' => $flags->onlyAccessed(),
]);
// Attach only specific flags
PostHog::capture([
'distinctId' => 'distinct_id_of_your_user',
'event' => 'event_name',
'flags' => $flags->only(['checkout-flow', 'new-dashboard']),
]);onlyAccessed() is order-dependent. If you call it before accessing any flags with isEnabled() or getFlag(), no feature flag properties are attached.
Method 2: Include the $feature/feature_flag_name property manually
In the event properties, include $feature/feature_flag_name: variant_key:
PHP
PostHog::capture([
'distinctId' => 'distinct_id_of_your_user',
'event' => 'event_name',
'properties' => [
// Replace feature-flag-key with your flag key and 'variant-key' with the key of your variant
'$feature/feature-flag-key' => 'variant-key',
],
]);Evaluating only specific flags
By default, evaluateFlags() evaluates every flag for the user. If you only need a few flags, pass flagKeys to request only those flags:
PHP
$flags = PostHog::evaluateFlags(
distinctId: 'distinct_id_of_your_user',
flagKeys: ['checkout-flow', 'new-dashboard'],
);Optional evaluation parameters
evaluateFlags() also accepts optional parameters for local evaluation and GeoIP behavior:
PHP
$flags = PostHog::evaluateFlags(
distinctId: 'distinct_id_of_your_user',
groups: ['company' => 'company_id_in_your_db'],
personProperties: ['plan' => 'pro'],
groupProperties: ['company' => ['employees' => 11]],
onlyEvaluateLocally: false, // Defaults to false. Set to true to avoid a remote fallback.
disableGeoip: false, // Defaults to false. Set to true to disable GeoIP enrichment during remote evaluation.
flagKeys: ['checkout-flow', 'new-dashboard'],
);Sending $feature_flag_called events
Capturing $feature_flag_called events enables PostHog to know when a flag was accessed by a user and provide analytics and insights (/docs/product-analytics/insights.md) on the flag. With evaluateFlags(), the SDK sends this event when you call $flags->isEnabled() or $flags->getFlag() for a flag.
The SDK deduplicates these events per (flag key, distinct_id) in a local cache. If you reinitialize the PostHog client, the cache resets and $feature_flag_called events may be sent again. PostHog handles duplicates, so duplicate $feature_flag_called events don't affect your analytics.
$flags->getFlagPayload() doesn't send $feature_flag_called events and doesn't count as an access for onlyAccessed().
Advanced: Overriding server properties
Sometimes, you may want to evaluate feature flags using person properties (/docs/product-analytics/person-properties.md), groups (/docs/product-analytics/group-analytics.md), or group properties that haven't been ingested yet, or were set incorrectly earlier.
You can provide properties to evaluate the flag with by using the person properties, groups, and group properties arguments. PostHog will then use these values to evaluate the flag, instead of any properties currently stored on your PostHog server.
For example:
PHP
$flags = PostHog::evaluateFlags(
distinctId: 'distinct_id_of_the_user',
groups: [
'your_group_type' => 'your_group_id',
'another_group_type' => 'your_group_id',
],
personProperties: ['property_name' => 'value'],
groupProperties: [
'your_group_type' => ['group_property_name' => 'value'],
'another_group_type' => ['group_property_name' => 'value'],
],
);
if ($flags->isEnabled('flag-key')) {
// Do something differently for this user
}Overriding GeoIP properties
By default, a user's GeoIP properties are set using the IP address they use to capture events on the frontend. You may want to override the these properties when evaluating feature flags. A common reason to do this is when you're not using PostHog on your frontend, so the user has no GeoIP properties.
You can override GeoIP properties by including them in the person_properties parameter when evaluating feature flags. This is useful when you're evaluating flags on your backend and want to use the client's location instead of your server's location.
The following GeoIP properties can be overridden:
$geoip_country_code$geoip_country_name$geoip_city_name$geoip_city_confidence$geoip_continent_code$geoip_continent_name$geoip_latitude$geoip_longitude$geoip_postal_code$geoip_subdivision_1_code$geoip_subdivision_1_name$geoip_subdivision_2_code$geoip_subdivision_2_name$geoip_subdivision_3_code$geoip_subdivision_3_name$geoip_time_zone
Simply include any of these properties in the person_properties parameter alongside your other person properties when calling feature flags.
Request timeout
You can configure the feature_flag_request_timeout_ms parameter when initializing your PostHog client to set a flag request timeout. This helps prevent your code from being blocked if PostHog's servers are too slow to respond. By default, this is set to 3 seconds.
PHP
PostHog::init("<ph_project_token>",
[
'host' => 'https://us.i.posthog.com',
'feature_flag_request_timeout_ms' => 3000, // Time in milliseconds. Defaults to 3000 (3 seconds).
]
);Local Evaluation
Evaluating feature flags requires making a request to PostHog for each flag. However, you can improve performance by evaluating flags locally. Instead of making a request for each flag, PostHog will periodically request and store feature flag definitions locally, enabling you to evaluate flags without making additional requests.
It is best practice to use local evaluation flags when possible, since this enables you to resolve flags faster and with fewer API calls.
To load feature flag definitions for local evaluation, initialize the SDK with your feature flags secure API key as personalAPIKey:
PHP
PostHog::init(
'<ph_project_token>',
['host' => 'https://us.i.posthog.com'],
personalAPIKey: 'your feature flags secure API key'
);For details on how to implement local evaluation, see our local evaluation guide (/docs/feature-flags/local-evaluation.md). For distributed or stateless PHP applications, use flag_definition_cache_provider to share flag definitions across workers or requests. See local evaluation in distributed environments (/docs/feature-flags/local-evaluation/distributed-environments?tab=PHP.md).
Experiments (A/B tests)
Since experiments (/docs/experiments/start-here.md) use feature flags, the code for running an experiment is very similar to the feature flags code:
PHP
$flags = PostHog::evaluateFlags('user_distinct_id');
$variant = $flags->getFlag('experiment-feature-flag-key');
if ($variant === 'variant-name') {
// Do something differently for this user
}It's also possible to run experiments without using feature flags (/docs/experiments/running-experiments-without-feature-flags.md).
Group analytics
Group analytics allows you to associate an event with a group (e.g. teams, organizations, etc.). This feature requires version 2.1.0 or above of the PHP SDK. Read the group analytics guide (/docs/product-analytics/group-analytics.md) for more information.
Note: This is a paid feature and is not available on the open-source or free cloud plan. Learn more on the pricing page (/pricing.md).
To create a group or update its properties, use groupIdentify:
PHP
PostHog::groupIdentify([
'groupType' => 'company',
'groupKey' => 'company_id_in_your_db',
'properties' => [
'name' => 'Awesome Inc.',
'employees' => 11,
],
// Optional distinct ID to associate this event with an existing person.
// Requires posthog-php 4.4.0 or later.
'distinctId' => 'user_distinct_id'
]);name is a special property which is used in the PostHog UI for the name of the group. If you don't specify a name property, the group ID is used instead.
If the optional distinctId parameter is not provided in the group identify call, it defaults to ${groupType}_${groupKey} (e.g., $company_company_id_in_your_db in the example above). This default behavior results in each group appearing as a separate person in PostHog. To avoid this, use a consistent distinctId, such as group_identifier, or a real user distinct ID.
Once a group is created, you can use the capture method and pass in the groups parameter to capture an event with group analytics.
PHP
PostHog::capture([
'distinctId' => 'user_distinct_id',
'event' => 'some_event',
'groups' => ['company' => 'company_id_in_your_db']
]);Request context
Use request context to apply a distinct ID, session ID, and common properties to all captures inside a callback. This is useful when connecting frontend activity to backend events, session replay, and error tracking.
PHP
PostHog::withContext([
'distinctId' => 'user_distinct_id',
'sessionId' => 'session_id_from_frontend',
'properties' => [
'$current_url' => 'https://example.com/account',
],
], function () {
PostHog::capture([
'event' => 'backend_event',
]);
});You can extract PostHog context from frontend tracing headers with contextFromHeaders(). If you're using PostHog JS (/docs/libraries/js.md) on the frontend, configure tracing_headers (/docs/libraries/js/config.md#tracing-headers) for your PHP backend hostname so browser requests include the session and distinct ID headers.
Then read the incoming headers on the server:
PHP
$context = PostHog::contextFromHeaders($_SERVER);
PostHog::withContext($context, function () {
PostHog::capture([
'event' => 'backend_event',
]);
});Call PostHog::getContext() to read the currently active context. Pass ['fresh' => true] as the third argument to withContext() if you don't want to inherit any existing context.
Tracing headers are client-controlled analytics context, not authentication or authorization. Pass an authenticated distinctId explicitly for security-sensitive server-side decisions.
Error tracking
The PHP SDK supports both manual exception capture and opt-in automatic error tracking.
To automatically capture uncaught exceptions, PHP errors, and fatal shutdown errors, enable error_tracking when initializing the client:
PHP
PostHog::init(
'<ph_project_token>',
[
'host' => 'https://us.i.posthog.com',
'error_tracking' => [
'enabled' => true,
],
],
);You can also call PostHog::captureException() directly for manual capture. When source files are readable at runtime, PostHog includes surrounding source lines for in-app stack frames automatically.
For the full setup guide, including context_provider, excluded exceptions, and verification steps, see the PHP error tracking installation docs (/docs/error-tracking/installation/php.md).
Config options
When calling PostHog::init, there are various configuration options you can set apart from the host. Pass them into your client initialisation like so:
PHP
PostHog::init(
'<ph_project_token>',
[
'host' => 'https://us.i.posthog.com',
'debug' => true,
'ssl' => false,
// all options go here
],
);All possible options below:
| Attribute | Description |
|---|---|
host Type: String Default: us.i.posthog.com |
URL of your PostHog instance. |
ssl Type: Boolean Default: true |
Whether to use SSL for API requests or not. If host includes http:// or https://, the SDK infers this option unless you set it explicitly. |
timeout Type: Integer Default: 10000 |
Request timeout in milliseconds. |
verify_batch_events_request Type: Boolean Default: true |
Whether to verify successful delivery of batch events (true, synchronous) or fire and forget (false, asynchronous) with the lib_curl consumer. |
feature_flag_request_timeout_ms Type: Integer Default: 3000 |
Request timeout for feature flags in milliseconds. |
flag_definition_cache_provider Type: PostHog\FlagDefinitionCacheProvider Default: null |
Provider for distributed local-evaluation flag definition caching. See local evaluation in distributed environments (/docs/feature-flags/local-evaluation/distributed-environments?tab=PHP.md). |
maximum_backoff_duration Type: Integer Default: 10000 |
Request retry backoff. Retries stop after this duration is hit. |
consumer Type: String Default: lib_curl |
One of socket, file, lib_curl, fork_curl, and noop. Determines what transport option to use for analytics capture. |
debug Type: Boolean Default: false |
Output debug logs or not. |
max_queue_size Type: Integer Default: 1000 |
Maximum number of events to queue before rejecting new events. Applies to queued consumers. |
batch_size Type: Integer Default: 100 |
Number of queued events to send in each batch. Applies to queued consumers. |
compress_request Type: Boolean/String Default: false |
Whether to gzip batch request payloads. |
error_handler Type: Callable Default: null |
Callback invoked for SDK transport errors. |
filename Type: String Default: sys_get_temp_dir() . '/posthog.log' |
File path used when consumer is set to file. |
error_tracking Type: Array Default: [] |
Enables automatic error tracking. See the options below or the PHP error tracking setup guide (/docs/error-tracking/installation/php.md). |
Error tracking options
| Attribute | Description |
|---|---|
enabled Type: Boolean Default: false |
Enables automatic error tracking handlers. Manual captureException works regardless. |
capture_errors Type: Boolean Default: true |
When enabled, captures PHP errors and fatal shutdown errors in addition to uncaught exceptions. |
excluded_exceptions Type: Array of class strings Default: [] |
Throwable classes to skip during automatic capture. |
max_frames Type: Integer Default: 20 |
Maximum number of stack frames included in $exception_list. |
context_provider Type: Callable or null Default: null |
Callback that returns distinctId and extra event properties for automatic captures. |
Flushing and shutting down
Call PostHog::flush() to send queued events without closing resources. When a script or long-running worker stops, call PostHog::shutdown() instead; it flushes queued events and releases resources held by providers such as flag_definition_cache_provider.
PHP
PostHog::shutdown();Debug mode
PHP
PostHog::init(
'<ph_project_token>',
[
'host' => 'https://us.i.posthog.com',
'debug' => true,
],
);Thank you
This library is largely based on the analytics-php package.
Still have questions?
Ask PostHog AI
Was this page useful?
HelpfulCould be better
references/posthog-python.md
AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt
PostHog Python SDK
SDK Version: 7.60.0
Integrate PostHog into any python application.
Categories
- Initialization
- Identification
- Capture
- Error Tracking
- Feature flags
- Tracing
- Contexts
- Events
- Client management
PostHog
This is the SDK reference for the PostHog Python SDK. You can learn more about example usage in the Python SDK documentation (/docs/libraries/python). You can also follow Flask (/docs/libraries/flask) and Django (/docs/libraries/django) guides to integrate PostHog into your project. For long-running applications, create one client during application startup and reuse it for the lifetime of the process. This keeps background queues predictable and makes shutdown flushing straightforward. Multiple clients are still supported for intentional multi-project or multi-host setups.
Initialization methods
Client()
Release Tag: public
Initialize a new PostHog client instance.
Parameters
project_api_key?(str) - PostHog project API key/token.host(any) - PostHog host. Defaults to the US ingestion endpoint when not set. App hosts such ashttps://us.posthog.comare mapped to the corresponding ingestion host.debug(bool) - Enable verbose SDK logging and re-raise errors from public API methods.max_queue_size(int) - Maximum number of events buffered before upload.send(bool) - If False, queueing succeeds but events are not sent.on_error(any) - Optional callback invoked by background consumers when an upload fails. Keep it short and non-blocking. Calling lifecycle methods directly is safe and deferred, but do not start another thread or task that callsflush(),join(), orshutdown()and then wait for it from the callback.flush_at(int) - Number of queued events that triggers a batch upload.flush_interval(float) - Maximum seconds a background consumer waits before flushing a partial batch.gzip(bool) - Whether to gzip event upload payloads.max_retries(int) - Number of upload retries. Values below 0 are treated as 0.sync_mode(bool) - If True, send each event synchronously instead of using background worker threads. This blocks the calling thread; in asyncio applications such as FastAPI, useAsyncPosthoginstead.timeout(int) - HTTP request timeout in seconds for event uploads.thread(int) - Number of background consumer threads.poll_interval(int) - Seconds between local feature flag definition refreshes.personal_api_key(any) - Deprecated alias forsecret_key. Still honored for backwards compatibility; prefersecret_key, which also accepts a Project Secret API Key.disabled(bool) - If True, disable captures and API requests. Useful in tests.disable_geoip(bool) - Whether to disable server-side GeoIP enrichment. Defaults to True.is_server(bool) - Whether events are emitted from a server-side runtime. Defaults to True; set to False when using the SDK as a client/CLI so the device OS is attributed to the person normally.historical_migration(bool) - Mark events as historical migration imports.feature_flags_request_timeout_seconds(int) - Timeout in seconds for feature flag and remote config requests.feature_flags_request_max_retries(int) - Number of retries for feature flag requests after network, transport, or timeout failures. Defaults to 1. Set to 0 to disable retries.super_properties(any) - Properties merged into every captured event.enable_exception_autocapture(bool) - Automatically capture uncaught exceptions.log_captured_exceptions(bool) - Also log exceptions captured by error tracking.project_root(any) - Root path used to determine in-app stack frames for captured exceptions. Defaults to the current working directory.privacy_mode(bool) - For AI observability, capture usage metadata without prompt inputs or outputs.before_send(any) - Optional callback that can modify or drop events before upload. ReturnNoneto drop an event.flag_fallback_cache_url(any) - Optional feature flag fallback cache URL, such asmemory://local/?ttl=300&size=10000or a Redis URL.enable_local_evaluation(bool) - Whether to poll feature flag definitions for local evaluation when a personal API key is configured.flag_definition_cache_provider?(FlagDefinitionCacheProvider) - Optional external cache provider for sharing feature flag definitions across workers.capture_exception_code_variables(bool) - Capture local variable values on exception stack frames.code_variables_mask_patterns(any) - Variable-name patterns to mask when capturing code variables.code_variables_ignore_patterns(any) - Variable-name patterns to omit when capturing code variables.code_variables_mask_url_credentials(any) - Scrub credentials embedded in URLs/DSNs (e.g.user:pass@host) from captured code variables, regardless of the surrounding variable name. Defaults to True.code_variables_detect_secrets(any) - Last-resort entropy-based detection that redacts high-entropy secret-looking values (API keys, tokens, strong passwords) sitting in innocuously-named variables, after the name and URL checks. Skips structured ids (UUIDs, ObjectIds, hashes). Defaults to True.in_app_modules(UnionType[list[str], any]) - Module/package prefixes treated as in-app frames in captured exceptions.enable_exception_autocapture_rate_limiting(bool) - Rate limit autocaptured exceptions client-side with a token bucket per exception type. Disabled by default.exception_autocapture_bucket_size(int) - Maximum burst of autocaptured exceptions allowed per exception type (token bucket size, clamped to 0-100).exception_autocapture_refill_rate(int) - Tokens restored per refill interval for each exception type's bucket.exception_autocapture_refill_interval_seconds(int) - Seconds between token refills for autocaptured exception rate limiting.capture_mode(CaptureMode) - Capture wire protocol to use. Defaults toCaptureMode.V0(legacy/batch/). SetCaptureMode.V1(or pass the string"v1") to opt into/i/v1/analytics/events. When omitted, thePOSTHOG_CAPTURE_MODEenv var is consulted, thenV0.capture_compression(CaptureCompression) - Request-body compression for capture-v1 uploads (ignored in V0, which usesgzip).CaptureCompression.GZIPorDEFLATE(or the strings"gzip"/"deflate"). When omitted, thePOSTHOG_CAPTURE_COMPRESSIONenv var is consulted, then the legacygzipflag, then no compression.secret_key(any) - A Personal API Key or Project Secret API Key, used to authenticate local feature flag evaluation, remote config payloads, and decrypted flag payloads. Example:: posthog.Client(project_api_key, secret_key="phx_...")metrics?(dict)enable_full_ai_capture(bool) - Route PostHog AI wrapper events through the dedicated AI capture endpoint and capture full AI content: skips string truncation and passes media (base64/data URIs) through unredacted.privacy_modealways wins. Defaults to False.capture_trace_context(bool) - When OpenTelemetry is installed and a valid span is active at capture time, add its trace and span IDs as$trace_idand$span_idproperties to events captured withcapture()andcapture_ai(), so they can be correlated with backend traces. Explicit$trace_id/$span_idvalues passed inpropertieswin. Exception events (capture_exception) always attach these IDs regardless of this setting. Defaults to False._use_ai_lane(bool)_enable_multimodal_capture(bool)traces?(dict) - Config dict for distributed tracing:service_name,service_version,environment,resource_attributes,flush_interval(5 s),max_queue_size(2048),max_export_batch_size(512),max_live_spans(10000),max_span_age(3600 s),max_attributes_per_span(128),max_events_per_span(128),max_attribute_value_length(8192).before_span_sendis a callable, or a list run in order, that receives each finished span as a dict (trace_id,span_idandparent_span_idare read-only) and returns it, edited, orNoneto drop it; a hook that raises drops the span. Tracing is off until this is provided. Spans export on a background timer even withsync_mode; serverless handlers should callflush()before returning. Defaults to None.
Returns
None
Examples
from posthog import Posthog
posthog = Posthog('<ph_project_api_key>', host='<ph_app_host>')Identification methods
alias()
Release Tag: public
Create an alias between two distinct IDs.
Parameters
previous_id?(Number) - The previous distinct ID. Required - the call is dropped with a warning if it is missing or empty.distinct_id?(str) - The new distinct ID to alias to. Falls back to the context distinct ID; the call is dropped with a warning if neither is available.timestamp(datetime) - The timestamp of the event. UTC is preferred; non-UTC datetimes and parseable ISO timestamp strings are converted to UTC.uuid?(str) - A unique identifier for the event. If provided, it must be a valid UUID string or uuid.UUID instance; invalid values are ignored and replaced with a newly generated UUID.disable_geoip?(bool) - Whether to disable GeoIP for this event.
Returns
Optional[str]
Examples
posthog.alias(previous_id='distinct_id', distinct_id='alias_id')group_identify()
Release Tag: public
Identify a group and set its properties.
Parameters
group_type?(str) - The type of group (e.g., 'company', 'team'). Required - the call is dropped with a warning if it is missing or empty.group_key?(str) - The unique identifier for the group. Required - the call is dropped with a warning if it is missing or empty.properties?(dict[str, Any]) - A dictionary of properties to set on the group.timestamp(datetime) - The timestamp of the event. UTC is preferred; non-UTC datetimes and parseable ISO timestamp strings are converted to UTC.uuid(str) - A unique identifier for the event. If provided, it must be a valid UUID string or uuid.UUID instance; invalid values are ignored and replaced with a newly generated UUID.disable_geoip?(bool) - Whether to disable GeoIP for this event.distinct_id(Number) - The distinct ID of the user performing the action.
Returns
Optional[str]
Examples
posthog.group_identify('company', 'company_id_in_your_db', {
'name': 'Awesome Inc.',
'employees': 11
})set()
Release Tag: public
Set properties on a person profile.
Parameters
kwargs?(Unpack[OptionalSetArgs])
Returns
Optional[str]
Examples
# Set with distinct id
posthog.set(distinct_id='user123', properties={'name': 'Max Hedgehog'})set_once()
Release Tag: public
Set properties on a person profile only if they haven't been set before.
Parameters
kwargs?(Unpack[OptionalSetArgs])
Returns
Optional[str]
Examples
posthog.set_once(distinct_id='user123', properties={'initial_signup_date': '2024-01-01'})Capture methods
capture()
Release Tag: public
Captures an event manually. Learn about capture best practices
Parameters
event?(str) - The event name to capture.kwargs?(Unpack[OptionalCaptureArgs])
Returns
Optional[str]
Examples
Anonymous event
# Anonymous event
posthog.capture('some-anon-event')Context usage
# Context usage
from posthog import identify_context, new_context
with new_context():
identify_context('distinct_id_of_the_user')
posthog.capture('user_signed_up')
posthog.capture('user_logged_in')
posthog.capture('some-custom-action', distinct_id='distinct_id_of_the_user')Set event properties
# Set event properties
posthog.capture(
"user_signed_up",
distinct_id="distinct_id_of_the_user",
properties={
"login_type": "email",
"is_free_trial": "true"
}
)Page view event
# Page view event
posthog.capture('$pageview', distinct_id="distinct_id_of_the_user", properties={'$current_url': 'https://example.com'})capture_ai()
Release Tag: public
Capture an AI event on the dedicated AI capture endpoint. Beta: the signature is stable; operational limits (per-event size cap, batching, endpoint) may change without notice. Takes the same arguments and returns the same value as capture(): the event UUID, or None when the event was not admitted (disabled client, or dropped by before_send). The event is queued on an isolated AI lane with its own consumer pool and a higher per-event size cap, posting to the dedicated AI ingestion endpoint. The payload is sent as given — no redaction or truncation is applied here.
Parameters
event?(str)kwargs?(Unpack[OptionalCaptureArgs])
Returns
Optional[str]
Error Tracking methods
capture_exception()
Release Tag: public
Capture an exception for error tracking. When OpenTelemetry is installed and a valid span is active, its trace and span IDs are added as $trace_id and $span_id event properties.
Parameters
exception?(BaseException) - The exception to capture.kwargs?(Unpack[OptionalCaptureArgs])
Returns
Optional[str]
Examples
try:
# Some code that might fail
pass
except Exception as e:
posthog.capture_exception(e, 'user_distinct_id', properties=additional_properties)Feature flags methods
evaluate_flags()
Release Tag: public
Evaluate all feature flags for a user in a single call and return a :class:FeatureFlagEvaluations snapshot. Branch on .is_enabled() / .get_flag() and pass the same snapshot to :meth:capture via the flags option so events carry the exact flag values the code branched on. Prefer this over repeated get_feature_flag() calls and over capture(send_feature_flags=True) — it consolidates flag evaluation into a single /flags request per incoming request. Local evaluation is transparent: when the poller resolves a flag, the snapshot's $feature_flag_called events are tagged locally_evaluated=True and reason "Evaluated locally".
Parameters
distinct_id(Number) - The user's distinct ID. IfNone, falls back to the context distinct_id. If still unresolvable, returns an empty snapshot.groups?(Mapping[str, Union[str, int]]) - Mapping of group type to group key.person_properties?(dict[str, Any]) - Person properties to use for evaluation.group_properties?(dict[str, dict[str, Any]]) - Group properties keyed by group type.only_evaluate_locally(bool) - If True, never fall back to remote evaluation — flags that can't be evaluated locally are simply omitted from the snapshot.disable_geoip?(bool) - Whether to disable GeoIP lookup.flag_keys?(list[str]) - Optional list that scopes local evaluation, the underlying/flagsrequest, and the returned snapshot. When omitted orNone, all flags are evaluated. An empty list returns an empty snapshot without evaluating flags. A requested key absent from loaded local definitions is included in one remote fallback perevaluate_flagscall unlessonly_evaluate_locallyis True. If the server also does not know the key, it is omitted from the snapshot.device_id?(str) - Optional device ID override. If not provided, falls back to the context device_id (which may be set via tracing headers). Used by experience-continuity flags to match users across distinct_id changes.
Returns
FeatureFlagEvaluations
Examples
flags = posthog.evaluate_flags(
"user_123",
person_properties={"plan": "enterprise"},
)
if flags.is_enabled("new-dashboard"):
render_new_dashboard()
posthog.capture("page_viewed", distinct_id="user_123", flags=flags)feature_enabled()
Release Tag: public
Check if a feature flag is enabled for a user.
Parameters
key?(str) - The feature flag key.distinct_id?(Number) - The distinct ID of the user.groups?(Mapping[str, Union[str, int]]) - A dictionary of group information.person_properties?(dict[str, Any]) - A dictionary of person properties.group_properties?(dict[str, dict[str, Any]]) - A dictionary of group properties.only_evaluate_locally(bool) - Whether to only evaluate locally.send_feature_flag_events(bool) - Whether to send feature flag events.disable_geoip?(bool) - Whether to disable GeoIP for this request.device_id?(str) - The device ID for this request.
Returns
Optional[bool]
Examples
is_my_flag_enabled = posthog.feature_enabled('flag-key', 'distinct_id_of_your_user')
if is_my_flag_enabled:
# Do something differently for this user
# Optional: fetch the payload
matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')feature_flag_definitions()
Release Tag: public
Return feature flag definitions loaded for local evaluation. Returns: The currently loaded feature flag definitions, or None before local evaluation has loaded definitions.
Returns
None
get_all_flags()
Release Tag: public
Get all feature flags for a user.
Parameters
distinct_id?(Number) - The distinct ID of the user.groups?(Mapping[str, Union[str, int]]) - A dictionary of group information.person_properties?(dict[str, Any]) - A dictionary of person properties.group_properties?(dict[str, dict[str, Any]]) - A dictionary of group properties.only_evaluate_locally(bool) - Whether to only evaluate locally.disable_geoip?(bool) - Whether to disable GeoIP for this request.flag_keys_to_evaluate?(list[str]) - A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.device_id?(str) - The device ID for this request.
Returns
Optional[dict[str, Union[bool, str]]]
Examples
posthog.get_all_flags('distinct_id_of_your_user')get_all_flags_and_payloads()
Release Tag: public
Get all feature flags and their payloads for a user.
Parameters
distinct_id?(Number) - The distinct ID of the user.groups?(Mapping[str, Union[str, int]]) - A dictionary of group information.person_properties?(dict[str, Any]) - A dictionary of person properties.group_properties?(dict[str, dict[str, Any]]) - A dictionary of group properties.only_evaluate_locally(bool) - Whether to only evaluate locally.disable_geoip?(bool) - Whether to disable GeoIP for this request.flag_keys_to_evaluate?(list[str]) - A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.device_id?(str) - The device ID for this request.
Returns
FlagsAndPayloads
Examples
posthog.get_all_flags_and_payloads('distinct_id_of_your_user')get_feature_flag()
Release Tag: public
Get multivariate feature flag value for a user.
Parameters
key?(str) - The feature flag key.distinct_id?(Number) - The distinct ID of the user.groups?(Mapping[str, Union[str, int]]) - A dictionary of group information.person_properties?(dict[str, Any]) - A dictionary of person properties.group_properties?(dict[str, dict[str, Any]]) - A dictionary of group properties.only_evaluate_locally(bool) - Whether to only evaluate locally.send_feature_flag_events(bool) - Whether to send feature flag events.disable_geoip?(bool) - Whether to disable GeoIP for this request.device_id?(str) - The device ID for this request.
Returns
Union[bool, str, any]
Examples
enabled_variant = posthog.get_feature_flag('flag-key', 'distinct_id_of_your_user')
if enabled_variant == 'variant-key': # replace 'variant-key' with the key of your variant
# Do something differently for this user
# Optional: fetch the payload
matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')get_feature_flag_evaluation_runtime()
Release Tag: public
Return where a locally loaded feature flag is meant to be evaluated.
Parameters
key?(str) - The feature flag key.
Returns
Optional[FeatureFlagEvaluationRuntime]
Examples
from posthog import FeatureFlagEvaluationRuntime
runtime = posthog.get_feature_flag_evaluation_runtime("my-flag")
if runtime is FeatureFlagEvaluationRuntime.SERVER:
...get_feature_flag_keys_by_evaluation_runtime()
Release Tag: public
Return the keys of locally loaded flags that a runtime can evaluate. A flag set to FeatureFlagEvaluationRuntime.ALL suits either runtime, so it is returned for CLIENT and for SERVER, and asking for ALL returns every loaded flag. Use this to decide which flags to hand to a browser when a backend serves flags to its own frontend.
Parameters
evaluation_runtime?(FeatureFlagEvaluationRuntime) - The runtime to match, as aFeatureFlagEvaluationRuntimeor its string value.
Returns
list[str]
Examples
from posthog import FeatureFlagEvaluationRuntime
client_keys = posthog.get_feature_flag_keys_by_evaluation_runtime(
FeatureFlagEvaluationRuntime.CLIENT
)get_feature_flag_payload()
Release Tag: public
Get the payload for a feature flag.
Parameters
key?(str) - The feature flag key.distinct_id?(Number) - The distinct ID of the user.match_value(bool) - The specific flag value to get payload for.groups?(Mapping[str, Union[str, int]]) - A dictionary of group information.person_properties?(dict[str, Any]) - A dictionary of person properties.group_properties?(dict[str, dict[str, Any]]) - A dictionary of group properties.only_evaluate_locally(bool) - Whether to only evaluate locally.send_feature_flag_events(bool) - Deprecated. Use get_feature_flag() instead if you need events.disable_geoip?(bool) - Whether to disable GeoIP for this request.device_id?(str) - The device ID for this request.
Returns
Optional[object]
Examples
is_my_flag_enabled = posthog.feature_enabled('flag-key', 'distinct_id_of_your_user')
if is_my_flag_enabled:
# Do something differently for this user
# Optional: fetch the payload
matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')get_feature_flags_and_payloads()
Release Tag: public
Get feature flags and payloads for a user.
Parameters
distinct_id?(Number) - The distinct ID of the user.groups?(Mapping[str, Union[str, int]]) - A dictionary of group information.person_properties?(dict[str, Any]) - A dictionary of person properties.group_properties?(dict[str, dict[str, Any]]) - A dictionary of group properties.disable_geoip?(bool) - Whether to disable GeoIP for this request.flag_keys_to_evaluate?(list[str]) - A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.device_id?(str) - The device ID for this request.
Returns
FlagsAndPayloads
Examples
result = posthog.get_feature_flags_and_payloads('<distinct_id>')get_feature_payloads()
Release Tag: public
Get feature flag payloads for a user, preserving valid serialized JSON.
Parameters
distinct_id?(Number) - The distinct ID of the user.groups?(Mapping[str, Union[str, int]]) - A dictionary of group information.person_properties?(dict[str, Any]) - A dictionary of person properties.group_properties?(dict[str, dict[str, Any]]) - A dictionary of group properties.disable_geoip?(bool) - Whether to disable GeoIP for this request.flag_keys_to_evaluate?(list[str]) - A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.device_id?(str) - The device ID for this request.
Returns
dict[str, Optional[str]]
Examples
payloads = posthog.get_feature_payloads('<distinct_id>')get_feature_variants()
Release Tag: public
Get feature flag variants for a user.
Parameters
distinct_id?(Number) - The distinct ID of the user.groups?(Mapping[str, Union[str, int]]) - A dictionary of group information.person_properties?(dict[str, Any]) - A dictionary of person properties.group_properties?(dict[str, dict[str, Any]]) - A dictionary of group properties.disable_geoip?(bool) - Whether to disable GeoIP for this request.flag_keys_to_evaluate?(list[str]) - A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.device_id?(str) - The device ID for this request.
Returns
dict[str, Union[bool, str]]
get_flags_decision()
Release Tag: public
Get feature flags decision.
Parameters
distinct_id(Number) - The distinct ID of the user.groups?(Mapping[str, Union[str, int]]) - A dictionary of group information.person_properties?(dict[str, Any]) - A dictionary of person properties.group_properties?(dict[str, dict[str, Any]]) - A dictionary of group properties.disable_geoip?(bool) - Whether to disable GeoIP for this request.flag_keys_to_evaluate?(list[str]) - A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.device_id?(str) - The device ID for this request.
Returns
FlagsResponse
Examples
decision = posthog.get_flags_decision('user123')get_remote_config_payload()
Release Tag: public
Get the payload for a remote config feature flag.
Parameters
key?(str) - The remote config feature flag key.
Returns
None
load_feature_flags()
Release Tag: public
Load feature flags for local evaluation.
Returns
None
Examples
posthog.load_feature_flags()Other methods
flush()
Release Tag: public
Force a flush from the internal queue to the server. Do not use directly, call shutdown() instead.
Parameters
timeout_seconds?(float) - Maximum seconds to wait for the queue to flush. Defaults to 10 seconds. PassNoneto wait indefinitely. Queued spans are sent at the same time, within the same
Returns
any
Examples
posthog.capture('event_name')
posthog.flush() # Ensures the event is sent immediatelyget_feature_flag_result()
Release Tag: public
Get a FeatureFlagResult object which contains the flag result and payload for a key by evaluating locally or remotely depending on whether local evaluation is enabled and the flag can be locally evaluated. This also captures the $feature_flag_called event unless send_feature_flag_events is False.
Parameters
key?(str) - The feature flag key.distinct_id?(Number) - The distinct ID of the user.groups?(Mapping[str, Union[str, int]]) - A dictionary of group information.person_properties?(dict[str, Any]) - A dictionary of person properties.group_properties?(dict[str, dict[str, Any]]) - A dictionary of group properties.only_evaluate_locally(bool) - Whether to only evaluate locally.send_feature_flag_events(bool) - Whether to send feature flag events.disable_geoip?(bool) - Whether to disable GeoIP for this request.device_id?(str) - The device ID for this request.
Returns
Optional[FeatureFlagResult]
Examples
flag_result = posthog.get_feature_flag_result('flag-key', 'distinct_id_of_your_user')
if flag_result and flag_result.get_value() == 'variant-key':
# Do something differently for this user
# Optional: fetch the payload
matched_flag_payload = flag_result.payloadjoin()
Release Tag: public
Attempt to process queued events and end the consumer threads. Do not use directly, call shutdown() instead. Failed or undrainable events may be dropped and reported through logging or on_error; returning does not guarantee server receipt. Lifecycle cleanup is attempted once, and cleanup failures are logged without retry.
Returns
any
Examples
posthog.join()shutdown()
Release Tag: public
Flush all messages and cleanly shutdown the client. Call this before the process ends in serverless environments to avoid data loss. Normally this method blocks until queued events have been attempted and cleanup finishes. Failed or undrainable events may be dropped and reported through logging or on_error; returning does not guarantee server receipt. Queued spans get one final flush of up to 30 s (plus a request already in flight); any it cannot send are discarded with a warning, as are spans still open. Lifecycle cleanup is attempted once, and cleanup failures are logged without retry. When called directly from an SDK callback such as on_error, shutdown is deferred to avoid blocking the worker that invoked the callback. If the callback must coordinate a blocking shutdown, have it signal an application-owned thread and return before that thread calls shutdown. Do not wait inside the callback for another thread or task that calls a lifecycle method.
Returns
any
Examples
posthog.shutdown()Tracing methods
get_active_span()
Release Tag: public
The span that is active in the current context, or None. Alpha. Only entering a span (with posthog.start_span(...) as span:) makes it active; a span started manually is not. Use it to propagate the trace to the next service: span.traceparent() is the header value.
Returns
Optional[Span]
Examples
span = posthog.get_active_span()
if span is not None:
headers["traceparent"] = span.traceparent()start_span()
Release Tag: public
Start a span for distributed tracing. Alpha. Returns a span handle. Use it as a context manager to make it the active span for the block and end it on exit (recording a raised exception on the way out); or call end() yourself for a span that cannot wrap a block. Spans started inside the block nest under it automatically. Always returns a usable handle, even when tracing is off, so calling code never branches.
Parameters
name?(str) - A low-cardinality operation name, e.g.GET /users/:id. Variable values belong in attributes, not the name.kind?(str) -internal(default),server,client,producerorconsumer.attributes?(Mapping[str, Any]) - Initial attributes.parent(Span) - A span handle, or an inbound W3Ctraceparentheader value to continue a remote trace. Defaults to the active span. A forked child starts with no active span; pass the parent span to continue a trace across a fork.tracestate?(str) - The inboundtracestateheader accompanying atraceparentstringparent; preserved and propagated.start_time(datetime) - Adatetimeor epoch seconds, to backdate the span.
Returns
Span
Examples
posthog = Posthog("<ph_project_api_key>", traces={"service_name": "checkout-api"})
with posthog.start_span("POST /checkout", parent=request.headers.get("traceparent")) as span:
span.set_attribute("plan", user.plan)
with posthog.start_span("db.query", kind="client"):
...
outgoing_headers = {"traceparent": span.traceparent()}Contexts methods
get_tags()
Release Tag: public
Get all tags from the current context. Returns: Dict of all tags in the current context.
Returns
dict[str, Any]
identify_context()
Release Tag: public
Identify the current context with a distinct ID.
Parameters
distinct_id?(str) - The distinct ID to associate with the current context and its children.
Returns
any
new_context()
Release Tag: public
Create a new context for managing shared state. Learn more about contexts (/docs/libraries/python#contexts).
Parameters
fresh(bool) - Whether to create a fresh context that doesn't inherit from parent.capture_exceptions?(bool) - Whether to automatically capture exceptions in this context. If omitted, defaults to this client's exception autocapture setting.
Returns
None
Examples
with client.new_context():
client.identify_context('<distinct_id>')
client.capture('event_name')scoped()
Release Tag: public
Decorator that creates a new context for the wrapped function using this client.
Parameters
fresh(bool) - Whether to create a fresh context that doesn't inherit from parent.capture_exceptions?(bool) - Whether to automatically capture exceptions in this context. If omitted, defaults to this client's exception autocapture setting.
Returns
None
set_context_device_id()
Release Tag: public
Set the device ID for the current context.
Parameters
device_id?(str) - The device ID to associate with the current context and its children.
Returns
any
set_context_session()
Release Tag: public
Set the session ID for the current context.
Parameters
session_id?(str) - The session ID to associate with the current context and its children.
Returns
any
tag()
Release Tag: public
Add a tag to the current context.
Parameters
name?(str) - The tag key.value?(Any) - The tag value.
Returns
any
PostHog Module Functions
Global functions available in the PostHog module
Identification methods
alias()
Release Tag: public
Associate user behaviour before and after they e.g. register, login, or perform some other identifying action.
Notes:
To marry up whatever a user does before they sign up or log in with what they do after you need to make an alias call. This will allow you to answer questions like "Which marketing channels leads to users churning after a month?" or "What do users do on our website before signing up?". Particularly useful for associating user behaviour before and after they e.g. register, login, or perform some other identifying action.
Parameters
previous_id?(Number) - The unique ID of the user beforedistinct_id?(str) - The current unique idtimestamp(datetime) - Optional timestamp for the event. UTC is preferred; non-UTC datetimes and parseable ISO timestamp strings are converted to UTC.uuid?(str) - Optional UUID for the eventdisable_geoip?(bool) - Whether to disable GeoIP lookup
Returns
Optional[str]
Examples
# Alias user
from posthog import alias
alias(previous_id='distinct_id', distinct_id='alias_id')group_identify()
Release Tag: public
Set properties on a group.
Parameters
group_type?(str) - Type of your group. Required - the call is dropped with a warning if it is missing or empty.group_key?(str) - Unique identifier of the group. Required - the call is dropped with a warning if it is missing or empty.properties?(dict[str, Any]) - Properties to set on the grouptimestamp(datetime) - Optional timestamp for the event. UTC is preferred; non-UTC datetimes and parseable ISO timestamp strings are converted to UTC.uuid?(str) - Optional UUID for the eventdisable_geoip?(bool) - Whether to disable GeoIP lookupdistinct_id(Number) - Optional distinct ID of the user performing the action
Returns
Optional[str]
Examples
# Group identify
from posthog import group_identify
group_identify('company', 'company_id_in_your_db', {
'name': 'Awesome Inc.',
'employees': 11
})identify_context()
Release Tag: public
Identify the current context with a distinct ID.
Parameters
distinct_id?(str) - The distinct ID to associate with the current context and its children
Returns
None
Examples
from posthog import identify_context
identify_context("user_123")set()
Release Tag: public
Set properties on a user record.
Notes:
This will overwrite previous people property values. Generally operates similar to capture, with distinct_id being an optional argument, defaulting to the current context's distinct ID. If there is no context-level distinct ID, and no override distinct_id is passed, this function will do nothing. Context tags are folded into $set properties, so tagging the current context and then calling set will cause those tags to be set on the user (unlike capture, which causes them to just be set on the event).
Parameters
kwargs?(Unpack[OptionalSetArgs])
Returns
Optional[str]
Examples
# Set person properties
from posthog import set
set(distinct_id='distinct_id', properties={'name': 'Max Hedgehog'})set_once()
Release Tag: public
Set properties on a user record, only if they do not yet exist.
Notes:
This will not overwrite previous people property values, unlike set. Otherwise, operates in an identical manner to set.
Parameters
kwargs?(Unpack[OptionalSetArgs])
Returns
Optional[str]
Examples
# Set property once
from posthog import set_once
set_once(distinct_id='distinct_id', properties={'initial_url': '/blog'})Events methods
capture()
Release Tag: public
Capture anything a user does within your system.
Notes:
Capture allows you to capture anything a user does within your system, which you can later use in PostHog to find patterns in usage, work out which features to improve or where people are giving up. A capture call requires an event name to specify the event. We recommend using [verb] [noun], like movie played or movie updated to easily identify what your events mean later on. Capture takes a number of optional arguments, which are defined by the OptionalCaptureArgs type.
Parameters
event?(str) - The event name to specify the event **kwargs: Optional arguments including:kwargs?(Unpack[OptionalCaptureArgs])
Returns
Optional[str]
Examples
Context and capture usage
# Context and capture usage
from posthog import new_context, identify_context, tag_context, capture
# Enter a new context (e.g. a request/response cycle, an instance of a background job, etc)
with new_context():
# Associate this context with some user, by distinct_id
identify_context('some user')
# Capture an event, associated with the context-level distinct ID ('some user')
capture('movie started')
# Capture an event associated with some other user (overriding the context-level distinct ID)
capture('movie joined', distinct_id='some-other-user')
# Capture an event with some properties
capture('movie played', properties={'movie_id': '123', 'category': 'romcom'})
# Capture an event with some properties
capture('purchase', properties={'product_id': '123', 'category': 'romcom'})
# Capture an event with some associated group
capture('purchase', groups={'company': 'id:5'})
# Adding a tag to the current context will cause it to appear on all subsequent events
tag_context('some-tag', 'some-value')
capture('another-event') # Will be captured with `'some-tag': 'some-value'` in the properties dictSet event properties
# Set event properties
from posthog import capture
capture(
"user_signed_up",
distinct_id="distinct_id_of_the_user",
properties={
"login_type": "email",
"is_free_trial": "true"
}
)capture_ai()
Release Tag: public
Capture an AI event on the dedicated AI capture endpoint. Beta: the signature is stable; operational limits (per-event size cap, batching, endpoint) may change without notice. Takes the same arguments and returns the same value as capture(): the event UUID, or None when the event was not admitted (disabled client, or dropped by before_send). The event is delivered on an isolated queue with its own consumer pool and a higher per-event size cap, posting to the dedicated AI ingestion endpoint. The payload is sent as given — no redaction or truncation is applied here.
Parameters
event?(str) - The event name, normally one of the$ai_*event names. **kwargs: Same optional arguments ascapture().kwargs?(Unpack[OptionalCaptureArgs])
Returns
Optional[str]
Examples
from posthog import capture_ai
uuid = capture_ai(
"$ai_generation",
distinct_id="user_123",
properties={"$ai_model": "gpt-5"},
)capture_exception()
Release Tag: public
Capture exceptions that happen in your code.
Notes:
Capture exception is idempotent - if it is called twice with the same exception instance, only a occurrence will be tracked in posthog. This is because, generally, contexts will cause exceptions to be captured automatically. However, to ensure you track an exception, if you catch and do not re-raise it, capturing it manually is recommended, unless you are certain it will have crossed a context boundary (e.g. by existing a with posthog.new_context(): block already). If the passed exception was raised and caught, the captured stack trace will consist of every frame between where the exception was raised and the point at which it is captured (the "traceback"). If the passed exception was never raised, e.g. if you call posthog.capture_exception(ValueError("Some Error")), the stack trace captured will be the full stack trace at the moment the exception was captured. Note that heavy use of contexts will lead to truncated stack traces, as the exception will be captured by the context entered most recently, which may not be the point you catch the exception for the final time in your code. It's recommended to use contexts sparingly, for this reason. capture_exception takes the same set of optional arguments as capture.
Parameters
exception(BaseException) - The exception to capture. If not provided, the current exception is captured viasys.exc_info()**kwargs: Optional capture arguments including distinct_id, properties, timestamp, uuid, groups, flags, send_feature_flags, and disable_geoip.kwargs?(Unpack[OptionalCaptureArgs])
Returns
Optional[str]
Examples
# Capture exception
from posthog import capture_exception
try:
risky_operation()
except Exception as e:
capture_exception(e)Feature flags methods
evaluate_flags()
Release Tag: public
Evaluate all feature flags for a user in a single call and return a :class:FeatureFlagEvaluations snapshot. Branch on .is_enabled() / .get_flag() and pass the same snapshot to capture() via the flags option so events carry the exact flag values the code branched on. Prefer this over repeated get_feature_flag() calls and over capture(send_feature_flags=True) — it consolidates flag evaluation into a single /flags request per incoming request.
Parameters
distinct_id(Number) - The user's distinct ID. IfNone, falls back to the context distinct_id. If still unresolvable, returns an empty snapshot.groups?(Mapping[str, Union[str, int]]) - Mapping of group type to group key.person_properties?(dict[str, Any]) - Person properties to use for evaluation.group_properties?(dict[str, dict[str, Any]]) - Group properties keyed by group type.only_evaluate_locally(bool) - IfTrue, never fall back to remote evaluation and omit flags that cannot be evaluated locally.disable_geoip?(bool) - Whether to disable GeoIP lookup.flag_keys?(list[str]) - Optional list that scopes local evaluation, the underlying/flagsrequest, and the returned snapshot. When omitted orNone, all flags are evaluated. An empty list returns an empty snapshot without evaluating flags. A requested key absent from loaded local definitions is included in one remote fallback perevaluate_flagscall unlessonly_evaluate_locallyisTrue. If the server also does not know the key, it is omitted from the snapshot.device_id?(str) - Optional device ID override. If not provided, falls back to the context device_id (which may be set via tracing headers). Used by experience-continuity flags to match users across distinct_id changes.
Returns
FeatureFlagEvaluations
Examples
from posthog import evaluate_flags, capture
flags = evaluate_flags("user_123", person_properties={"plan": "enterprise"})
if flags.is_enabled("new-dashboard"):
render_new_dashboard()
capture("page_viewed", distinct_id="user_123", flags=flags)feature_enabled()
Release Tag: public
Use feature flags to enable or disable features for users.
Notes:
You can call posthog.load_feature_flags() before to make sure you're not doing unexpected requests.
Parameters
key?(str) - The feature flag keydistinct_id?(Number) - The user's distinct IDgroups?(Mapping[str, Union[str, int]]) - Groups mappingperson_properties?(dict[str, Any]) - Person propertiesgroup_properties?(dict[str, dict[str, Any]]) - Group propertiesonly_evaluate_locally(bool) - Whether to evaluate only locallysend_feature_flag_events(bool) - Whether to send feature flag eventsdisable_geoip?(bool) - Whether to disable GeoIP lookupdevice_id?(str) - Optional device ID override for experience-continuity flags
Returns
Optional[bool]
Examples
# Boolean feature flag
from posthog import feature_enabled, get_feature_flag_payload
is_my_flag_enabled = feature_enabled('flag-key', 'distinct_id_of_your_user')
if is_my_flag_enabled:
matched_flag_payload = get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')feature_flag_definitions()
Release Tag: public
Returns loaded feature flags.
Notes:
Returns loaded feature flags, if any. Helpful for debugging what flag information you have loaded.
Returns
None
Examples
from posthog import feature_flag_definitions
definitions = feature_flag_definitions()get_all_flags()
Release Tag: public
Get all flags for a given user.
Notes:
Flags are key-value pairs where the key is the flag key and the value is the flag variant, or True, or False.
Parameters
distinct_id?(Number) - The user's distinct IDgroups?(Mapping[str, Union[str, int]]) - Groups mappingperson_properties?(dict[str, Any]) - Person propertiesgroup_properties?(dict[str, dict[str, Any]]) - Group propertiesonly_evaluate_locally(bool) - Whether to evaluate only locallydisable_geoip?(bool) - Whether to disable GeoIP lookupdevice_id?(str) - Optional device ID override for experience-continuity flagsflag_keys_to_evaluate?(list[str]) - Optional list of flag keys to evaluate (evaluates all if None)
Returns
Optional[dict[str, Union[bool, str]]]
Examples
# All flags for user
from posthog import get_all_flags
get_all_flags('distinct_id_of_your_user')get_all_flags_and_payloads()
Release Tag: public
Get all feature flag values and payloads for a user.
Parameters
distinct_id?(Number) - The user's distinct ID.groups?(Mapping[str, Union[str, int]]) - Mapping of group type to group key.person_properties?(dict[str, Any]) - Person properties to use for evaluation.group_properties?(dict[str, dict[str, Any]]) - Group properties keyed by group type.only_evaluate_locally(bool) - Whether to evaluate only locally.disable_geoip?(bool) - Whether to disable GeoIP lookup.device_id?(str) - Optional device ID override for experience-continuity flags.flag_keys_to_evaluate?(list[str]) - Optional list of flag keys to evaluate. Evaluates all flags when omitted.
Returns
FlagsAndPayloads
get_feature_flag()
Release Tag: public
Get feature flag variant for users. Used with experiments.
Notes:
groups are a mapping from group type to group key. So, if you have a group type of "organization" and a group key of "5", you would pass groups={"organization": "5"}. group_properties take the format: { group_type_name: { group_properties } }. So, for example, if you have the group type "organization" and the group key "5", with the properties name, and employee count, you'll send these as: group_properties={"organization": {"name": "PostHog", "employees": 11}}.
Parameters
key?(str) - The feature flag keydistinct_id?(Number) - The user's distinct IDgroups?(Mapping[str, Union[str, int]]) - Groups mapping from group type to group keyperson_properties?(dict[str, Any]) - Person propertiesgroup_properties?(dict[str, dict[str, Any]]) - Group properties in format { group_type_name: { group_properties } }only_evaluate_locally(bool) - Whether to evaluate only locallysend_feature_flag_events(bool) - Whether to send feature flag eventsdisable_geoip?(bool) - Whether to disable GeoIP lookupdevice_id?(str) - Optional device ID override for experience-continuity flags
Returns
Union[bool, str, any]
Examples
# Multivariate feature flag
from posthog import get_feature_flag, get_feature_flag_payload
enabled_variant = get_feature_flag('flag-key', 'distinct_id_of_your_user')
if enabled_variant == 'variant-key':
matched_flag_payload = get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')get_feature_flag_evaluation_runtime()
Release Tag: public
Return where a locally loaded feature flag is meant to be evaluated.
Notes:
Reads the evaluation_runtime each flag definition carries, so no extra request is made. Returns None when local evaluation has not loaded a definition for this key. A definition that carries no runtime reports FeatureFlagEvaluationRuntime.ALL, the default PostHog applies.
Parameters
key?(str)
Returns
Optional[FeatureFlagEvaluationRuntime]
Examples
from posthog import FeatureFlagEvaluationRuntime, get_feature_flag_evaluation_runtime
runtime = get_feature_flag_evaluation_runtime("my-flag")get_feature_flag_keys_by_evaluation_runtime()
Release Tag: public
Return the keys of locally loaded flags that a runtime can evaluate.
Notes:
A flag set to FeatureFlagEvaluationRuntime.ALL suits either runtime, so it is returned for CLIENT and for SERVER. Use this to decide which flags to hand to a browser when a backend serves flags to its own frontend.
Parameters
evaluation_runtime?(FeatureFlagEvaluationRuntime)
Returns
list[str]
Examples
from posthog import FeatureFlagEvaluationRuntime, get_feature_flag_keys_by_evaluation_runtime
client_keys = get_feature_flag_keys_by_evaluation_runtime(FeatureFlagEvaluationRuntime.CLIENT)get_feature_flag_payload()
Release Tag: public
Get the payload associated with a feature flag value. Deprecated for new code. Prefer evaluate_flags() and flags.get_flag_payload(key) so flag evaluation happens once per request.
Parameters
key?(str) - The feature flag key.distinct_id?(Number) - The user's distinct ID.match_value(bool) - Optional flag value to use when selecting a payload.groups?(Mapping[str, Union[str, int]]) - Mapping of group type to group key.person_properties?(dict[str, Any]) - Person properties to use for evaluation.group_properties?(dict[str, dict[str, Any]]) - Group properties keyed by group type.only_evaluate_locally(bool) - Whether to evaluate only locally.send_feature_flag_events(bool) - Whether to send a $feature_flag_called event.disable_geoip?(bool) - Whether to disable GeoIP lookup.device_id?(str) - Optional device ID override for experience-continuity flags.
Returns
Optional[object]
load_feature_flags()
Release Tag: public
Load feature flag definitions from PostHog.
Returns
None
Examples
from posthog import load_feature_flags
load_feature_flags()Client management methods
flush()
Release Tag: public
Tell the client to flush all queued events.
Parameters
timeout_seconds?(float) - Maximum seconds to wait for the queue to flush. Defaults to 10 seconds. PassNoneto wait indefinitely.
Returns
any
Examples
from posthog import flush
flush()join()
Release Tag: public
Attempt to process queued events and stop the client's background workers. Use shutdown() directly in most cases. Failed or undrainable events may be dropped and reported through logging or on_error; returning does not guarantee server receipt. Lifecycle cleanup is attempted once, and cleanup failures are logged without retry.
Returns
any
Examples
from posthog import join
join()shutdown()
Release Tag: public
Flush all messages and cleanly shutdown the client. This normally blocks until queued events have been attempted and cleanup finishes. Failed or undrainable events may be dropped and reported through logging or on_error; returning does not guarantee server receipt. Lifecycle cleanup is attempted once, and cleanup failures are logged without retry. Calls made directly from SDK callbacks such as on_error are deferred to avoid deadlocking the worker. If blocking completion is required, signal an application-owned thread, return from the callback, and call shutdown() from that thread. Do not wait inside a callback for another thread or task calling a lifecycle method.
Returns
any
Examples
from posthog import shutdown
shutdown()Tracing methods
get_active_span()
Release Tag: public
The span that is active in the current context, or None. Alpha. Only entering a span (with posthog.start_span(...) as span:) makes it active; a span started manually is not. Use it to propagate the trace to the next service: span.traceparent() is the header value.
Returns
Optional[Span]
Examples
span = posthog.get_active_span()
if span is not None:
headers["traceparent"] = span.traceparent()start_span()
Release Tag: public
Start a span for distributed tracing. Alpha. Returns a span handle. Use it as a context manager to make it the active span for the block and end it on exit (recording a raised exception on the way out); or call end() yourself for a span that cannot wrap a block. Spans started inside the block nest under it automatically. Always returns a usable handle, even when tracing is off, so calling code never branches.
Parameters
name?(str) - A low-cardinality operation name, e.g.GET /users/:id. Variable values belong in attributes, not the name.kind?(str) -internal(default),server,client,producerorconsumer.attributes?(Mapping[str, Any]) - Initial attributes.parent(Span) - A span handle, or an inbound W3Ctraceparentheader value to continue a remote trace. Defaults to the active span.tracestate?(str) - The inboundtracestateheader accompanying atraceparentstringparent; preserved and propagated.start_time(datetime) - Adatetimeor epoch seconds, to backdate the span.
Returns
Span
Examples
import posthog
posthog.traces = {"service_name": "checkout-api"}
with posthog.start_span("POST /checkout", parent=request.headers.get("traceparent")) as span:
span.set_attribute("plan", user.plan)
with posthog.start_span("db.query", kind="client"):
...
outgoing_headers = {"traceparent": span.traceparent()}Other methods
get_feature_flag_result()
Release Tag: public
Get a FeatureFlagResult object which contains the flag result and payload. This method evaluates a feature flag and returns a FeatureFlagResult object containing: - enabled: Whether the flag is enabled - variant: The variant value if the flag has variants - payload: The payload associated with the flag (automatically deserialized from JSON) - key: The flag key - reason: Why the flag was enabled/disabled
Parameters
key?(str) - The feature flag key.distinct_id?(Number) - The user's distinct ID.groups?(Mapping[str, Union[str, int]]) - Mapping of group type to group key.person_properties?(dict[str, Any]) - Person properties to use for evaluation.group_properties?(dict[str, dict[str, Any]]) - Group properties keyed by group type.only_evaluate_locally(bool) - Whether to evaluate only locally.send_feature_flag_events(bool) - Whether to send a $feature_flag_called event.disable_geoip?(bool) - Whether to disable GeoIP lookup.device_id?(str) - Optional device ID override for experience-continuity flags.
Returns
Optional[FeatureFlagResult]
get_remote_config_payload()
Release Tag: public
Get the payload for a remote config feature flag.
Parameters
key?(str) - The key of the feature flag
Returns
None
set_code_variables_mask_url_credentials_context()
Release Tag: public
Whether to scrub credentials embedded in URLs/DSNs (e.g. user:pass@host) from captured code variables for the current context.
Parameters
enabled?(bool)
Returns
None
Contexts methods
get_tags()
Release Tag: public
Get all tags from the current context. Returns: Dict of all tags in the current context
Returns
dict[str, Any]
new_context()
Release Tag: public
Create a new context scope that will be active for the duration of the with block.
Parameters
fresh(bool) - Whether to start with a fresh context (default: False)capture_exceptions?(bool) - Whether to capture exceptions raised within the context. If omitted, defaults to the relevant client's exception autocapture setting.client?(Client) - Optional Posthog client instance to use for this context (default: None)
Returns
None
Examples
from posthog import new_context, tag, capture
with new_context():
tag("request_id", "123")
capture("event_name", properties={"property": "value"})scoped()
Release Tag: public
Decorator that creates a new context for the function.
Parameters
fresh(bool) - Whether to start with a fresh context (default: False)capture_exceptions?(bool) - Whether to capture and track exceptions with posthog error tracking. If omitted, defaults to the global exception autocapture setting.
Returns
None
Examples
from posthog import scoped, tag, capture
@scoped()
def process_payment(payment_id):
tag("payment_id", payment_id)
capture("payment_started")set_capture_exception_code_variables_context()
Release Tag: public
Override code-variable capture for exceptions in the current context.
Parameters
enabled?(bool) - Whether exceptions captured in this context should include local variable values from stack frames.
Returns
None
set_code_variables_detect_secrets_context()
Release Tag: public
Whether to apply entropy-based secret detection as a last-resort redaction of high-entropy values (API keys, tokens, strong passwords) in captured code variables for the current context.
Parameters
enabled?(bool)
Returns
None
set_code_variables_ignore_patterns_context()
Release Tag: public
Override code-variable ignore patterns for exceptions in the current context.
Parameters
ignore_patterns?(list[str]) - Variable-name patterns that should be omitted entirely when code variables are captured.
Returns
None
set_code_variables_mask_patterns_context()
Release Tag: public
Override code-variable mask patterns for exceptions in the current context.
Parameters
mask_patterns?(list[str]) - Variable-name patterns whose values should be replaced with***when code variables are captured.
Returns
None
set_context_device_id()
Release Tag: public
Set the device ID for the current context, associating all feature flag requests in this or child contexts with the given device ID.
Parameters
device_id?(str) - The device ID to associate with the current context and its children
Returns
None
Examples
from posthog import set_context_device_id
set_context_device_id("device_123")set_context_session()
Release Tag: public
Set the session ID for the current context.
Parameters
session_id?(str) - The session ID to associate with the current context and its children
Returns
None
Examples
from posthog import set_context_session
set_context_session("session_123")tag()
Release Tag: public
Add a tag to the current context.
Parameters
name?(str) - The tag keyvalue?(Any) - The tag value
Returns
None
Examples
from posthog import tag
tag("user_id", "123")Initialization methods
setup()
Release Tag: public
Create or return the global PostHog client configured by module settings. Most applications should either instantiate Posthog directly or set posthog.api_key/other module settings before calling top-level helpers. setup() is called automatically by global APIs such as capture(). Returns: The global Client instance. If both api_key and project_api_key are missing or blank, the client is disabled and module-level calls become no-ops.
Returns
Client
references/python.md
AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt
Python
The Python SDK makes it easy to capture events, evaluate feature flags, track errors, and more in your Python apps.
These docs cover version
7.xof the Python SDK, which requires Python 3.10 or higher. On Python 3.9? See supported versions (#supported-versions).
Installation
Terminal
pip install posthogUpgrading to v6
Version 6.x of the PostHog Python SDK introduces a new contexts (/docs/libraries/python.md#contexts) API and breaking changes. If you're upgrading from 5.x to 6.x, read the migration guide (/tutorials/python-v6-migration.md) first to learn more.
In your app, import the posthog library and set your project token and host before making any calls.
Python
from posthog import Posthog
posthog = Posthog('<ph_project_token>', host='https://us.i.posthog.com')Note: As a rule of thumb, we do not recommend having API keys or tokens in plaintext. Setting it as an environment variable is best.
You can find your project token and instance address in the project settings page in PostHog.
Use the asyncio client
The Python SDK includes an asyncio-native client in version 7.45.0 and later. Continue to use Posthog in synchronous apps. For an asyncio app, install the optional async dependencies:
Terminal
pip install "posthog[async]>=7.45.0"Import AsyncPosthog, the customer-facing name for AsyncClient. Both names provide the same async context manager and lifecycle methods. Keep one client for the lifetime of your app. For example, use a FastAPI lifespan handler:
Python
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from posthog import AsyncPosthog
@asynccontextmanager
async def lifespan(app: FastAPI):
async with AsyncPosthog(
os.environ["POSTHOG_PROJECT_TOKEN"],
host=os.environ["POSTHOG_HOST"],
secret_key=os.environ.get("POSTHOG_FEATURE_FLAGS_SECURE_API_KEY"),
) as posthog:
app.state.posthog = posthog
yield
app = FastAPI(lifespan=lifespan)Exiting the context calls shutdown(). This flushes buffered events, waits for in-flight operations, stops the workers, and closes the HTTP transport. If you don't use the context manager, call await posthog.shutdown() during app shutdown. await posthog.join() has the same effect.
Capture events without blocking the event loop
capture() queues an event and returns without waiting for a network request. Don't await it:
Python
posthog.capture(
"event_name",
distinct_id="user-distinct-id",
properties={"source": "fastapi"},
)Use capture_immediate() when your code must wait for that event's delivery attempt:
Python
capture_id = await posthog.capture_immediate(
"event_name",
distinct_id="user-distinct-id",
)Evaluate feature flags
Await evaluate_flags() once, then use its snapshot with synchronous in-memory accessors. Pass the same snapshot to capture() to attach the exact values used for branching without another feature flag request:
Python
flags = await posthog.evaluate_flags("user-distinct-id")
if flags.is_enabled("new-checkout"):
# Show the new checkout
pass
posthog.capture(
"checkout started",
distinct_id="user-distinct-id",
flags=flags,
)The snapshot provides synchronous is_enabled(), get_flag(), and get_flag_payload() accessors. The awaited evaluate_flags() call also accepts groups, person_properties, group_properties, disable_geoip, flag_keys, and device_id arguments.
Fetch remote config
Initialize the client with a server-side feature flags secure API key (/docs/feature-flags/remote-config.md#step-1-find-your-feature-flags-secure-api-key) as secret_key, then await the remote config request:
Python
config = await posthog.get_remote_config_payload("landing-page-config")See Remote config (/docs/feature-flags/remote-config.md) for setup and security details.
Identifying users
Identifying users is required. Backend events need a
distinct_idto associate events with the correct user.In Python, you can do this through a context. All event captures in the same context will be tagged automatically with the correct
distinct_id. Typically, you would set a fresh context and identify at the top of each route.Python
from posthog import new_context, identify_context, capture @app.get("/foo") def foo(current_user: User = Depends(get_current_user)): with new_context(): # Set context at the top of a route identify_context(current_user.id) capture("foo_viewed") return {"status": "ok"}When possible, write a small piece of middleware that resolves your authenticated user, wrap a context around the request, and identifies it. Every
capture()downstream is then attributed automatically. The SDK's Django middleware does this automatically and you can replicate it when using the plain Python SDK.
Capturing events
You can send custom events using capture:
Python
# Events captured with no context or explicit distinct_id are marked as personless and have an auto-generated distinct_id:
posthog.capture('some-anon-event')
from posthog import identify_context, new_context
# Use contexts to manage user identification across multiple capture calls
with new_context():
identify_context('distinct_id_of_the_user')
posthog.capture('user_signed_up')
posthog.capture('user_logged_in')
# You can also capture events with a specific distinct_id
posthog.capture('some-custom-action', distinct_id='distinct_id_of_the_user')Tip: We recommend using a
[object] [verb]format for your event names, where[object]is the entity that the behavior relates to, and[verb]is the behavior itself. For example,project created,user signed up, orinvite sent.
Tip: You can define event schemas with typed properties and generate type-safe code using schema management (/docs/product-analytics/schema-management.md).
Setting event properties
Optionally, you can include additional information with the event by including a properties (/docs/data/events.md#event-properties) object:
Python
posthog.capture(
"user_signed_up",
distinct_id="distinct_id_of_the_user",
properties={
"login_type": "email",
"is_free_trial": "true"
}
)Sending page views
If you're aiming for a backend-only implementation of PostHog and won't be capturing events from your frontend, you can send pageviews from your backend like so:
Python
posthog.capture('$pageview', distinct_id="distinct_id_of_the_user", properties={'$current_url': 'https://example.com'})Person profiles and properties
The Python SDK captures identified events if the current context is identified or if you pass a distinct ID explicitly. These create person profiles (/docs/data/persons.md). To set person properties (/docs/product-analytics/person-properties.md) in these profiles, include them when capturing an event:
Python
# Passing a distinct id explicitly
posthog.capture(
'event_name',
distinct_id='user-distinct-id',
properties={
'$set': {'name': 'Max Hedgehog'},
'$set_once': {'initial_url': '/blog'}
}
)
# Using contexts
from posthog import new_context, identify_context
with new_context():
identify_context('user-distinct-id')
posthog.capture('event_name')For more details on the difference between $set and $set_once, see our person properties docs (/docs/product-analytics/person-properties.md#what-is-the-difference-between-set-and-set_once).
To capture anonymous events (/docs/data/anonymous-vs-identified-events.md) without person profiles, set the event's $process_person_profile property to False. Events captured with no context or explicit distinct_id are marked as personless, and will have an auto-generated distinct_id:
Python
posthog.capture(
event='event_name',
properties={
'$process_person_profile': False
}
)Alias
Sometimes, you want to assign multiple distinct IDs to a single user. This is helpful when your primary distinct ID is inaccessible. For example, if a distinct ID used on the frontend is not available in your backend.
In this case, you can use alias to assign another distinct ID to the same user.
Python
posthog.alias(previous_id='distinct_id', distinct_id='alias_id')We strongly recommend reading our docs on alias (/docs/product-analytics/identify.md#alias-assigning-multiple-distinct-ids-to-the-same-user) to best understand how to correctly use this method.
Contexts
The Python SDK uses nested contexts for managing state that's shared across events. Contexts are the recommended way to manage things like "which user is taking this action" (through identify_context), rather than manually passing user state through your apps stack.
When events (including exceptions) are captured in a context, the event uses the user distinct ID (/docs/getting-started/identify-users.md), session ID (/docs/data/sessions.md), and tags that are (optionally) set in the context. This is useful for adding properties to multiple events during a single user's interaction with your product.
You can enter a context using the with statement:
Python
from posthog import new_context, tag, set_context_session, identify_context
with new_context():
tag("transaction_id", "abc123")
tag("some_arbitrary_value", {"tags": "can be dicts"})
# Sessions are UUIDv7 values and used to track a sequence of events that occur within a single user session
# See https://posthog.com/docs/data/sessions
set_context_session(session_id)
# Setting the context-level distinct ID. See below for more details.
identify_context(user_id)
# This event is captured with the distinct ID, session ID, and tags set above
posthog.capture("order_processed")Contexts are persisted across function calls. If you enter one and then call a function and capture an event in the called function, it uses the context tags and session ID set in the parent context:
Python
from posthog import new_context, tag
def some_function():
# When called from `outer_function`, this event is captured with the property some-key="value-4"
posthog.capture("order_processed")
def outer_function():
with new_context():
tag("some-key", "value-4")
some_function()Contexts are nested, so tags added to a parent context are inherited by child contexts. If you set the same tag in both a parent and child context, the child context's value overrides the parent's at event capture (but the parent context won't be affected). This nesting also applies to session IDs and distinct IDs.
Python
from posthog import new_context, tag
with new_context():
tag("some-key", "value-1")
tag("some-other-key", "another-value")
with new_context():
tag("some-key", "value-2")
# This event is captured with some-key="value-2" and some-other-key="another-value"
posthog.capture("order_processed")
# This event is captured with some-key="value-1" and some-other-key="another-value"
posthog.capture("order_processed")You can disable this nesting behavior by passing fresh=True to new_context:
Python
from posthog import new_context, tag
with new_context(fresh=True):
tag("some-key", "value-2")
# This event only has the property some-key="value-2" from the fresh context
posthog.capture("order_processed")Note: Distinct IDs, session IDs, and properties passed directly to calls to
captureand related functions override context state in the final event captured.
Contexts and user identification
Contexts can be associated with a distinct ID by calling posthog.identify_context:
Python
from posthog import identify_context
identify_context("distinct-id")Within a context associated with a distinct ID, all events captured are associated with that user. You can override the distinct ID for a specific event by passing a distinct_id argument to capture:
Python
from posthog import new_context, identify_context
with new_context():
identify_context("distinct-id")
posthog.capture("order_processed") # will be associated with distinct-id
posthog.capture("order_processed", distinct_id="another-distinct-id") # will be associated with another-distinct-idIt's recommended to pass the currently active distinct ID from the frontend to the backend, using the X-POSTHOG-DISTINCT-ID header. If you're using our Django middleware, this is extracted and associated with the request handler context automatically.
You can read more about identifying users in the user identification documentation (/docs/product-analytics/identify.md).
Contexts and sessions
Contexts can be associated with a session ID by calling posthog.set_context_session. When linking backend events to frontend sessions, use the session ID from the frontend SDK (PostHog session IDs are UUIDv7 strings).
Python
from posthog import new_context, set_context_session
with new_context():
set_context_session(request.get_header("X-POSTHOG-SESSION-ID"))Using PostHog on your frontend too?
If you're using the PostHog JavaScript Web SDK on your frontend, it generates a session ID for you. Configure tracing_headers (/docs/libraries/js/config.md#tracing-headers) for your backend hostname to add the session and distinct ID headers to browser requests automatically.
You need to extract the header in your request handler (if you're using our Django middleware integration, this happens automatically).
If you associate a context with a session, you'll be able to do things like:
- See backend events on the session timeline when viewing session replays
- View session replays for users that triggered a backend exception in error tracking
You can read more about sessions in the session tracking (/docs/data/sessions.md) documentation.
Exception capture
By default exceptions raised within a context are captured and available in the error tracking (/docs/error-tracking.md) dashboard. You can override this behavior by passing capture_exceptions=False to new_context:
Python
from posthog import new_context, tag
with new_context(capture_exceptions=False):
tag("transaction_id", "abc123")
tag("some_arbitrary_value", {"tags": "can be dicts"})
# This event will be captured with the tags set above
posthog.capture("order_processed")
# This exception will not be captured
raise Exception("Order processing failed")Decorating functions
The SDK exposes a function decorator. It takes the same fresh and capture_exceptions arguments as new_context and provides a handy way to mark a whole function as being in a new context. For example:
Python
from posthog import scoped, identify_context
@scoped(fresh=True)
def process_order(user, order_id):
identify_context(user.distinct_id)
posthog.capture("order_processed") # Associated with the user
raise Exception("Order processing failed") # This exception is also captured and associated with the userGroup analytics
Group analytics allows you to associate an event with a group (e.g. teams, organizations, etc.). Read the Group Analytics (/docs/user-guides/group-analytics.md) guide for more information.
Note: This is a paid feature and is not available on the open-source or free cloud plan. Learn more on our pricing page (/pricing.md).
To capture an event and associate it with a group:
Python
posthog.capture('some_event', groups={'company': 'company_id_in_your_db'})To update properties on a group:
Python
posthog.group_identify('company', 'company_id_in_your_db', {
'name': 'Awesome Inc.',
'employees': 11
})The name is a special property which is used in the PostHog UI for the name of the group. If you don't specify a name property, the group ID will be used instead.
Feature flags
The examples in this section use the synchronous Posthog client. For AsyncPosthog, use the awaited feature flag example (#evaluate-feature-flags). The returned snapshot uses the same accessors.
PostHog's feature flags (/docs/feature-flags.md) enable you to safely deploy and roll back new features as well as target specific users and groups with them.
There are two steps to implement feature flags in Python:
Step 1: Evaluate flags once
Call posthog.evaluate_flags() once for the user, then read values from the returned snapshot.
Boolean feature flags
Python
flags = posthog.evaluate_flags("distinct_id_of_your_user")
if flags.is_enabled("flag-key"):
# Do something differently for this user
# Optional: fetch the payload
matched_flag_payload = flags.get_flag_payload("flag-key")Multivariate feature flags
Python
flags = posthog.evaluate_flags("distinct_id_of_your_user")
enabled_variant = flags.get_flag("flag-key")
if enabled_variant == "variant-key": # replace "variant-key" with the key of your variant
# Do something differently for this user
# Optional: fetch the payload
matched_flag_payload = flags.get_flag_payload("flag-key")flags.get_flag() returns the variant string for multivariate flags, True for enabled boolean flags, False for disabled flags, and None when the flag wasn't returned by the evaluation.
Note:
posthog.feature_enabled(),posthog.get_feature_flag(),posthog.get_feature_flag_payload(), andposthog.capture(send_feature_flags=True)still work during the migration period, but they're deprecated. Preferposthog.evaluate_flags()for new code.
Step 2: Include feature flag information when capturing events
If you want use your feature flag to breakdown or filter events in your insights (/docs/product-analytics/insights.md), you'll need to include feature flag information in those events. This ensures that the feature flag value is attributed correctly to the event.
Note: This step is only required for events captured using our server-side SDKs or API (/docs/api.md).
There are two methods you can use to include feature flag information in your events:
Method 1: Pass the evaluated flags snapshot to capture()
Pass the same flags object that you used for branching. This attaches the exact flag values from that evaluation and doesn't make another /flags request.
Python
flags = posthog.evaluate_flags("distinct_id_of_your_user")
if flags.is_enabled("flag-key"):
# Do something differently for this user
pass
posthog.capture(
"event_name",
distinct_id="distinct_id_of_your_user",
flags=flags,
)By default, this attaches every flag in the snapshot using $feature/<flag-key> properties and $active_feature_flags.
To reduce event property bloat, pass a filtered snapshot:
Python
# Attach only flags accessed with is_enabled() or get_flag() before this call
posthog.capture(
"event_name",
distinct_id="distinct_id_of_your_user",
flags=flags.only_accessed(),
)
# Attach only specific flags
posthog.capture(
"event_name",
distinct_id="distinct_id_of_your_user",
flags=flags.only(["checkout-flow", "new-dashboard"]),
)only_accessed() is order-dependent. If you call it before accessing any flags with is_enabled() or get_flag(), no feature flag properties are attached.
Method 2: Include the $feature/feature_flag_name property manually
In the event properties, include $feature/feature_flag_name: variant_key:
Python
posthog.capture(
"event_name",
distinct_id="distinct_id_of_the_user",
properties={
# Replace feature-flag-key with your flag key and "variant-key" with the key of your variant
"$feature/feature-flag-key": "variant-key",
},
)Evaluating only specific flags
By default, posthog.evaluate_flags() evaluates every flag for the user. If you only need a few flags, pass flag_keys to request only those flags:
Python
flags = posthog.evaluate_flags(
"distinct_id_of_your_user",
flag_keys=["checkout-flow", "new-dashboard"],
)Sending $feature_flag_called events
Capturing $feature_flag_called events enables PostHog to know when a flag was accessed by a user and provide analytics and insights (/docs/product-analytics/insights.md) on the flag. With posthog.evaluate_flags(), the SDK sends this event when you call flags.is_enabled() or flags.get_flag() for a flag.
The SDK deduplicates these events per (distinct_id, flag, value) in a local cache. If you reinitialize the PostHog client, the cache resets and $feature_flag_called events may be sent again. PostHog handles duplicates, so duplicate $feature_flag_called events don't affect your analytics.
flags.get_flag_payload() doesn't send $feature_flag_called events and doesn't count as an access for only_accessed().
Advanced: Overriding server properties
Sometimes, you may want to evaluate feature flags using person properties (/docs/product-analytics/person-properties.md), groups (/docs/product-analytics/group-analytics.md), or group properties that haven't been ingested yet, or were set incorrectly earlier.
You can provide properties to evaluate the flag with by using the person properties, groups, and group properties arguments. PostHog will then use these values to evaluate the flag, instead of any properties currently stored on your PostHog server.
For example:
Python
flags = posthog.evaluate_flags(
"distinct_id_of_the_user",
person_properties={"property_name": "value"},
groups={
"your_group_type": "your_group_id",
"another_group_type": "your_group_id",
},
group_properties={
"your_group_type": {"group_property_name": "value"},
"another_group_type": {"group_property_name": "value"},
},
)
if flags.is_enabled("flag-key"):
# Do something differently for this userOverriding GeoIP properties
By default, a user's GeoIP properties are set using the IP address they use to capture events on the frontend. You may want to override the these properties when evaluating feature flags. A common reason to do this is when you're not using PostHog on your frontend, so the user has no GeoIP properties.
You can override GeoIP properties by including them in the person_properties parameter when evaluating feature flags. This is useful when you're evaluating flags on your backend and want to use the client's location instead of your server's location.
The following GeoIP properties can be overridden:
$geoip_country_code$geoip_country_name$geoip_city_name$geoip_city_confidence$geoip_continent_code$geoip_continent_name$geoip_latitude$geoip_longitude$geoip_postal_code$geoip_subdivision_1_code$geoip_subdivision_1_name$geoip_subdivision_2_code$geoip_subdivision_2_name$geoip_subdivision_3_code$geoip_subdivision_3_name$geoip_time_zone
Simply include any of these properties in the person_properties parameter alongside your other person properties when calling feature flags.
Request timeout
You can configure the feature_flags_request_timeout_seconds parameter when initializing your PostHog client to set a flag request timeout. This helps prevent your code from being blocked if PostHog's servers are too slow to respond. By default, this is set to 3 seconds.
Python
posthog = Posthog(
"<ph_project_token>",
host="https://us.i.posthog.com",
feature_flags_request_timeout_seconds=3, # Time in seconds. Defaults to 3.
)Local evaluation
Evaluating feature flags requires making a request to PostHog for each flag. However, you can improve performance by evaluating flags locally. Instead of making a request for each flag, PostHog will periodically request and store feature flag definitions locally, enabling you to evaluate flags without making additional requests.
It is best practice to use local evaluation flags when possible, since this enables you to resolve flags faster and with fewer API calls.
For details on how to implement local evaluation, see our local evaluation guide (/docs/feature-flags/local-evaluation.md).
Distributed environments
In multi-worker or edge environments, you can implement custom caching for flag definitions using Redis, Cloudflare KV, or other storage backends. This enables sharing definitions across workers and coordinating fetches. See our guide for local evaluation in distributed environments (/docs/feature-flags/local-evaluation/distributed-environments?tab=Python.md) for details.
Experiments (A/B tests)
Since experiments (/docs/experiments/start-here.md) use feature flags, the code for running an experiment is very similar to the feature flags code. This example uses the synchronous Posthog client:
Python
flags = posthog.evaluate_flags("user_distinct_id")
variant = flags.get_flag("experiment-feature-flag-key")
if variant == "variant-name":
# Do somethingWith AsyncPosthog, await the evaluation: flags = await posthog.evaluate_flags("user_distinct_id"). The remaining snapshot access is the same.
It's also possible to run experiments without using feature flags (/docs/experiments/running-experiments-without-feature-flags.md).
AI Observability
Our Python SDK includes a built-in AI Observability feature. It enables you to capture LLM usage, performance, and more. Check out our analytics docs (/docs/ai-observability.md) for more details on setting it up.
Error tracking
You can autocapture exceptions (/docs/error-tracking/installation.md) by setting the enable_exception_autocapture argument to True when initializing the PostHog client.
Python
from posthog import Posthog
posthog = Posthog("<ph_project_token>", enable_exception_autocapture=True, ...)You can also manually capture exceptions using the capture_exception method:
Python
posthog.capture_exception(e, distinct_id='user_distinct_id', properties=additional_properties)Contexts automatically capture exceptions thrown inside them, unless disable it by passing capture_exceptions=False to new_context().
Code variables capture
The Python SDK can automatically capture the state of local variables when an exception occurs. This gives you a debugger-like view of your application state at the time of the error:
Python
posthog = Posthog(
"<ph_project_token>",
enable_exception_autocapture=True,
capture_exception_code_variables=True,
)You can configure which variables are captured, masked, or ignored. See the code variables documentation (/docs/error-tracking/code-variables/python.md) for detailed configuration options.
Distributed tracing
Requires
posthogversion 7.58.0 or later.
The span API is experimental
Tracing is new in the Python SDK and its API can still change in a minor release. Spans you send are kept – it's the SDK surface that isn't frozen yet.
Tracing records spans – timed units of work – so you can see where time went in a request and how work fans out across your services. Spans created inside a context (#contexts) automatically carry the person and session they belong to, so a slow trace links back to the person who experienced it.
Tracing is off until you set the traces option. No OpenTelemetry dependency is required. For what you can do with spans once they arrive, see Distributed tracing (/docs/distributed-tracing/start-here.md).
Python
from posthog import Posthog
posthog = Posthog(
"<ph_project_token>",
host="https://us.i.posthog.com",
traces={"service_name": "checkout-api"},
)If you use the module-level API instead, set posthog.traces = {"service_name": "checkout-api"} alongside your other options, before you start the first span.
Set service_name – PostHog groups operations by service and span name.
Tracing is available on the synchronous Posthog client and the module-level API. AsyncPosthog doesn't support it yet.
Creating spans
start_span returns a span. Use it in a with block to make it the active span for the block and end it when the block exits. Spans started inside the block nest underneath it automatically.
Python
with posthog.start_span("POST /checkout", kind="server") as span:
span.set_attribute("plan", user.plan)
with posthog.start_span("create-order"):
order = create_order(cart)
with posthog.start_span("charge-card"):
stripe.charge(order)If an exception escapes the block, the span records it, its status is set to error, and the exception propagates unchanged. KeyboardInterrupt, GeneratorExit, and asyncio.CancelledError still end the span but aren't recorded as failures.
The recorded exception includes the stack trace, which contains file paths from your server. If you'd rather those didn't leave your process, remove exception.stacktrace in before_span_send (#scrubbing-and-dropping-spans).
For work that can't wrap a block, call start_span without with. A span started this way isn't active, so spans started afterwards aren't its children unless you pass parent explicitly – and you must call end() yourself.
Python
span = posthog.start_span("background-sync", attributes={"queue": "emails"})
try:
# Explicitly parent a child to a span that isn't active.
child = posthog.start_span("send-batch", parent=span)
child.end()
finally:
span.end()get_active_span() returns the span active in the current context, or None when there isn't one.
The active span is tracked with contextvars, so it carries across await in asyncio code. Threads don't reliably inherit it – pass parent=span to continue the trace in a thread you start or a ThreadPoolExecutor task. A forked child process starts with no active span, so pass parent there too.
start_span always returns a usable span, even when tracing is off, so your code never needs to check whether tracing is enabled.
Span names and attributes
Span names should be low-cardinality operation names – GET /users/:id, not GET /users/123. Variable values belong in attributes. Strings, booleans, integers, and floats keep their type. Lists and dictionaries are sent as arrays and maps, but PostHog stores them as serialized strings. Setting an attribute to None removes it, and any other value is converted to a string.
Python
with posthog.start_span("GET /users/:id", kind="server") as span:
span.set_attributes({"user.id": user_id, "db.rows": len(rows)})
span.add_event("cache-miss")
if not rows:
span.set_status("error", "user not found")| Method | Description |
|---|---|
set_attribute(key, value) |
Set a single attribute |
set_attributes(attributes) |
Merge several attributes at once |
add_event(name, attributes=None, timestamp=None) |
Record a timestamped event within the span |
set_status(code, message=None) |
Set the outcome: "ok" or "error". "ok" is final – an exception raised later in the with block doesn't override it |
record_exception(exception) |
Attach an exception event carrying the type, message, and – for a raised exception – stack trace, and set status to error. Use it for exceptions you catch and handle |
update_name(name) |
Replace the span name, e.g. once a route template resolves |
traceparent() |
This span's W3C traceparent header value, or None |
tracestate() |
This span's W3C tracestate value, or None when it has none |
end(end_time=None) |
End the span and queue it for export. A with block does this for you |
Every method except traceparent(), tracestate(), and end() returns the span, so calls chain. Calls after end() are ignored.
start_span takes these keyword arguments:
| Argument | Description |
|---|---|
kind |
What the work is: "internal" (default), "server" for an inbound request, "client" for an outbound call, "producer" or "consumer" for queue work |
attributes |
Attributes to set at span start |
parent |
A span, or an inbound W3C traceparent string to continue a trace another service started. Defaults to the active span |
tracestate |
The W3C tracestate accompanying a traceparent string. Ignored when parent is a span, which inherits its parent's |
start_time |
Backdate the span's start, as a datetime or seconds since the epoch. The server clamps a start more than 24 hours old to receive time; with debug on, the SDK prints a debug message when you pass one |
Tracing across services
Spans use W3C Trace Context, so a trace can span several services. Pass an inbound traceparent header as parent to continue a trace another service started, and send span.traceparent() onward when you call out.
app.py
import requests
from flask import request
@app.post("/checkout")
def checkout():
with posthog.start_span(
"POST /checkout",
kind="server",
parent=request.headers.get("traceparent"),
) as span:
traceparent = span.traceparent()
requests.post(
"https://payments.internal/charge",
headers={"traceparent": traceparent} if traceparent else {},
)
return {"status": "ok"}A malformed traceparent starts a new trace rather than raising. A missing one (None) falls back to the active span, if there is one.
A continued trace propagates the sampled flag it was handed, so a downstream sampler sees the decision the head service made. PostHog itself doesn't sample – a span is recorded and exported whichever way that flag is set.
Linking traces to people and sessions
Spans created inside a context (#contexts) that has a distinct ID or session ID automatically carry posthogDistinctId and sessionId attributes, which is what makes a trace reachable from a person or a Session Replay recording. In Django, the contexts middleware (/docs/libraries/django.md#django-contexts-middleware) sets these for every request. Elsewhere, set them yourself:
Python
from posthog import new_context, identify_context, set_context_session
with new_context():
identify_context(user.id)
set_context_session(session_id)
with posthog.start_span("POST /checkout"):
process_order()Spans created outside a context with those values omit the attributes.
Scrubbing and dropping spans
before_span_send runs on every finished span before it's queued for export. It receives the span as a dict with name, kind, status, attributes, events, start_time_ns, end_time_ns, trace_id, span_id, and parent_span_id. Edit it and return it, or return None to drop the span entirely.
Python
def scrub_spans(span):
if span["attributes"].get("http.route") == "/health":
return None
span["attributes"].pop("http.request.header.authorization", None)
return span
posthog = Posthog(
"<ph_project_token>",
host="https://us.i.posthog.com",
traces={
"service_name": "checkout-api",
"before_span_send": scrub_spans,
},
)Attributes are plain Python values, not the OTLP wire encoding. The hook runs after PostHog attaches posthogDistinctId and sessionId, so those are visible to the hook and can be scrubbed too. An exception's stack trace is on its event, under event["attributes"]["exception.stacktrace"].
trace_id,span_id, andparent_span_idare read-only. Rewriting them would orphan child spans that have already been exported, so changes are reverted.- A hook that raises drops the span rather than exporting it without scrubbing.
- Pass a list to run several hooks in order. The first one to return
Nonestops the chain. - The hook must be a regular function. An
asynchook drops every span. - If an entry isn't callable, tracing turns off for the client rather than exporting spans the hook was meant to scrub.
Span limits
A span is capped at 128 attributes and 128 events, each event at 128 attributes, and each string attribute value at 8192 characters. The endpoint rejects a span that's too large, and a rejected span is lost whole rather than truncated, so the caps bound a span before it gets there.
Past the cap, the earliest attributes and events are kept and the number dropped is reported alongside the span, so a truncated span reads as truncated rather than as quietly incomplete. The attributes PostHog attaches itself – posthogDistinctId and sessionId – don't count toward the cap and are never dropped, so a span at the limit still links back to its person and session.
The event cap is absolute: an exception event the SDK records for you spends an ordinary slot like any other. A span that fills its events and then raises keeps its error status but not the exception detail, and reports the loss as a dropped event. Raise max_events_per_span on spans that record many events and can also fail.
The length bound reaches inside a value, including strings nested in lists and dictionaries, and applies to exception.stacktrace like any other attribute – a long stack trace keeps its last 8192 characters. All four caps are re-applied after before_span_send, so a hook that enriches a span can't push it back over.
Configuration
| Option | Default | Description |
|---|---|---|
service_name |
– | Name of the service producing spans. Set this |
service_version |
– | Version of the service |
environment |
– | Deployment environment, e.g. production |
resource_attributes |
– | Extra OTLP resource attributes. Takes precedence over the fields above |
flush_interval |
5 |
Seconds between exports of queued spans |
max_export_batch_size |
512 |
Maximum spans per request |
max_queue_size |
2048 |
Maximum spans held in memory. Spans beyond this are dropped |
max_live_spans |
10000 |
Maximum spans open at once. At the limit start_span returns a span that isn't recorded |
max_span_age |
3600 |
Once max_live_spans is reached, spans open longer than this many seconds are treated as leaked and never exported |
before_span_send |
– | Edit or drop each finished span before export. Return None to drop it |
max_attributes_per_span |
128 |
Maximum attributes you set on one span |
max_events_per_span |
128 |
Maximum events on one span |
max_attribute_value_length |
8192 |
Maximum characters in a string attribute value |
An invalid value falls back to its default with a warning. The exception is before_span_send: an entry that isn't callable turns tracing off.
Shutdown and short-lived processes
Spans are exported on a background interval, even with sync_mode on. Both flush() and shutdown() export spans that have already ended. A span still open at flush() is exported once it ends; a span still open at shutdown() is discarded with a warning, so end your spans before shutting down – a with block does this for you. shutdown() gives queued spans up to 30 seconds to send.
In a serverless handler, call flush() before returning. Events and spans are flushed concurrently, so it costs one round trip, not two.
Python
def handler(event, context):
with posthog.start_span("handler"):
do_work()
posthog.flush()A script that exits without calling shutdown() still gets a brief best-effort flush at exit, but don't rely on it for spans you need.
GeoIP properties
Before posthog-python v3.0, we added GeoIP properties to all incoming events by default. We also used these properties for feature flag evaluation, based on the IP address of the request. This isn't ideal since they are created based on your server IP address, rather than the user's, leading to incorrect location resolution.
As of posthog-python v3.0, the default now is to disregard the server IP, not add the GeoIP properties, and not use the values for feature flag evaluations.
You can go back to previous behavior by doing setting the disable_geoip argument in your initialization to False:
Python
posthog = Posthog('api_key', disable_geoip=False)The list of properties that this overrides:
$geoip_city_name$geoip_country_name$geoip_country_code$geoip_continent_name$geoip_continent_code$geoip_postal_code$geoip_time_zone
You can also explicitly chose to enable or disable GeoIP for a single capture request like so:
Python
posthog.capture('test_event', disable_geoip=True|False)Debug mode
If you're not seeing the expected events being captured, the feature flags being evaluated, or the surveys being shown, you can enable debug mode to see what's happening.
You can enable debug mode by setting the debug option to True in the PostHog object. This will enable verbose logs about the inner workings of the SDK.
Python
posthog.debug = TrueDisabling requests during tests
You can disable requests during tests by setting the disabled option to True in the PostHog object. This means no events will be captured or no requests will be sent to PostHog.
Python
if settings.TEST:
posthog.disabled = TrueConnection configuration
The SDK uses HTTP connection pooling internally for better performance. These settings typically need not be changed, but in some environments, such as when running behind NAT gateways, pooled connections may be terminated non-gracefully, causing request failures.
You can configure connection behavior in several ways. The following settings should be called during initialization, before any API requests are made.
Enable TCP keepalive
TCP keepalive probes help prevent idle connections from being dropped by network infrastructure. This is the recommended approach for most cases where idle connections are terminated.
Python
import posthog
posthog.enable_keep_alive()This enables TCP keepalive with sensible defaults (60 second idle time, 60 second probe interval, 3 probes before timeout).
Disable connection pooling
If you need each request to use a fresh connection, you can disable connection reuse entirely. This will incur additional overhead per request but may be desirable in some circumstances.
Python
import posthog
posthog.disable_connection_reuse()Custom HTTP socket options
For advanced use cases, you can configure arbitrary socket options on the underlying HTTP connection.
Python
import socket
import posthog
posthog.set_socket_options([
(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1),
# Add additional socket options as needed
])Pass None to set_socket_options() to reset to default behavior.
Filtering or modifying events before sending
Use before_send to modify or drop events before they are queued for delivery. Return the modified event dictionary to send it, or None to drop it.
Python
from typing import Any
import posthog
def scrub_pii(event: dict[str, Any]) -> dict[str, Any] | None:
properties = event.get("properties", {})
if "email" in properties:
email = properties["email"]
properties["email"] = f"***@{email.split('@', 1)[1]}" if "@" in email else "***"
if event.get("event") == "test_event":
return None
return event
client = posthog.Client(
"<ph_project_api_key>",
before_send=scrub_pii,
)If your callback raises an exception, the SDK logs the error and continues with the original unmodified event.
Historical migrations
You can use the Python or Node SDK to run historical migrations (/docs/migrate.md) of data into PostHog. To do so, set the historical_migration option to true when initializing the client.
Python
from posthog import Posthog
from datetime import datetime
posthog = Posthog(
'<ph_project_token>',
host='https://us.i.posthog.com',
debug=True,
historical_migration=True
)
events = [
{
"event": "batched_event_name",
"distinct_id": "user_id",
"timestamp": datetime.fromisoformat("2024-04-02T12:00:00+00:00"),
"properties": {"account_type": "pro"}
},
{
"event": "batched_event_name",
"distinct_id": "user_id",
"timestamp": datetime.fromisoformat("2024-04-03T09:30:00+00:00"),
"properties": {"account_type": "pro"}
}
]
for event in events:
posthog.capture(
event["event"],
distinct_id=event["distinct_id"],
properties=event["properties"],
timestamp=event["timestamp"],
)
posthog.shutdown()Node.js
import { PostHog } from 'posthog-node'
const client = new PostHog(
'<ph_project_token>',
{
host: 'https://us.i.posthog.com',
historicalMigration: true
}
)
client.debug()
client.capture({
event: "batched_event_name",
distinctId: "user_id",
properties: {},
timestamp: new Date("2024-04-03T12:00:00Z")
})
client.capture({
event: "batched_event_name",
distinctId: "user_id",
properties: {},
timestamp: new Date("2024-04-03T13:00:00Z")
})
await client.shutdown()Serverless environments (Render/Lambda/...)
Synchronous Posthog
By default, the synchronous Posthog client buffers events before sending them to the capture endpoint. This can lead to lost events if the platform terminates the Python process before the buffer is fully flushed. To avoid this, you can either:
- Call
posthog.shutdown()before the process ends. This blocking call attempts to deliver queued events and cleans up the client. - Enable
sync_modewhen initializing the client so eachposthog.capture()call attempts delivery before it returns.
If you use distributed tracing (#distributed-tracing), sync_mode doesn't apply to spans. Call posthog.flush() before the handler returns – see Shutdown and short-lived processes (#shutdown-and-short-lived-processes).
Asyncio AsyncPosthog
Keep one AsyncPosthog client for the lifetime of your application. Use buffered capture() by default, or await capture_immediate() when one invocation must wait for an event's delivery attempt. Call await posthog.shutdown() once during application cleanup. Don't shut down the client after each request.
Django
See our Django docs (/docs/libraries/django.md) for how to set up PostHog in Django. Our library includes a contexts middleware (/docs/libraries/django.md#django-contexts-middleware) that can automatically capture distinct IDs, session IDs, and other properties you can set up with tags.
Alternative name
As our open source project PostHog shares the same module name, we created a special posthoganalytics package, mostly for internal use to avoid module collision. It is the exact same.
Thank you
This library is largely based on the analytics-python package.
Supported versions
These docs cover version 7.x of the PostHog Python SDK, which requires Python 3.10 or higher. Python 3.9 is no longer supported on 7.x.x and higher — pin to the 6.x line with pip install 'posthog<7', where 6.9.3 is the final release.
Everything on this page works the same way on 6.9.3. Event capture, the context API (new_context, identify_context, set_context_session), and PosthogContextMiddleware are identical on 6.9.3 and 7.0.0 — 7.0.0 only dropped Python 3.9 and bumped the optional LLM provider SDKs. That includes the middleware identifying the request context from the X-POSTHOG-DISTINCT-ID header and falling back to the authenticated user, which behaves the same across both lines.
Later 7.x releases add what the 6.x line does not receive, such as the Celery integration, tracing header sanitization, and set_context_device_id. They also changed the middleware's own captured properties: 7.x sends the request IP as $ip, where 6.9.3 sends it as $ip_address, and 7.x additionally captures $request_path, $raw_user_agent, and the authenticated user's email.
Still have questions?
Ask PostHog AI
Was this page useful?
HelpfulCould be better
references/react-native.md
AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt
React Native
Installation
Our React Native enables you to integrate PostHog with your React Native project. For React Native projects built with Expo, there are no mobile native dependencies outside of supported Expo packages.
To install, add the posthog-react-native package to your project as well as the required peer dependencies.
Expo apps
Terminal
npx expo install posthog-react-native expo-file-system expo-application expo-device expo-localizationReact Native apps
Terminal
yarn add posthog-react-native @react-native-async-storage/async-storage react-native-device-info react-native-localize
# or
npm i -s posthog-react-native @react-native-async-storage/async-storage react-native-device-info react-native-localizeReact Native Web and macOS
If you're using React Native Web or React Native macOS, do not use the expo-file-system package since the Web and macOS targets aren't supported, use the @react-native-async-storage/async-storage package instead.
Configuration
With the PosthogProvider
The recommended way to set up PostHog for React Native is to use the PostHogProvider. This utilizes the Context API to pass the PostHog client around, and enables autocapture (/docs/product-analytics/autocapture.md).
To set up PostHogProvider, add it to your App.js or App.ts file:
App.js
// App.(js|ts)
import { usePostHog, PostHogProvider } from 'posthog-react-native'
...
export function MyApp() {
return (
<PostHogProvider apiKey="<ph_project_token>" options={{
// usually 'https://us.i.posthog.com' or 'https://eu.i.posthog.com'
host: 'https://us.i.posthog.com',
}}>
<MyComponent />
</PostHogProvider>
)
}Then you can access PostHog using the usePostHog() hook:
React Native
const MyComponent = () => {
const posthog = usePostHog()
useEffect(() => {
posthog.capture("event_name")
}, [posthog])
}Without the PosthogProvider
If you prefer not to use the provider, you can initialize PostHog in its own file and import the instance from there:
posthog.ts
import PostHog from 'posthog-react-native'
export const posthog = new PostHog('<ph_project_token>', {
// usually 'https://us.i.posthog.com' or 'https://eu.i.posthog.com'
host: 'https://us.i.posthog.com'
})Then you can access PostHog by importing your instance:
React Native
import { posthog } from './posthog'
export function MyApp1() {
useEffect(() => {
posthog.capture('event_name')
}, [])
return <View>Your app code</View>
}You can even use this instance with the PostHogProvider:
React Native
import { posthog } from './posthog'
export function MyApp() {
return <PostHogProvider client={posthog}>{/* Your app code */}</PostHogProvider>
}Choose an iOS dependency path for the native plugin
The optional @posthog/react-native-plugin package adds native features such as session replay and native crash capture. Install it as described in the guide for the feature that you use. Then choose one iOS dependency path:
| Path | Requirements | What it resolves |
|---|---|---|
| CocoaPods | A React Native project that uses CocoaPods | CocoaPods resolves the plugin and posthog-ios. This remains the default path. |
CocoaPods with posthog-ios through Swift Package Manager |
React Native 0.75 or later and a CocoaPods project with dynamic frameworks | CocoaPods resolves the plugin. Swift Package Manager resolves posthog-ios. |
| Full Swift Package Manager | Verified with an iOS-only React Native 0.87.1 app and React Native Community CLI 20.2.0. Requires @posthog/react-native-plugin 2.4.0 or later, Xcode 16 or later, and an iOS 15.1 or later app deployment target. |
React Native's experimental Swift Package Manager integration resolves the plugin and posthog-ios. This path does not use CocoaPods. |
This verification does not cover Expo or other React Native versions. Use CocoaPods or the hybrid path unless you validate the full Swift Package Manager path for your configuration.
CocoaPods
Use the standard React Native CocoaPods flow:
Terminal
cd ios
pod installThe plugin podspec adds posthog-ios as a CocoaPods dependency. You do not need to add posthog-ios separately.
CocoaPods with posthog-ios through Swift Package Manager
Add the following property to ios/Podfile.properties.json:
JSON
{
"posthog.useSpm": "true"
}Add dynamic frameworks to your ios/Podfile:
Ruby
use_frameworks! :linkage => :dynamicThen install the pods:
Terminal
cd ios
pod installThis setting changes only how the plugin resolves posthog-ios. The plugin and other React Native dependencies still use CocoaPods.
Full Swift Package Manager
This path uses React Native's experimental CocoaPods-free iOS integration. Every native dependency in your app must support React Native's full Swift Package Manager integration. Use CocoaPods or the hybrid path if a dependency does not support it.
Install your JavaScript dependencies first. Make a clean commit or a backup of your iOS project before the conversion. Then run this command from the ios directory:
Terminal
npx react-native spm add --deintegrate --yesThe --deintegrate option removes the complete CocoaPods integration from the iOS project. React Native then finds the plugin's ios/Package.swift manifest. Swift Package Manager resolves the plugin and posthog-ios. Do not run pod install for this path.
PostHog CI verifies this path with the configuration in the requirements table. The verified app sets its deployment target to iOS 15.1. The plugin package manifest has a separate iOS 15 minimum. The CocoaPods and hybrid paths keep the plugin podspec's iOS 13 minimum.
Set up a reverse proxy (recommended)
We recommend setting up a reverse proxy (/docs/advanced/proxy.md), so that events are less likely to be intercepted by tracking blockers.
We have our own managed reverse proxy service (/docs/advanced/proxy/managed-reverse-proxy.md), which is free for all PostHog Cloud users, routes through our infrastructure, and makes setting up your proxy easy.
If you don't want to use our managed service then there are several other options for creating a reverse proxy, including using Cloudflare (/docs/advanced/proxy/cloudflare.md), AWS Cloudfront (/docs/advanced/proxy/cloudfront.md), and Vercel (/docs/advanced/proxy/vercel.md).
Grouping products in one project (recommended)
If you have multiple customer-facing products (e.g. a marketing website + mobile app + web app), it's best to install PostHog on them all and group them in one project (/docs/settings/projects.md).
This makes it possible to track users across their entire journey (e.g. from visiting your marketing website to signing up for your product), or how they use your product across multiple platforms.
Add IPs to Firewall/WAF allowlists (recommended)
For certain features like heatmaps (/docs/toolbar/heatmaps.md), your Web Application Firewall (WAF) may be blocking PostHog's requests to your site. Add these IP addresses to your WAF allowlist or rules to let PostHog access your site.
EU: 3.75.65.221, 18.197.246.42, 3.120.223.253
US: 44.205.89.55, 52.4.194.122, 44.208.188.173
These are public, stable IPs used by PostHog services.
PostHog captures heatmap screenshots using Browserless, which has its own IP addresses. Browserless publishes the current list here.
An allowlist does not help when your app has a private address. For apps on an internal network, see internal and intranet applications (/docs/session-replay/troubleshooting.md#internal-and-intranet-applications).
Configuration options
You can further customize how PostHog works through its configuration on initialization.
| Attribute | Description |
|---|---|
host Type: String Default: https://us.i.posthog.com |
PostHog API host (usually https://us.i.posthog.com by default or https://eu.i.posthog.com). Host is optional if you use https://us.i.posthog.com. |
flushAt Type: Number Default: 20 |
The number of events to queue before sending to PostHog (flushing). |
flushInterval Type: Number Default: 10000 |
The interval in milliseconds between periodic flushes. |
maxBatchSize Type: Number Default: 100 |
The maximum number of queued messages to be flushed as part of a single batch (must be higher than flushAt). |
maxQueueSize Type: Number Default: 1000 |
The maximum number of cached messages either in memory or on the local storage (must be higher than flushAt). |
disabled Type: Boolean Default: false |
If set to true, the SDK is essentially disabled (useful for local environments where you don't want to track anything). |
defaultOptIn Type: Boolean Default: true |
If set to false, the SDK will not track until the optIn() function is called. |
sendFeatureFlagEvent Type: Boolean Default: true |
Whether to track that getFeatureFlag was called (used by experiments). |
preloadFeatureFlags Type: Boolean Default: true |
Whether to load feature flags when initialized or not. |
bootstrap Type: Object Default: {} |
Seeds identity (distinctId, isIdentifiedId) and feature flag state (featureFlags, featureFlagPayloads) during initialization. See SDK bootstrapping (/docs/libraries/bootstrapping.md). |
disableRemoteFeatureFlags Type: Boolean Default: false |
When true, the SDK never fetches or evaluates feature flags from PostHog, and identify(), group(), and reset() stop triggering /flags requests. Supply flag values yourself via bootstrap (at startup) and updateFlags() (at runtime). Available in version 4.49.0+. |
fetchRetryCount Type: Number Default: 3 |
How many times HTTP requests will be retried. |
fetchRetryDelay Type: Number Default: 3000 |
The delay between HTTP request retries. |
requestTimeout Type: Number Default: 10000 |
Timeout in milliseconds for any calls. |
featureFlagsRequestTimeoutMs Type: Number Default: 10000 |
Timeout in milliseconds for feature flag calls. |
sessionExpirationTimeSeconds Type: Number Default: 1800 |
For session analysis, how long before a session expires (defaults to 30 minutes). |
persistence Type: String Default: file |
Allows you to provide the storage type. file will try to load the best available storage, the provided customStorage, customAsyncStorage, or in-memory storage. |
customAppProperties Type: Object or Function Default: null |
Allows you to provide your own implementation of the common information about your App or a function to modify the default App properties generated. |
customStorage Type: Object Default: null |
Allows you to provide a custom asynchronous storage such as async-storage, expo-file-system, or a synchronous storage such as mmkv. If not provided, PostHog will attempt to use the best available storage via optional peer dependencies. If persistence is set to memory, this option is ignored. |
captureAppLifecycleEvents Type: Boolean Default: true |
Captures app lifecycle events such as Application Installed, Application Updated, Application Opened, Application Became Active, and Application Backgrounded. Enabled by default since version 4.39.0. |
disableGeoip Type: Boolean Default: false |
When true, disables automatic GeoIP resolution for events and feature flags. |
enableSessionReplay Type: Boolean Default: false |
Enable Recording of Session replay for Android and iOS. |
sessionReplayConfig Type: Object Default: null |
Session replay configuration. See the replay install docs (/docs/session-replay/installation.md) for more details. |
enablePersistSessionIdAcrossRestart Type: Boolean Default: false |
When true, persists the $session_id across app restarts. If false, $session_id always resets on app restart. |
evaluationContexts Type: Array of Strings Default: undefined |
Evaluation context tags that constrain which feature flags are evaluated. When set, only flags with matching evaluation context tags (or no evaluation context tags) will be returned. This helps reduce unnecessary flag evaluations and improves performance. See evaluation contexts documentation (/docs/feature-flags/evaluation-contexts.md) for more details. Available in version 4.21.0+. The legacy parameter evaluationEnvironments (version 4.10.0+) is also supported for backward compatibility. |
addTracingHeaders Type: Array of Strings Default: undefined |
Hostnames for which PostHog should add tracing headers to outgoing fetch requests. Matching requests include X-POSTHOG-DISTINCT-ID and X-POSTHOG-SESSION-ID, which lets backend events, errors, and LLM traces link back to frontend sessions and replays. Use hostnames only, without the protocol or path. |
before_send Type: Function Default: undefined |
A callback function that is called before each event is sent to PostHog. You can use it to modify, filter, or suppress events. Return null to drop the event, or return the modified event to send it. See customizing exception capture (#customizing-exception-capture-with-before_send) for details. |
capturePushNotificationSubscriptions Type: Boolean Default: true |
Whether to automatically register this device's push token so Workflows (/docs/workflows.md) can target it. Requires @posthog/react-native-plugin. See push notifications (#push-notifications). Available in version 4.62.0+. |
capturePushNotificationOpened Type: Boolean Default: true |
Whether to automatically capture $push_notification_opened when the user taps a push notification. Requires @posthog/react-native-plugin. See push notifications (#push-notifications). Available in version 4.62.0+. |
pushIdentityProvider Type: Function Default: undefined |
Supplies a signed identity-verification token for push subscription requests. Only needed when your push channel requires identity verification. See identity verification (#identity-verification). Available in version 4.62.0+. |
Tracing headers
Use addTracingHeaders to connect React Native network requests to backend events, errors, and LLM traces captured by a server-side PostHog SDK:
typescript
const posthog = new PostHog('<ph_project_token>', {
host: 'https://us.i.posthog.com',
addTracingHeaders: ['api.example.com'],
})Hostnames are matched exactly. The SDK patches global fetch and sends X-POSTHOG-DISTINCT-ID and X-POSTHOG-SESSION-ID on matching requests when those values are available.
Capturing events
You can send custom events using capture:
React Native
posthog.capture('user_signed_up')Tip: We recommend using a
[object] [verb]format for your event names, where[object]is the entity that the behavior relates to, and[verb]is the behavior itself. For example,project created,user signed up, orinvite sent.
Setting event properties
Optionally, you can include additional information with the event by including a properties (/docs/data/events.md#event-properties) object:
React Native
posthog.capture('user_signed_up', {
login_type: "email",
is_free_trial: true
})Capturing screen views
With @react-navigation/native and autocapture:
When using @react-navigation/native v6 or lower, screen tracking is automatically captured if the autocapture (/docs/libraries/react-native.md#autocapture) property is used in the PostHogProvider:
It is important that the PostHogProvider is configured as a child of the NavigationContainer:
React Native
// App.(js|ts)
import { PostHogProvider } from 'posthog-react-native'
import { NavigationContainer } from '@react-navigation/native'
export function App() {
return (
<NavigationContainer>
<PostHogProvider apiKey="<ph_project_token>" autocapture>
{/* Rest of app */}
</PostHogProvider>
</NavigationContainer>
)
}When using @react-navigation/native v7 or higher, screen tracking has to be manually captured:
React Native
// App.(js|ts)
import { PostHogProvider } from 'posthog-react-native'
import { NavigationContainer } from '@react-navigation/native'
// Using `PostHogProvider` is optional, but needed if you want to capture touch events automatically with the `captureTouches` option.
export function App() {
return (
<NavigationContainer>
<PostHogProvider apiKey="<ph_project_token>" autocapture={{
captureScreens: false, // Screen events are handled differently for v7 and higher
captureTouches: true,
}}>
{/* Rest of app */}
</PostHogProvider>
</NavigationContainer>
)
}Check out and set it up the official way for Screen tracking for analytics.
Then call the screen method within the trackScreenView method.
React Native
const posthog = usePostHog() // use the usePostHog hook if using the PostHogProvider or your own custom posthog instance
// you can read the params from `getCurrentRoute()`
posthog.screen(currentRouteName, params)With react-native-navigation and autocapture:
First, simplify the wrapping of your screens with a shared PostHogProvider:
React Native
import PostHog, { PostHogProvider } from 'posthog-react-native'
import { Navigation } from 'react-native-navigation';
export const posthog = new PostHog('<ph_project_token>');
export const SharedPostHogProvider = (props: any) => {
return (
<PostHogProvider client={posthog} autocapture={{
captureScreens: false, // Screen events are handled differently for react-native-navigation
captureTouches: true,
}}>
{props.children}
</PostHogProvider>
);
};Then, every screen needs to be wrapped with this provider if you want to capture touches or use the usePostHog() hook
React Native
export const MyScreen = () => {
return (
<SharedPostHogProvider>
<View>
...
</View>
</SharedPostHogProvider>
);
};
Navigation.registerComponent('Screen', () => MyScreen);
Navigation.events().registerAppLaunchedListener(async () => {
posthog.initReactNativeNavigation({
navigation: {
// (Optional) Set the name based on the route. Defaults to the route name.
routeToName: (name, properties) => name,
// (Optional) Tracks all passProps as properties. Defaults to undefined
routeToProperties: (name, properties) => properties,
},
captureScreens: true,
});
});With expo-router:
Check out and set it up the official way for Screen tracking for analytics.
Then call the screen method within the useEffect callback.
React Native
const posthog = usePostHog() // use the usePostHog hook if using the PostHogProvider or your own custom posthog instance
posthog.screen(pathname, params)Manually capturing screen capture events
If you prefer not to use autocapture, you can manually capture screen views by calling posthog.screen(). This function requires a name. You may also pass in an optional properties object.
JavaScript
posthog.screen('dashboard', {
background: 'blue',
hero: 'superhog',
})Autocapture
PostHog autocapture can automatically track the following events for you:
- Application Opened – when the app is opened from a closed state
- Application Became Active – when the app comes to the foreground (e.g. from the app switcher)
- Application Backgrounded – when the app is sent to the background by the user
- Application Installed – when the app is installed.
- Application Updated – when the app is updated.
- $screen – when the user navigates (if using
@react-navigation/native(v6 or lower) orreact-native-navigation), check out the capturing screen views (/docs/libraries/react-native.md#capturing-screen-views) section - $autocapture – touch events when the user interacts with the screen
- $exception – when the app throws exceptions.
⚠️ React Navigation v7 users
React Navigation v7 restricts navigation hooks (such as
useNavigationState) to components rendered inside a Screen that belongs to a Navigator.Because of this change, automatic screen tracking may throw errors if PostHog is initialized outside a screen context. This commonly affects apps upgrading from React Navigation v6 to v7.
For React Navigation v7, we recommend disabling automatic screen capture for screens and manually calling
posthog.screen()inside each screen component. See the Capturing screen views (/docs/libraries/react-native.md#capturing-screen-views) section below.
Application lifecycle events are enabled by default. Screen capture is enabled by default in PostHogProvider unless you set captureScreens: false. Touch capture is disabled by default and requires captureTouches: true.
When touch capture is enabled, touch events for children of PostHogProvider are tracked, capturing a snapshot of the view hierarchy at that point. This enables you to create insights (/docs/product-analytics/insights.md) in PostHog without adding custom events.
PostHog will try to generate a sensible name for touched elements based on the React component displayName or name. If you prefer, you can set your own name using the ph-label prop:
React Native
<View ph-label="my-special-label"></View>Autocapture configuration
React Native
<PostHogProvider apiKey="<ph_project_token>" autocapture={{
captureTouches: true, // Disabled by default
captureScreens: true, // Enabled by default
ignoreLabels: [], // Any labels here will be ignored from the stack in touch events
customLabelProp: "ph-label",
maxElementsCaptured: 20,
noCaptureProp: "ph-no-capture",
propsToCapture: ["testID"], // Limit which props are captured. By default, identifiers and text content are captured.
navigation: {
// By default, only the screen name is tracked but it is possible to track the
// params or modify the name by intercepting the autocapture like so
routeToName: (name, params) => {
if (params.id) return `${name}/${params.id}`
return name
},
routeToProperties: (name, params) => {
if (name === "SensitiveScreen") return undefined
return params
},
},
}}>
...
</PostHogProvider>Preventing sensitive data capture
If there are elements you don't want to be captured, you can add the ph-no-capture property. If this property is found anywhere in the view hierarchy, the entire touch event is ignored:
React Native
<View ph-no-capture>Sensitive view here</View>Capturing screen views
With captureScreens: true (the default in PostHogProvider), PostHog captures a $screen event automatically when the user navigates, provided you're using @react-navigation/native (v6 or lower) or react-native-navigation.
To manually send a screen capture event, use the screen method:
React Native
posthog.screen('Dashboard', { fromIcon: 'bottom' })React Navigation v7 users: automatic screen tracking may throw errors if PostHog is initialized outside a screen context. For v7, disable automatic screen capture (
captureScreens: false) and callposthog.screen()manually inside each screen component.
Filtering autocaptured screens
You can stop specific screens from being autocaptured by filtering them in your before-send hook. Return null for any $screen event whose $screen_name matches a screen you don't want to track, and it's dropped before being sent – keeping unwanted screen views out of your event log.
Because it's just a function, you can filter however you like – an ignorelist (drop the screens you name), an allowlist (invert the check to capture only the screens you name), or any custom rule such as a name prefix, a regex, or a check against the event's properties.
before_send is a client option, so pass it via the provider's options prop (or to new PostHog(...) if you create the client yourself):
React Native
const IGNORED_SCREENS = new Set(['Splash', 'Debug'])
<PostHogProvider
apiKey="<ph_project_token>"
autocapture={{ captureScreens: true }}
options={{
host: 'https://us.i.posthog.com',
before_send: (event) => {
if (event?.event === '$screen') {
const screenName = event.properties?.['$screen_name']
return IGNORED_SCREENS.has(screenName) ? null : event
}
return event
},
}}
>
{/* app */}
</PostHogProvider>Swap the check for an allowlist (return TRACKED_SCREENS.has(screenName) ? event : null) if you'd rather capture only a specific set of screens.
Common before_send patterns
before_send accepts a single function or an array of functions that run in order, so you can compose several small hooks. Filtering screens is one use – here are a few others.
Drop a specific event. Stop an internal or debug event from ever being sent:
React Native
const posthog = new PostHog('<ph_project_token>', {
before_send: (event) => {
if (event?.event === 'debug_only_event') {
return null // never send this event
}
return event
},
})Log events instead of sending them. Handy while debugging what would be captured:
React Native
const posthog = new PostHog('<ph_project_token>', {
before_send: (event) => {
console.log('[PostHog] would send', event?.event, event?.properties)
return null // drop everything
},
})Redact sensitive properties. Strip a value before it leaves the device:
React Native
const posthog = new PostHog('<ph_project_token>', {
before_send: (event) => {
if (event?.properties?.email) {
event.properties.email = '***'
}
return event
},
})For more examples, see the JavaScript Web SDK docs (/docs/libraries/js/usage.md#amending-or-sampling-events).
Identifying users
We highly recommend reading our section on Identifying users (/docs/integrate/identifying-users.md) to better understand how to correctly use this method.
Using identify, you can associate events with specific users. This enables you to gain full insights as to how they're using your product across different sessions, devices, and platforms.
An identify call has the following arguments:
- distinctId: Required. A unique identifier for your user. Typically either their email or database ID.
- properties: Optional. A dictionary with key:value pairs to set the person properties (/docs/product-analytics/person-properties.md)
React Native
posthog.identify('distinctID',
{ // ($set):
email: 'user@posthog.com',
name: 'My Name'
}
)$set_once works just like $set, except that it will only set the property if the user doesn't already have that property set. See the difference between $set and $set_once (/docs/product-analytics/person-properties.md#what-is-the-difference-between-set-and-set_once)
React Native
posthog.identify('distinctID',
{
$set: {
email: 'user@posthog.com',
name: 'My Name'
},
$set_once: {
date_of_first_log_in: '2024-03-01'
}
}
)You should call identify as soon as you're able to. Typically, this is after your user logs in. This ensures that events sent during your user's sessions are correctly associated with them.
When you call identify, all previously tracked anonymous events (/docs/data/anonymous-vs-identified-events.md) will be linked to the user.
Get the current user's distinct ID
You may find it helpful to get the current user's distinct ID. For example, to check whether you've already called identify for a user or not.
To do this, call posthog.get_distinct_id(). This returns either the ID automatically generated by PostHog or the ID that has been passed by a call to identify().
Alias
Sometimes, you want to assign multiple distinct IDs to a single user. This is helpful when your primary distinct ID is inaccessible. For example, if a distinct ID used on the frontend is not available in your backend.
In this case, you can use alias to assign another distinct ID to the same user.
React Native
// Sets alias for current user
posthog.alias('distinct_id')We strongly recommend reading our docs on alias (/docs/product-analytics/identify.md#alias-assigning-multiple-distinct-ids-to-the-same-user) to best understand how to correctly use this method.
Setting person properties
Person properties enable you to capture, manage, and analyze specific data about a user. You can use them to create filters (/docs/product-analytics/trends.md#filtering-events-based-on-properties) or cohorts (/docs/data/cohorts.md), which can then be used in insights (/docs/product-analytics/insights.md), feature flags (/docs/feature-flags.md), and more.
To set a user's properties, include the $set or $set_once property when capturing any event:
$set
JavaScript
posthog.capture('some_event', { $set: { userProperty: 'value' } })$set_once
$set_once works just like $set, except it only sets the property if the user doesn't already have that property set.
JavaScript
posthog.capture('some_event', { $set_once: { userProperty: 'value' } })You can also use setPersonProperties() and unsetPersonProperties() to manage person properties directly. See person properties (/docs/product-analytics/person-properties.md) for examples.
Super properties
Super properties are properties associated with events that are set once and then sent with every capture call, be it a $screen, an autocaptured touch, or anything else.
They are set using posthog.register, which takes a properties object as a parameter, and they persist across sessions.
For example:
JavaScript
posthog.register({
'icecream pref': 'vanilla',
team_id: 22,
})The call above ensures that every event sent by the user will include "icecream pref": "vanilla" and "team_id": 22. This way, if you filtered events by property using icecream_pref = vanilla, it would display all events captured on that user after the posthog.register call, since they all include the specified Super Property.
This does not set the user's properties. This only sets the properties for their events. To store person properties, see the setting person properties section (#setting-user-properties).
Removing stored super properties
Super Properties are persisted across sessions so you have to explicitly remove them if they are no longer relevant. In order to stop sending a Super Property with events, you can use posthog.unregister, like so:
JavaScript
posthog.unregister('icecream pref'),This will remove the super property and subsequent events will not include it.
If you are doing this as part of a user logging out you can instead simply posthog.reset() (#reset-after-logout) which takes care of clearing all stored Super Properties and more.
Opt out of data capture
You can completely opt users out from data capture by default or on a per-person basis. See Opt in/out (#opt-inout) for the current React Native API.
Flush
You can configure how many events queue before flushing with flushAt. Setting this to 1 will send events immediately and will use more battery. The default is 20.
You can also configure the flush interval with flushInterval, in milliseconds (default 10000), after which queued events are sent regardless of how many have been gathered:
JavaScript
const posthog = new PostHog('<ph_project_token>', {
flushAt: 20,
flushInterval: 10000,
})You can also manually flush the queue to start sending events immediately instead of waiting for the next batch:
JavaScript
await posthog.flush()If a flush is already in progress, it returns a promise for the existing flush.
Flushing is best-effort and asynchronous – it starts sending queued events in the background but doesn't wait for the request to finish, so it isn't a delivery guarantee.
Reset after logout
To reset the user's ID and anonymous ID, call reset. Usually you would do this right after the user logs out.
JavaScript
posthog.reset()Offline behavior
The PostHog React Native SDK will continue to capture events when the device is offline. When persistence is set to file (by default), the events are stored in a queue in the device's file storage. Even when the app is closed, the events are persisted and will be flushed when the app is opened again.
- The queue has a maximum size defined by
maxQueueSizein the configuration. - When the queue is full, the oldest event is deleted first.
- The queue is flushed only when the device is online.
Opt in/out
By default, PostHog has tracking enabled unless it is forcefully disabled by default using the option { defaultOptIn: false }.
You can give your users the option to opt in or out by calling the relevant methods. Once these have been called they are persisted and will be respected until optIn/Out is called again or the reset function is called.
To opt in/out of tracking, use the following calls.
JavaScript
posthog.optedOut // See if a user has opted out
posthog.optIn() // opt in
posthog.optOut() // opt outIf you still wish capture these events but want to create a distinction between users and team in PostHog, you should look into Cohorts (/docs/user-guides/cohorts.md#differentiating-team-vs-users-traffic).
Feature Flags
PostHog's feature flags (/docs/feature-flags.md) enable you to safely deploy and roll back new features as well as target specific users and groups with them.
There are two ways to implement feature flags in React Native:
- Using hooks.
- Loading the flag directly.
Method 1: Using hooks
Example 1: Boolean feature flags
React Native
import { useFeatureFlag } from 'posthog-react-native'
const MyComponent = () => {
const booleanFlag = useFeatureFlag('key-for-your-boolean-flag')
if (booleanFlag === undefined) {
// the response is undefined if the flags are being loaded
return null
}
// Optional use the 'useFeatureFlagWithPayload' hook for fetching the feature flag payload
return booleanFlag ? <Text>Testing feature 😄</Text> : <Text>Not Testing feature 😢</Text>
}Example 2: Multivariate feature flags
React Native
import { useFeatureFlag } from 'posthog-react-native'
const MyComponent = () => {
const multiVariantFeature = useFeatureFlag('key-for-your-multivariate-flag')
if (multiVariantFeature === undefined) {
// the response is undefined if the flags are being loaded
return null
} else if (multiVariantFeature === 'variant-name') { // replace 'variant-name' with the name of your variant
// Do something
}
// Optional use the 'useFeatureFlagWithPayload' hook for fetching the feature flag payload
return <div/>
}Method 2: Loading the flag directly
React Native
// Defaults to undefined if not loaded yet or if there was a problem loading
posthog.isFeatureEnabled('key-for-your-boolean-flag')
// Defaults to undefined if not loaded yet or if there was a problem loading
posthog.getFeatureFlag('key-for-your-boolean-flag')
// Multivariant feature flags are returned as a string
posthog.getFeatureFlag('key-for-your-multivariate-flag')
// Optional: fetch the payload (returns 'JsonType' or undefined if not loaded yet or if there was a problem loading)
posthog.getFeatureFlagResult('key-for-your-multivariate-flag')?.payloadInspecting all feature flags
You can inspect all currently loaded feature flags with getAllFeatureFlags(). It returns each flag's key, enabled state, variant, and payload, and does not send a $feature_flag_called event, so calling it won't affect your experiment results or flag usage analytics:
React Native
for (const flag of posthog.getAllFeatureFlags()) {
console.log(flag.key, flag.enabled, flag.variant, flag.payload)
}Ensuring flags are loaded before usage
Every time a user opens the app, we send a request in the background to fetch the feature flags that apply to that user. We store those flags in the storage.
This means that for most screens, the feature flags are available immediately — except for the first time a user visits.
To handle this, you can use the onFeatureFlags callback to wait for the feature flag request to finish:
React Native
posthog.onFeatureFlags((flags) => {
// feature flags are guaranteed to be available at this point
if (posthog.isFeatureEnabled('flag-key')) {
// do something
}
})Reloading flags
PostHog loads feature flags when instantiated and refreshes whenever methods are called that affect the flag.
If want to manually trigger a refresh, you can call reloadFeatureFlagsAsync():
React Native
posthog.reloadFeatureFlagsAsync().then((refreshedFlags) => console.log(refreshedFlags))Or when you want to trigger the reload, but don't care about the result:
React Native
posthog.reloadFeatureFlags()Feature flag caching
The React Native SDK caches feature flag values in AsyncStorage. Cached values persist indefinitely with no TTL until updated by a successful API call. This enables offline support and reduces latency, but means inactive users may see stale flag values from their last session.
For example, if a user last opened your app when a flag was false, that value remains cached even after you roll it out to 100%. When they reopen the app, the SDK returns the cached false first, then fetches the fresh true value from the API.
To ensure fresh flag values:
React Native
// Force refresh on app start
await posthog.reloadFeatureFlagsAsync()Or clear cached values for inactive users:
React Native
if (lastActiveDate < migrationDate) {
posthog.reset() // Clears all cached data
}Request timeout
You can configure the featureFlagsRequestTimeoutMs parameter when initializing your PostHog client to set a flag request timeout. This helps prevent your code from being blocked in the case when PostHog's servers are too slow to respond. By default, this is set at 10 seconds.
React Native
export const posthog = new PostHog('<ph_project_token>', {
// usually 'https://us.i.posthog.com' or 'https://eu.i.posthog.com'
host: 'https://us.i.posthog.com',
featureFlagsRequestTimeoutMs: 10000 // Time in milliseconds. Default is 10000 (10 seconds).
})Error handling
When using the PostHog SDK, it's important to handle potential errors that may occur during feature flag operations. Here's an example of how to wrap PostHog SDK methods in an error handler:
React Native
function handleFeatureFlag(client, flagKey, distinctId) {
try {
const isEnabled = client.isFeatureEnabled(flagKey, distinctId);
console.log(`Feature flag '${flagKey}' for user '${distinctId}' is ${isEnabled ? 'enabled' : 'disabled'}`);
return isEnabled;
} catch (error) {
console.error(`Error fetching feature flag '${flagKey}': ${error.message}`);
// Optionally, you can return a default value or throw the error
// return false; // Default to disabled
throw error;
}
}
// Usage example
try {
const flagEnabled = handleFeatureFlag(client, 'new-feature', 'user-123');
if (flagEnabled) {
// Implement new feature logic
} else {
// Implement old feature logic
}
} catch (error) {
// Handle the error at a higher level
console.error('Feature flag check failed, using default behavior');
// Implement fallback logic
}Overriding server properties
Sometimes, you might want to evaluate feature flags using properties that haven't been ingested yet, or were set incorrectly earlier. You can do so by setting properties the flag depends on with these calls:
React Native
posthog.setPersonPropertiesForFlags({'property1': 'value', property2: 'value2'})Note that these are set for the entire session. Successive calls are additive: all properties you set are combined together and sent for flag evaluation.
Whenever you set these properties, we also trigger a reload of feature flags to ensure we have the latest values. You can disable this by passing in the optional parameter for reloading:
React Native
posthog.setPersonPropertiesForFlags({'property1': 'value', property2: 'value2'}, false)At any point, you can reset these properties by calling resetPersonPropertiesForFlags:
React Native
posthog.resetPersonPropertiesForFlags()The same holds for group (/docs/product-analytics/group-analytics.md) properties:
React Native
// set properties for a group
posthog.setGroupPropertiesForFlags({'company': {'property1': 'value', property2: 'value2'}})
// reset properties for all groups:
posthog.resetGroupPropertiesForFlags()Note: You don't need to add the group names here, since these properties are automatically attached to the current group (set via
posthog.group()). When you change the group, these properties are reset.
Automatic overrides
Whenever you call posthog.identify with person properties, we automatically add these properties to flag evaluation calls to help determine the correct flag values. The same is true for when you call posthog.group().
Default overridden properties
By default, we always override some properties based on the user IP address.
The list of properties that this overrides:
- $geoip_city_name
- $geoip_country_name
- $geoip_country_code
- $geoip_continent_name
- $geoip_continent_code
- $geoip_postal_code
- $geoip_time_zone
This enables any geolocation-based flags to work without manually setting these properties.
Bootstrapping flags
Since there is a delay between initializing PostHog and fetching feature flags, feature flags are not always available immediately. This makes them unusable if you want to do something like redirecting a user to a different page based on a feature flag.
To have your feature flags available immediately, you can initialize PostHog with precomputed values until it has had a chance to fetch them. This is called bootstrapping. After the SDK fetches feature flags from PostHog, it will use those flag values instead of bootstrapped ones.
Pass bootstrap in the initialization options to seed identity and flag values:
React Native
<PostHogProvider
apiKey="<ph_project_token>"
options={{
host: 'https://us.i.posthog.com',
bootstrap: {
distinctId: 'distinct_id_of_your_user',
isIdentifiedId: true,
featureFlags: {
'flag-1': true,
'variant-flag': 'control',
},
},
}}
>
<MyComponent />
</PostHogProvider>See bootstrapping Feature Flags (/docs/feature-flags/bootstrapping.md) for server-side evaluation and flag lifecycle, and SDK bootstrapping (/docs/libraries/bootstrapping.md) for cross-SDK identity behavior.
Supplying flags from your own backend
If you evaluate feature flags outside the SDK – for example on your own server with posthog-node local evaluation (/docs/feature-flags/local-evaluation.md), then pass the results into your app – you can have the SDK use those values and never fetch flags itself.
Set disableRemoteFeatureFlags: true so the SDK never requests /flags (including the refetches that identify(), group(), and reset() normally trigger), then push your evaluated flags at runtime with updateFlags(flags, payloads?, { merge }):
React Native
const posthog = new PostHog('<ph_project_token>', {
host: 'https://us.i.posthog.com',
// Don't fetch or evaluate flags on-device – we supply them ourselves.
disableRemoteFeatureFlags: true,
// Optional: values that must be available at startup, before updateFlags() runs.
// Without this, reads return their not-loaded defaults until you push flags.
bootstrap: {
featureFlags: { 'my-flag': true },
featureFlagPayloads: { 'my-flag': { color: 'blue' } },
},
})
// Later – e.g. after login, once your backend has evaluated flags for this user:
posthog.updateFlags(
{ 'my-flag': true, 'my-variant-flag': 'test' },
{ 'my-flag': { color: 'blue' } }
)
posthog.getFeatureFlag('my-variant-flag') // 'test'
posthog.getFeatureFlagResult('my-flag')?.payload // { color: 'blue' }updateFlags replaces the stored flags by default; pass { merge: true } to merge into the existing set instead. Values persist across app restarts, and getFeatureFlag() / getFeatureFlagResult() read them back like any other flag.
Note that reset() (called on logout) clears the supplied flags, so re-push them with updateFlags() after the next identity change. Use bootstrap for any flag values that must be available at startup before updateFlags() runs.
Experiments (A/B tests)
Since experiments (/docs/experiments/start-here.md) use feature flags, the code for running an experiment is very similar to the feature flags code. See adding experiment code (/docs/experiments/adding-experiment-code.md) for React Native examples.
It's also possible to run experiments without using feature flags (/docs/experiments/running-experiments-without-feature-flags.md).
Group analytics
Group analytics allows you to associate the events for that person's session with a group (e.g. teams, organizations, etc.). See Group Analytics (/docs/product-analytics/group-analytics.md) for implementation details.
Note: This is a paid feature and is not available on the open-source or free cloud plan. Learn more on the pricing page (/pricing.md).
Error tracking
To set up error tracking in your project, see the error tracking docs (/docs/error-tracking.md).
Native crash autocapture
The JavaScript-level autocapture only covers exceptions thrown in your JS/TS code. To also capture native iOS and Android crashes – for example, a crash inside a native module or the platform runtime – install the optional @posthog/react-native-plugin package and enable errorTracking.autocapture.nativeCrashes. Native capture is gated by your project's Enable exception autocapture setting, and crash reports need native debug symbols uploaded at build time to produce readable stack traces.
Follow the React Native installation guide (/docs/error-tracking/installation/react-native.md) for the full setup, and native crash symbolication (/docs/error-tracking/upload-source-maps/react-native.md#native-crash-symbolication) to upload symbols.
Error boundaries
You can use the PostHogErrorBoundary component to capture React rendering errors thrown by components:
React Native
import { PostHogProvider, PostHogErrorBoundary } from 'posthog-react-native'
import { View, Text } from 'react-native'
const App = () => {
return (
<PostHogProvider apiKey="<ph_project_token>">
<PostHogErrorBoundary
fallback={YourFallbackComponent}
additionalProperties={{ screen: "home" }}
>
<YourApp />
</PostHogErrorBoundary>
</PostHogProvider>
)
}
const YourFallbackComponent = ({ error, componentStack }) => {
return (
<View>
<Text>Something went wrong!</Text>
<Text>{error instanceof Error ? error.message : String(error)}</Text>
</View>
)
}The fallback prop accepts a component to render when an error occurs. The additionalProperties prop lets you add custom properties to the captured error event.
Duplicate errors with console capture
If you have both PostHogErrorBoundary and console capture enabled in your errorTracking config, render errors will be captured twice. This is because React logs all errors to the console by default. To avoid this, set console: [] on errorTracking.autocapture (for example, errorTracking: { autocapture: { console: [] } }) when using PostHogErrorBoundary.
Customizing exception capture with before_send
You can use the before_send callback to modify, filter, or suppress exception events before they are sent to PostHog. This is useful for:
- Adding custom properties to exceptions
- Overriding exception fingerprints for custom grouping
- Suppressing specific types of exceptions
- Redacting sensitive information
React Native
const posthog = new PostHog('<ph_project_token>', {
host: 'https://us.i.posthog.com',
before_send: (event) => {
if (event.event === '$exception') {
const exceptionList = event.properties?.['$exception_list'] || []
const exception = exceptionList.length > 0 ? exceptionList[0] : null
if (exception) {
// Add custom properties
event.properties['custom_property'] = 'custom_value'
// Override fingerprint for custom grouping
event.properties['$exception_fingerprint'] = 'MyCustomGroup'
}
// Suppress specific exception types
if (exception?.['$exception_type'] === 'IgnoredError') {
return null // Drop the event
}
}
return event
},
})You can also use before_send to sample or filter other event types. See the JavaScript Web SDK documentation (/docs/libraries/js/usage.md#amending-or-sampling-events) for more examples.
Logs
To set up logs (/docs/logs.md) in your React Native app, follow the React Native logs installation guide (/docs/logs/installation/react-native.md). The SDK exposes posthog.captureLog, posthog.logger.{trace,debug,info,warn,error,fatal}, and posthog.flushLogs for sending structured records to PostHog Logs.
Minimum version:
posthog-react-native@4.44.0or later.
Session replay
To set up session replay (/docs/session-replay/mobile.md) in your project, all you need to do is install the React Native SDK and the Session replay plugin, then follow the instructions to enable Session Replay (/docs/session-replay/installation/react-native.md) for React Native.
Surveys
To set up surveys, follow the additional installation instructions for React Native (/docs/surveys/installation/react-native.md). Surveys launched with popover presentation (/docs/surveys/creating-surveys.md#presentation) are automatically shown to users matching the display conditions (/docs/surveys/creating-surveys.md#display-conditions) you set up.
Note: URL and CSS selector targeting are not supported in React Native. Surveys that rely on these conditions will not appear.
Push notifications
The React Native SDK can register a device for Workflows (/docs/workflows.md) push notifications and capture when a user opens one. For setup, including automatic and manual registration, capturing opens, opting out, and identity verification, see Push notifications (/docs/workflows/push-notifications.md).
Debug mode
If you're not seeing the expected events being captured, the feature flags being evaluated, or the surveys being shown, you can enable debug mode to see what's happening.
You can enable debug mode by setting the debug option to true in the PostHogProvider options. This will enable verbose logs about the inner workings of the SDK.
React Native
<PostHogProvider
debug={true}
apiKey="<ph_project_token>"
options={{
host: "https://us.i.posthog.com",
}}
>You can also call the debug() method in your code.
React Native
posthog.debug()Disabling for local development
You may want to disable PostHog when working locally or in a test environment. You can do this by setting the disable option to true when initializing PostHog. Helpfully this allows you to continue using usePostHog and safely calling it without anything actually happening.
React Native
// App.(js|ts)
import { usePostHog, PostHogProvider } from 'posthog-react-native'
...
export function MyApp() {
return (
<PostHogProvider apiKey="<ph_project_token>" options={{
// Disable PostHog in development (or whatever other logic you choose)
disabled: __DEV__,
}}>
<MyComponent />
</PostHogProvider>
)
}
const MyComponent = () => {
const posthog = usePostHog()
useEffect(() => {
// Safe to call even when disabled!
posthog.capture("mycomponent_loaded", { foo: "bar" })
}, [])
}Upgrading from V1, V2 to V3 or V3 to V4
V1 of this library utilised the underlying posthog-ios and posthog-android SDKs to do most of the work. Since the new version is written entirely in JS, using only Expo supported libraries, there are some changes to the way PostHog is configured as well as actually calling PostHog.
For iOS, the new React Native SDK will attempt to migrate the previously persisted data (such as distinctId and anonymousId) which should result in no unexpected changes to tracked data.
For Android, it is unfortunately not possible for persisted Android data to be loaded which means stored information such as the randomly generated anonymousId or the distinctId set by posthog.identify will not be present. For identified users, the simple workaround is to ensure that identify is called at least once when the app loads. For anonymous users there is unfortunately no straightforward workaround they will show up as new anonymous users in PostHog.
Events such as Application Installed and Application Updated that require previously persisted data were unable to be migrated, the side effect being that you may see much higher numbers for Application Installed events. This is due to the fact that there is no native way of detecting a real "install" and as such, we store a marker the first time the SDK loads and treat that as an install.
JSX
// DEPRECATED V1 Setup
import PostHog from 'posthog-react-native'
await PostHog.setup('<ph_project_token>', {
// usually 'https://us.i.posthog.com' or 'https://eu.i.posthog.com'
host: 'https://us.i.posthog.com',
captureApplicationLifecycleEvents: false, // Replaced by 'PostHogProvider'
captureDeepLinks: false, // No longer supported
recordScreenViews: false, // Replaced by 'PostHogProvider' supporting @react-navigation/native
flushInterval: 30, // Stays the same
flushAt: 20, // Stays the same
android: {...}, // No longer needed
iOS: {...}, // No longer needed
})
PostHog.capture("foo")
// V2 Setup difference
import PostHog from 'posthog-react-native'
const posthog = await Posthog.initAsync('<ph_project_token>', {
// usually 'https://us.i.posthog.com' or 'https://eu.i.posthog.com'
host: 'https://us.i.posthog.com',
// Add any other options here.
})
// Use created instance rather than the PostHog class
posthog.capture("foo")
// V3 Setup difference
import PostHog from 'posthog-react-native'
const posthog = new PostHog('<ph_project_token>', {
// usually 'https://us.i.posthog.com' or 'https://eu.i.posthog.com'
host: 'https://us.i.posthog.com',
// Add any other options here.
})
// Use created instance rather than the PostHog class
posthog.capture("foo")
// V4 Setup difference
import PostHog from 'posthog-react-native'
const posthog = new PostHog('<ph_project_token>', {
// usually 'https://us.i.posthog.com' or 'https://eu.i.posthog.com'
host: 'https://us.i.posthog.com',
// captureAppLifecycleEvents is enabled by default since version 4.39.0 (previously named `captureNativeAppLifecycleEvents` or `autocapture={{ captureLifecycleEvents: true }}`)
// captureMode: 'json', // No longer supported
// maskPhotoLibraryImages: true, // No longer supported
})
posthog.setPersonPropertiesForFlags(...) // instead of `personProperties`
posthog.setGroupPropertiesForFlags(...) // instead of `groupProperties`Still have questions?
Ask PostHog AI
Was this page useful?
HelpfulCould be better
references/react-router-v6.md
AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt
React Router V6
This guide walks you through setting up PostHog for React Router V6. If you're using React Router v7, find the guide for that mode in the React Router page (/docs/libraries/react-router.md). If you're using React with another framework, go to the React integration guide (/docs/libraries/react.md).
1
Install client-side SDKs
Required
First, you'll need to install
posthog-jsand@posthog/reactusing your package manager. These packages allow you to capture client-side events.npm
npm install --save posthog-js @posthog/reactYarn
yarn add posthog-js @posthog/reactpnpm
pnpm add posthog-js @posthog/reactBun
bun add posthog-js @posthog/reactIf your site sets a Content-Security-Policy, it needs to allow PostHog. This applies to the snippet and to package installs alike: the SDK lazy-loads extra bundles (session replay, surveys) from PostHog's CDN, and sends events to the ingestion host. PostHog serves from subdomains of
posthog.comthat change over time, so allow the wildcard:script-src 'self' https://*.posthog.com; connect-src 'self' https://*.posthog.com; worker-src 'self' blob: data:;script-srccovers the snippet and the lazy-loaded bundles,connect-srccovers event ingestion and feature flags, andworker-srccovers session replay. The toolbar needs a few more (/docs/advanced/content-security-policy.md), or use a reverse proxy (/docs/advanced/proxy.md) so everything is first-party. Failing to do so causes silent failures wherecaptureandidentifycalls never send, so the integration looks complete while zero events arrive. Rememberconnect-srcfalls back todefault-src, sodefault-src 'self'blocks event delivery even when the script itself is bundled.2
Add your environment variables
Required
Add your environment variables to your
.env.localfile and to your hosting provider (e.g. Vercel, Netlify, AWS). You can find your project token and host in your project settings. If you're using Vite, prefixing variable names withVITE_ensures they are accessible in the frontend..env.local
VITE_POSTHOG_PROJECT_TOKEN=<ph_project_token> VITE_POSTHOG_HOST=https://us.i.posthog.com3
Add the PostHogProvider to your app
Required
In declarative mode, you'll need to wrap your
BrowserRouterwith thePostHogProvidercontext. This passes an initialized PostHog client to your app.src/main.tsx
import { StrictMode } from "react"; import ReactDOM from "react-dom/client"; import { BrowserRouter, Routes, Route } from "react-router"; import posthog from 'posthog-js'; import { PostHogErrorBoundary, PostHogProvider } from '@posthog/react' // Initialize PostHog posthog.init(import.meta.env.VITE_POSTHOG_PROJECT_TOKEN, { api_host: import.meta.env.VITE_POSTHOG_HOST, defaults: '2026-05-30', }); const root = document.getElementById("root"); ReactDOM.createRoot(root).render( <StrictMode> {/* Pass PostHog client through PostHogProvider */} <PostHogProvider client={posthog}> <BrowserRouter> <Routes> <Route path="/" element={<Root />}> {/* ... Your routes ... */} </Route> </Routes> </BrowserRouter> </PostHogProvider> </StrictMode>, );This initializes PostHog and passes it to your app through the
PostHogProvidercontext.TypeError: Cannot read properties of undefined
If you see the error
TypeError: Cannot read properties of undefined (reading '...')this is likely because you tried to call a posthog function when posthog was not initialized (such as during the initial render). On purpose, we still render the children even if PostHog is not initialized so that your app still loads even if PostHog can't load.To fix this error, add a check that posthog has been initialized such as:
React
useEffect(() => { posthog?.capture('test') // using optional chaining (recommended) if (posthog) { posthog.capture('test') // using an if statement } }, [posthog])Typescript helps protect against these errors.
Verify client-side events are captured
Checkpoint
Confirm that you can capture client-side events and see them in your PostHog project
At this point, you should be able to capture client-side events and see them in your PostHog project. This includes basic events like page views and button clicks that are autocaptured (/docs/product-analytics/autocapture.md).
You can also try to capture a custom event to verify it's working. You can access PostHog in any component using the
usePostHoghook.TSX
import { usePostHog } from '@posthog/react' function App() { const posthog = usePostHog() return <button onClick={() => posthog?.capture('button_clicked')}>Click me</button> }You should see these events in a minute or two in the activity tab.
4
Access PostHog methods
Required
On the client-side, you can access the PostHog client using the
usePostHoghook. This hook returns the initialized PostHog client, which you can use to call PostHog methods. For example:TSX
import { usePostHog } from '@posthog/react' function App() { const posthog = usePostHog() return <button onClick={() => posthog?.capture('button_clicked')}>Click me</button> }For a complete list of available methods, see the posthog-js documentation (/docs/libraries/js.md).
5
Identify your user
Recommended
Now that you can capture basic client-side events, you'll want to identify your user so you can associate users with captured events.
Generally, you identify users when they log in or when they input some identifiable information (e.g. email, name, etc.). You can identify users by calling the
identifymethod on the PostHog client:TSX
export default function Login() { const { user, login } = useAuth(); const posthog = usePostHog(); const handleLogin = async (e: React.FormEvent) => { // existing code to handle login... const user = await login({ email, password }); posthog?.identify(user.email, { email: user.email, name: user.name, } ); posthog?.capture('user_logged_in'); }; return ( <div> {/* ... existing code ... */} <button onClick={handleLogin}>Login</button> </div> ); }PostHog automatically generates anonymous IDs for users before they're identified. When you call identify, a new identified person is created. All previous events tracked with the anonymous ID link to the new identified distinct ID, and all future captures on the same browser associate with the identified person.
6
Create an error boundary
Recommended
PostHog can capture exceptions thrown in your app through an error boundary. PostHog provides a
PostHogErrorBoundarycomponent that you can use to capture exceptions. You can wrap your app with this component to capture exceptions.TSX
ReactDOM.createRoot(root).render( <StrictMode> <PostHogProvider client={posthog}> <PostHogErrorBoundary> <BrowserRouter> <Routes> <Route path="/" element={<Root />}> {/* ... Your routes ... */} </Route> </Routes> </BrowserRouter> </PostHogErrorBoundary> </PostHogProvider> </StrictMode>, );This automatically captures exceptions thrown in your React Router app using the
posthog.captureException()method.7
Tracking element visibility
Recommended
The
PostHogCaptureOnViewedcomponent enables you to automatically capture events when elements scroll into view in the browser. This is useful for tracking impressions of important content, monitoring user engagement with specific sections, or understanding which parts of your page users are actually seeing.The component wraps your content and sends a
$element_viewedevent to PostHog when the wrapped element becomes visible in the viewport. It only fires once per component instance.Basic usage:
React
import { PostHogCaptureOnViewed } from '@posthog/react' function App() { return ( <PostHogCaptureOnViewed name="hero-banner"> <div>Your important content here</div> </PostHogCaptureOnViewed> ) }With custom properties:
You can include additional properties with the event to provide more context:
React
<PostHogCaptureOnViewed name="product-card" properties={{ product_id: '123', category: 'electronics', price: 299.99 }} > <ProductCard /> </PostHogCaptureOnViewed>Tracking multiple children:
Use
trackAllChildrento track each child element separately. This is useful for galleries or lists where you want to know which specific items were viewed:React
<PostHogCaptureOnViewed name="product-gallery" properties={{ gallery_type: 'featured' }} trackAllChildren > <ProductCard id="1" /> <ProductCard id="2" /> <ProductCard id="3" /> </PostHogCaptureOnViewed>When
trackAllChildrenis enabled, each child element sends its own event with achild_indexproperty indicating its position.Custom intersection observer options:
You can customize when elements are considered "viewed" by passing options to the
IntersectionObserver:React
<PostHogCaptureOnViewed name="footer" observerOptions={{ threshold: 0.5, // Element is 50% visible rootMargin: '0px' }} > <Footer /> </PostHogCaptureOnViewed>The component passes all other props to the wrapper
div, so you can add styling, classes, or other HTML attributes as needed.8
Set up server-side analytics
Recommended
Now that you've set up PostHog for React Router V7 in declarative mode, you can continue to set up server-side analytics. You can find our other SDKs in the SDKs page (/docs/libraries.md).
To help PostHog track your user sessions across the client and server, you'll need to add the
tracing_headers: ['your-backend-hostname1.com', 'your-backend-hostname2.com', ...]option to your PostHog initialization:TSX
posthog.init(import.meta.env.VITE_POSTHOG_PROJECT_TOKEN, { api_host: import.meta.env.VITE_POSTHOG_HOST, defaults: '2026-05-30', tracing_headers: [ window.location.hostname, 'localhost' ], });This adds the
X-POSTHOG-DISTINCT-IDandX-POSTHOG-SESSION-IDheaders to requests sent to the configured hostnames, which you can later use on the server-side.9
Next steps
Recommended
Now that you've set up PostHog for React Router, you can start capturing events and exceptions in your app.
To get the most out of PostHog, you should familiarize yourself with the following:
- PostHog Web SDK docs (/docs/libraries/js.md): Learn more about the PostHog Web SDK and how to use it on the client-side.
- PostHog Node SDK docs (/docs/libraries/node.md): Learn more about the PostHog Node SDK and how to use it on the server-side.
- Identify users (/docs/product-analytics/identify.md): Learn more about how to identify users in your app.
- Group analytics (/docs/product-analytics/group-analytics.md): Learn more about how to use group analytics in your app.
- PostHog AI (/docs/posthog-ai.md): After capturing events, use PostHog AI to help you understand your data and build insights.
- Feature flags and experiments (/docs/libraries/react.md#feature-flags): Feature flag and experiment setup is the same as React. You can find more details in the React integration guide.
Still have questions?
Ask PostHog AI
Was this page useful?
HelpfulCould be better
references/react-router-v7-data-mode.md
AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt
React Router V7 data mode
This guide walks you through setting up PostHog for React Router V7 in data mode. If you're using React Router in another mode, find the guide for that mode in the React Router page (/docs/libraries/react-router.md). If you're using React with another framework, go to the React integration guide (/docs/libraries/react.md).
1
Install client-side SDKs
Required
First, you'll need to install
posthog-jsand@posthog/reactusing your package manager. These packages allow you to capture client-side events.npm
npm install --save posthog-js @posthog/reactYarn
yarn add posthog-js @posthog/reactpnpm
pnpm add posthog-js @posthog/reactBun
bun add posthog-js @posthog/reactIf your site sets a Content-Security-Policy, it needs to allow PostHog. This applies to the snippet and to package installs alike: the SDK lazy-loads extra bundles (session replay, surveys) from PostHog's CDN, and sends events to the ingestion host. PostHog serves from subdomains of
posthog.comthat change over time, so allow the wildcard:script-src 'self' https://*.posthog.com; connect-src 'self' https://*.posthog.com; worker-src 'self' blob: data:;script-srccovers the snippet and the lazy-loaded bundles,connect-srccovers event ingestion and feature flags, andworker-srccovers session replay. The toolbar needs a few more (/docs/advanced/content-security-policy.md), or use a reverse proxy (/docs/advanced/proxy.md) so everything is first-party. Failing to do so causes silent failures wherecaptureandidentifycalls never send, so the integration looks complete while zero events arrive. Rememberconnect-srcfalls back todefault-src, sodefault-src 'self'blocks event delivery even when the script itself is bundled.2
Add your environment variables
Required
Add your environment variables to your
.env.localfile and to your hosting provider (e.g. Vercel, Netlify, AWS). You can find your project token and host in your project settings. If you're using Vite, prefixing variable names withVITE_ensures they are accessible in the frontend..env.local
VITE_POSTHOG_PROJECT_TOKEN=<ph_project_token> VITE_POSTHOG_HOST=https://us.i.posthog.com3
Add the PostHogProvider to your app
Required
In data mode, you'll need to wrap your
RouterProviderwith thePostHogProvidercontext. This passes an initialized PostHog client to your app.app/index.tsx
import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { createBrowserRouter, RouterProvider } from "react-router"; import Root, { RootErrorBoundary } from "./app/root"; import posthog from 'posthog-js'; import { PostHogProvider } from '@posthog/react' posthog.init(import.meta.env.VITE_POSTHOG_PROJECT_TOKEN, { api_host: import.meta.env.VITE_POSTHOG_HOST, defaults: '2026-05-30', }); const router = createBrowserRouter([...]); createRoot(document.getElementById("root")!).render( <StrictMode> {/* Pass PostHog client through PostHogProvider */} <PostHogProvider client={posthog}> <RouterProvider router={router} /> </PostHogProvider>, ); });This initializes PostHog and passes it to your app through the
PostHogProvidercontext.TypeError: Cannot read properties of undefined
If you see the error
TypeError: Cannot read properties of undefined (reading '...')this is likely because you tried to call a posthog function when posthog was not initialized (such as during the initial render). On purpose, we still render the children even if PostHog is not initialized so that your app still loads even if PostHog can't load.To fix this error, add a check that posthog has been initialized such as:
React
useEffect(() => { posthog?.capture('test') // using optional chaining (recommended) if (posthog) { posthog.capture('test') // using an if statement } }, [posthog])Typescript helps protect against these errors.
Verify client-side events are captured
Checkpoint
Confirm that you can capture client-side events and see them in your PostHog project
At this point, you should be able to capture client-side events and see them in your PostHog project. This includes basic events like page views and button clicks that are autocaptured (/docs/product-analytics/autocapture.md).
You can also try to capture a custom event to verify it's working. You can access PostHog in any component using the
usePostHoghook.TSX
import { usePostHog } from '@posthog/react' function App() { const posthog = usePostHog() return <button onClick={() => posthog?.capture('button_clicked')}>Click me</button> }You should see these events in a minute or two in the activity tab.
4
Access PostHog methods
Required
On the client-side, you can access the PostHog client using the
usePostHoghook. This hook returns the initialized PostHog client, which you can use to call PostHog methods. For example:TSX
import { usePostHog } from '@posthog/react' function App() { const posthog = usePostHog() return <button onClick={() => posthog?.capture('button_clicked')}>Click me</button> }For a complete list of available methods, see the posthog-js documentation (/docs/libraries/js.md).
5
Identify your user
Recommended
Now that you can capture basic client-side events, you'll want to identify your user so you can associate users with captured events.
Generally, you identify users when they log in or when they input some identifiable information (e.g. email, name, etc.). You can identify users by calling the
identifymethod on the PostHog client:TSX
export default function Login() { const { user, login } = useAuth(); const posthog = usePostHog(); const handleLogin = async (e: React.FormEvent) => { // existing code to handle login... const user = await login({ email, password }); posthog?.identify(user.email, { email: user.email, name: user.name, } ); posthog?.capture('user_logged_in'); }; return ( <div> {/* ... existing code ... */} <button onClick={handleLogin}>Login</button> </div> ); }PostHog automatically generates anonymous IDs for users before they're identified. When you call identify, a new identified person is created. All previous events tracked with the anonymous ID link to the new identified distinct ID, and all future captures on the same browser associate with the identified person.
6
Create an error boundary
Recommended
PostHog can capture exceptions thrown in your app through an error boundary. React Router in data mode has a built-in error boundary that you can use to capture exceptions. You can create an error boundary by exporting
RootErrorBoundaryfrom yourapp/root.tsxfile.app/root.tsx
import { usePostHog } from '@posthog/react' export function RootErrorBoundary() { const error = useRouteError(); const posthog = usePostHog(); if (error) { posthog.captureException(error); } // other error handling code... }This automatically captures exceptions thrown in your React Router app using the
posthog.captureException()method.7
Tracking element visibility
Recommended
The
PostHogCaptureOnViewedcomponent enables you to automatically capture events when elements scroll into view in the browser. This is useful for tracking impressions of important content, monitoring user engagement with specific sections, or understanding which parts of your page users are actually seeing.The component wraps your content and sends a
$element_viewedevent to PostHog when the wrapped element becomes visible in the viewport. It only fires once per component instance.Basic usage:
React
import { PostHogCaptureOnViewed } from '@posthog/react' function App() { return ( <PostHogCaptureOnViewed name="hero-banner"> <div>Your important content here</div> </PostHogCaptureOnViewed> ) }With custom properties:
You can include additional properties with the event to provide more context:
React
<PostHogCaptureOnViewed name="product-card" properties={{ product_id: '123', category: 'electronics', price: 299.99 }} > <ProductCard /> </PostHogCaptureOnViewed>Tracking multiple children:
Use
trackAllChildrento track each child element separately. This is useful for galleries or lists where you want to know which specific items were viewed:React
<PostHogCaptureOnViewed name="product-gallery" properties={{ gallery_type: 'featured' }} trackAllChildren > <ProductCard id="1" /> <ProductCard id="2" /> <ProductCard id="3" /> </PostHogCaptureOnViewed>When
trackAllChildrenis enabled, each child element sends its own event with achild_indexproperty indicating its position.Custom intersection observer options:
You can customize when elements are considered "viewed" by passing options to the
IntersectionObserver:React
<PostHogCaptureOnViewed name="footer" observerOptions={{ threshold: 0.5, // Element is 50% visible rootMargin: '0px' }} > <Footer /> </PostHogCaptureOnViewed>The component passes all other props to the wrapper
div, so you can add styling, classes, or other HTML attributes as needed.8
Set up server-side analytics
Recommended
Now that you've set up PostHog for React Router V7 in data mode, you can continue to set up server-side analytics. You can find our other SDKs in the SDKs page (/docs/libraries.md).
To help PostHog track your user sessions across the client and server, you'll need to add the
tracing_headers: ['your-backend-hostname1.com', 'your-backend-hostname2.com', ...]option to your PostHog initialization:TSX
posthog.init(import.meta.env.VITE_POSTHOG_PROJECT_TOKEN, { api_host: import.meta.env.VITE_POSTHOG_HOST, defaults: '2026-05-30', tracing_headers: [ window.location.hostname, 'localhost' ], });This adds the
X-POSTHOG-DISTINCT-IDandX-POSTHOG-SESSION-IDheaders to requests sent to the configured hostnames, which you can later use on the server-side.9
Next steps
Recommended
Now that you've set up PostHog for React Router, you can start capturing events and exceptions in your app.
To get the most out of PostHog, you should familiarize yourself with the following:
- PostHog Web SDK docs (/docs/libraries/js.md): Learn more about the PostHog Web SDK and how to use it on the client-side.
- PostHog Node SDK docs (/docs/libraries/node.md): Learn more about the PostHog Node SDK and how to use it on the server-side.
- Identify users (/docs/product-analytics/identify.md): Learn more about how to identify users in your app.
- Group analytics (/docs/product-analytics/group-analytics.md): Learn more about how to use group analytics in your app.
- PostHog AI (/docs/posthog-ai.md): After capturing events, use PostHog AI to help you understand your data and build insights.
- Feature flags and experiments (/docs/libraries/react.md#feature-flags): Feature flag and experiment setup is the same as React. You can find more details in the React integration guide.
Still have questions?
Ask PostHog AI
Was this page useful?
HelpfulCould be better
references/react-router-v7-declarative-mode.md
AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt
React Router V7 declarative mode
This guide walks you through setting up PostHog for React Router V7 in declarative mode. If you're using React Router in another mode, find the guide for that mode in the React Router page (/docs/libraries/react-router.md). If you're using React with another framework, go to the React integration guide (/docs/libraries/react.md).
1
Install client-side SDKs
Required
First, you'll need to install
posthog-jsand@posthog/reactusing your package manager. These packages allow you to capture client-side events.npm
npm install --save posthog-js @posthog/reactYarn
yarn add posthog-js @posthog/reactpnpm
pnpm add posthog-js @posthog/reactBun
bun add posthog-js @posthog/reactIf your site sets a Content-Security-Policy, it needs to allow PostHog. This applies to the snippet and to package installs alike: the SDK lazy-loads extra bundles (session replay, surveys) from PostHog's CDN, and sends events to the ingestion host. PostHog serves from subdomains of
posthog.comthat change over time, so allow the wildcard:script-src 'self' https://*.posthog.com; connect-src 'self' https://*.posthog.com; worker-src 'self' blob: data:;script-srccovers the snippet and the lazy-loaded bundles,connect-srccovers event ingestion and feature flags, andworker-srccovers session replay. The toolbar needs a few more (/docs/advanced/content-security-policy.md), or use a reverse proxy (/docs/advanced/proxy.md) so everything is first-party. Failing to do so causes silent failures wherecaptureandidentifycalls never send, so the integration looks complete while zero events arrive. Rememberconnect-srcfalls back todefault-src, sodefault-src 'self'blocks event delivery even when the script itself is bundled.2
Add your environment variables
Required
Add your environment variables to your
.env.localfile and to your hosting provider (e.g. Vercel, Netlify, AWS). You can find your project token and host in your project settings. If you're using Vite, prefixing variable names withVITE_ensures they are accessible in the frontend..env.local
VITE_POSTHOG_PROJECT_TOKEN=<ph_project_token> VITE_POSTHOG_HOST=https://us.i.posthog.com3
Add the PostHogProvider to your app
Required
In declarative mode, you'll need to wrap your
BrowserRouterwith thePostHogProvidercontext. This passes an initialized PostHog client to your app.src/main.tsx
import { StrictMode } from "react"; import ReactDOM from "react-dom/client"; import { BrowserRouter, Routes, Route } from "react-router"; import posthog from 'posthog-js'; import { PostHogErrorBoundary, PostHogProvider } from '@posthog/react' // Initialize PostHog posthog.init(import.meta.env.VITE_POSTHOG_PROJECT_TOKEN, { api_host: import.meta.env.VITE_POSTHOG_HOST, defaults: '2026-05-30', }); const root = document.getElementById("root"); ReactDOM.createRoot(root).render( <StrictMode> {/* Pass PostHog client through PostHogProvider */} <PostHogProvider client={posthog}> <BrowserRouter> <Routes> <Route path="/" element={<Root />}> {/* ... Your routes ... */} </Route> </Routes> </BrowserRouter> </PostHogProvider> </StrictMode>, );This initializes PostHog and passes it to your app through the
PostHogProvidercontext.TypeError: Cannot read properties of undefined
If you see the error
TypeError: Cannot read properties of undefined (reading '...')this is likely because you tried to call a posthog function when posthog was not initialized (such as during the initial render). On purpose, we still render the children even if PostHog is not initialized so that your app still loads even if PostHog can't load.To fix this error, add a check that posthog has been initialized such as:
React
useEffect(() => { posthog?.capture('test') // using optional chaining (recommended) if (posthog) { posthog.capture('test') // using an if statement } }, [posthog])Typescript helps protect against these errors.
Verify client-side events are captured
Checkpoint
Confirm that you can capture client-side events and see them in your PostHog project
At this point, you should be able to capture client-side events and see them in your PostHog project. This includes basic events like page views and button clicks that are autocaptured (/docs/product-analytics/autocapture.md).
You can also try to capture a custom event to verify it's working. You can access PostHog in any component using the
usePostHoghook.TSX
import { usePostHog } from '@posthog/react' function App() { const posthog = usePostHog() return <button onClick={() => posthog?.capture('button_clicked')}>Click me</button> }You should see these events in a minute or two in the activity tab.
4
Access PostHog methods
Required
On the client-side, you can access the PostHog client using the
usePostHoghook. This hook returns the initialized PostHog client, which you can use to call PostHog methods. For example:TSX
import { usePostHog } from '@posthog/react' function App() { const posthog = usePostHog() return <button onClick={() => posthog?.capture('button_clicked')}>Click me</button> }For a complete list of available methods, see the posthog-js documentation (/docs/libraries/js.md).
5
Identify your user
Recommended
Now that you can capture basic client-side events, you'll want to identify your user so you can associate users with captured events.
Generally, you identify users when they log in or when they input some identifiable information (e.g. email, name, etc.). You can identify users by calling the
identifymethod on the PostHog client:TSX
export default function Login() { const { user, login } = useAuth(); const posthog = usePostHog(); const handleLogin = async (e: React.FormEvent) => { // existing code to handle login... const user = await login({ email, password }); posthog?.identify(user.email, { email: user.email, name: user.name, } ); posthog?.capture('user_logged_in'); }; return ( <div> {/* ... existing code ... */} <button onClick={handleLogin}>Login</button> </div> ); }PostHog automatically generates anonymous IDs for users before they're identified. When you call identify, a new identified person is created. All previous events tracked with the anonymous ID link to the new identified distinct ID, and all future captures on the same browser associate with the identified person.
6
Create an error boundary
Recommended
PostHog can capture exceptions thrown in your app through an error boundary. PostHog provides a
PostHogErrorBoundarycomponent that you can use to capture exceptions. You can wrap your app with this component to capture exceptions.TSX
ReactDOM.createRoot(root).render( <StrictMode> <PostHogProvider client={posthog}> <PostHogErrorBoundary> <BrowserRouter> <Routes> <Route path="/" element={<Root />}> {/* ... Your routes ... */} </Route> </Routes> </BrowserRouter> </PostHogErrorBoundary> </PostHogProvider> </StrictMode>, );This automatically captures exceptions thrown in your React Router app using the
posthog.captureException()method.7
Tracking element visibility
Recommended
The
PostHogCaptureOnViewedcomponent enables you to automatically capture events when elements scroll into view in the browser. This is useful for tracking impressions of important content, monitoring user engagement with specific sections, or understanding which parts of your page users are actually seeing.The component wraps your content and sends a
$element_viewedevent to PostHog when the wrapped element becomes visible in the viewport. It only fires once per component instance.Basic usage:
React
import { PostHogCaptureOnViewed } from '@posthog/react' function App() { return ( <PostHogCaptureOnViewed name="hero-banner"> <div>Your important content here</div> </PostHogCaptureOnViewed> ) }With custom properties:
You can include additional properties with the event to provide more context:
React
<PostHogCaptureOnViewed name="product-card" properties={{ product_id: '123', category: 'electronics', price: 299.99 }} > <ProductCard /> </PostHogCaptureOnViewed>Tracking multiple children:
Use
trackAllChildrento track each child element separately. This is useful for galleries or lists where you want to know which specific items were viewed:React
<PostHogCaptureOnViewed name="product-gallery" properties={{ gallery_type: 'featured' }} trackAllChildren > <ProductCard id="1" /> <ProductCard id="2" /> <ProductCard id="3" /> </PostHogCaptureOnViewed>When
trackAllChildrenis enabled, each child element sends its own event with achild_indexproperty indicating its position.Custom intersection observer options:
You can customize when elements are considered "viewed" by passing options to the
IntersectionObserver:React
<PostHogCaptureOnViewed name="footer" observerOptions={{ threshold: 0.5, // Element is 50% visible rootMargin: '0px' }} > <Footer /> </PostHogCaptureOnViewed>The component passes all other props to the wrapper
div, so you can add styling, classes, or other HTML attributes as needed.8
Set up server-side analytics
Recommended
Now that you've set up PostHog for React Router V7 in declarative mode, you can continue to set up server-side analytics. You can find our other SDKs in the SDKs page (/docs/libraries.md).
To help PostHog track your user sessions across the client and server, you'll need to add the
tracing_headers: ['your-backend-hostname1.com', 'your-backend-hostname2.com', ...]option to your PostHog initialization:TSX
posthog.init(import.meta.env.VITE_POSTHOG_PROJECT_TOKEN, { api_host: import.meta.env.VITE_POSTHOG_HOST, defaults: '2026-05-30', tracing_headers: [ window.location.hostname, 'localhost' ], });This adds the
X-POSTHOG-DISTINCT-IDandX-POSTHOG-SESSION-IDheaders to requests sent to the configured hostnames, which you can later use on the server-side.9
Next steps
Recommended
Now that you've set up PostHog for React Router, you can start capturing events and exceptions in your app.
To get the most out of PostHog, you should familiarize yourself with the following:
- PostHog Web SDK docs (/docs/libraries/js.md): Learn more about the PostHog Web SDK and how to use it on the client-side.
- PostHog Node SDK docs (/docs/libraries/node.md): Learn more about the PostHog Node SDK and how to use it on the server-side.
- Identify users (/docs/product-analytics/identify.md): Learn more about how to identify users in your app.
- Group analytics (/docs/product-analytics/group-analytics.md): Learn more about how to use group analytics in your app.
- PostHog AI (/docs/posthog-ai.md): After capturing events, use PostHog AI to help you understand your data and build insights.
- Feature flags and experiments (/docs/libraries/react.md#feature-flags): Feature flag and experiment setup is the same as React. You can find more details in the React integration guide.
Still have questions?
Ask PostHog AI
Was this page useful?
HelpfulCould be better
references/react-router-v7-framework-mode.md
AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt
React Router V7 framework mode (Remix V3)
This guide walks you through setting up PostHog for React Router V7 in framework mode. If you're using React Router in another mode, find the guide for that mode in the React Router page (/docs/libraries/react-router.md). If you're using React with another framework, go to the React integration guide (/docs/libraries/react.md).
1
Install client-side SDKs
Required
First, you'll need to install
posthog-jsand@posthog/reactusing your package manager. These packages allow you to capture client-side events.npm
npm install --save posthog-js @posthog/reactYarn
yarn add posthog-js @posthog/reactpnpm
pnpm add posthog-js @posthog/reactBun
bun add posthog-js @posthog/reactIf your site sets a Content-Security-Policy, it needs to allow PostHog. This applies to the snippet and to package installs alike: the SDK lazy-loads extra bundles (session replay, surveys) from PostHog's CDN, and sends events to the ingestion host. PostHog serves from subdomains of
posthog.comthat change over time, so allow the wildcard:script-src 'self' https://*.posthog.com; connect-src 'self' https://*.posthog.com; worker-src 'self' blob: data:;script-srccovers the snippet and the lazy-loaded bundles,connect-srccovers event ingestion and feature flags, andworker-srccovers session replay. The toolbar needs a few more (/docs/advanced/content-security-policy.md), or use a reverse proxy (/docs/advanced/proxy.md) so everything is first-party. Failing to do so causes silent failures wherecaptureandidentifycalls never send, so the integration looks complete while zero events arrive. Rememberconnect-srcfalls back todefault-src, sodefault-src 'self'blocks event delivery even when the script itself is bundled.In framework mode, you'll also need to set
posthog-jsand@posthog/reactas external packages in yourvite.config.tsfile to avoid SSR errors.vite.config.ts
// ... imports export default defineConfig({ plugins: [tailwindcss(), reactRouter(), tsconfigPaths()], ssr: { noExternal: ['posthog-js', '@posthog/react'] } });2
Add your environment variables
Required
Add your environment variables to your
.env.localfile and to your hosting provider (e.g. Vercel, Netlify, AWS). You can find your project token and host in your project settings. If you're using Vite, prefixing variable names withVITE_ensures they are accessible in the frontend..env.local
VITE_POSTHOG_PROJECT_TOKEN=<ph_project_token> VITE_POSTHOG_HOST=https://us.i.posthog.com3
Add the PostHogProvider to your app
Required
In framework mode, your app enters from the
app/entry.client.tsxfile. In this file, you'll need to initialize the PostHog SDK and pass it to your app through thePostHogProvidercontext.app/entry.client.tsx
import { startTransition, StrictMode } from "react"; import { hydrateRoot } from "react-dom/client"; import { HydratedRouter } from "react-router/dom"; import posthog from 'posthog-js'; import { PostHogProvider } from '@posthog/react' posthog.init(import.meta.env.VITE_POSTHOG_PROJECT_TOKEN, { api_host: import.meta.env.VITE_POSTHOG_HOST, defaults: '2026-05-30', tracing_headers: [ window.location.hostname, 'localhost' ], }); startTransition(() => { hydrateRoot( document, {/* Pass PostHog client through PostHogProvider */} <PostHogProvider client={posthog}> <StrictMode> <HydratedRouter /> </StrictMode> </PostHogProvider>, ); });To help PostHog track your user sessions across the client and server, you'll need to add the
tracing_headers: ['your-backend-hostname1.com', 'your-backend-hostname2.com', ...]option to your PostHog initialization. This adds theX-POSTHOG-DISTINCT-IDandX-POSTHOG-SESSION-IDheaders to requests sent to the configured hostnames, which we'll later use on the server-side.TypeError: Cannot read properties of undefined
If you see the error
TypeError: Cannot read properties of undefined (reading '...')this is likely because you tried to call a posthog function when posthog was not initialized (such as during the initial render). On purpose, we still render the children even if PostHog is not initialized so that your app still loads even if PostHog can't load.To fix this error, add a check that posthog has been initialized such as:
React
useEffect(() => { posthog?.capture('test') // using optional chaining (recommended) if (posthog) { posthog.capture('test') // using an if statement } }, [posthog])Typescript helps protect against these errors.
Verify client-side events are captured
Checkpoint
Confirm that you can capture client-side events and see them in your PostHog project
At this point, you should be able to capture client-side events and see them in your PostHog project. This includes basic events like page views and button clicks that are autocaptured (/docs/product-analytics/autocapture.md).
You can also try to capture a custom event to verify it's working. You can access PostHog in any component using the
usePostHoghook.TSX
import { usePostHog } from '@posthog/react' function App() { const posthog = usePostHog() return <button onClick={() => posthog?.capture('button_clicked')}>Click me</button> }You should see these events in a minute or two in the activity tab.
4
Access PostHog methods
Required
On the client-side, you can access the PostHog client using the
usePostHoghook. This hook returns the initialized PostHog client, which you can use to call PostHog methods. For example:TSX
import { usePostHog } from '@posthog/react' function App() { const posthog = usePostHog() return <button onClick={() => posthog?.capture('button_clicked')}>Click me</button> }For a complete list of available methods, see the posthog-js documentation (/docs/libraries/js.md).
5
Identify your user
Recommended
Now that you can capture basic client-side events, you'll want to identify your user so you can associate users with captured events.
Generally, you identify users when they log in or when they input some identifiable information (e.g. email, name, etc.). You can identify users by calling the
identifymethod on the PostHog client:TSX
export default function Login() { const { user, login } = useAuth(); const posthog = usePostHog(); const handleLogin = async (e: React.FormEvent) => { // existing code to handle login... const user = await login({ email, password }); posthog?.identify(user.email, { email: user.email, name: user.name, } ); posthog?.capture('user_logged_in'); }; return ( <div> {/* ... existing code ... */} <button onClick={handleLogin}>Login</button> </div> ); }PostHog automatically generates anonymous IDs for users before they're identified. When you call identify, a new identified person is created. All previous events tracked with the anonymous ID link to the new identified distinct ID, and all future captures on the same browser associate with the identified person.
6
Create an error boundary
Recommended
PostHog can capture exceptions thrown in your app through an error boundary. React Router in framework mode has a built-in error boundary that you can use to capture exceptions. You can create an error boundary by exporting
ErrorBoundaryfrom yourapp/root.tsxfile.app/root.tsx
import { usePostHog } from '@posthog/react' export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { const posthog = usePostHog(); posthog?.captureException(error); // other error handling code... return ( <div> <h1>Something went wrong</h1> <p>{error.message}</p> </div> ); }This automatically captures exceptions thrown in your React Router app using the
posthog.captureException()method.7
Tracking element visibility
Recommended
The
PostHogCaptureOnViewedcomponent enables you to automatically capture events when elements scroll into view in the browser. This is useful for tracking impressions of important content, monitoring user engagement with specific sections, or understanding which parts of your page users are actually seeing.The component wraps your content and sends a
$element_viewedevent to PostHog when the wrapped element becomes visible in the viewport. It only fires once per component instance.Basic usage:
React
import { PostHogCaptureOnViewed } from '@posthog/react' function App() { return ( <PostHogCaptureOnViewed name="hero-banner"> <div>Your important content here</div> </PostHogCaptureOnViewed> ) }With custom properties:
You can include additional properties with the event to provide more context:
React
<PostHogCaptureOnViewed name="product-card" properties={{ product_id: '123', category: 'electronics', price: 299.99 }} > <ProductCard /> </PostHogCaptureOnViewed>Tracking multiple children:
Use
trackAllChildrento track each child element separately. This is useful for galleries or lists where you want to know which specific items were viewed:React
<PostHogCaptureOnViewed name="product-gallery" properties={{ gallery_type: 'featured' }} trackAllChildren > <ProductCard id="1" /> <ProductCard id="2" /> <ProductCard id="3" /> </PostHogCaptureOnViewed>When
trackAllChildrenis enabled, each child element sends its own event with achild_indexproperty indicating its position.Custom intersection observer options:
You can customize when elements are considered "viewed" by passing options to the
IntersectionObserver:React
<PostHogCaptureOnViewed name="footer" observerOptions={{ threshold: 0.5, // Element is 50% visible rootMargin: '0px' }} > <Footer /> </PostHogCaptureOnViewed>The component passes all other props to the wrapper
div, so you can add styling, classes, or other HTML attributes as needed.8
Install server-side SDKs
Recommended
Install the PostHog Node SDK (/docs/libraries/node.md) using your package manager. This is the SDK you'll use to capture server-side events.
npm
npm install posthog-node --saveYarn
yarn add posthog-nodepnpm
pnpm add posthog-nodeBun
bun add posthog-node9
Create a server-side middleware
Recommended
Next, create a server-side middleware to help you capture server-side events. This middleware helps you achieve the following:
- Initialize a PostHog client
- Fetch the session and distinct ID from the
X-POSTHOG-SESSION-IDandX-POSTHOG-DISTINCT-IDheaders and pass them to your request as a context (/docs/libraries/node.md#contexts). This automatically identifies the user and session for you in all subsequent event captures. - Calls
shutdown()on the PostHog client to ensure all events are sent before the request is completed.
app/lib/posthog-middleware.ts
import { PostHog } from "posthog-node"; import type { RouterContextProvider } from "react-router"; import type { Route } from "../+types/root"; export interface PostHogContext extends RouterContextProvider { posthog?: PostHog; } export const posthogMiddleware: Route.MiddlewareFunction = async ({ request, context }, next) => { const posthog = new PostHog(process.env.VITE_POSTHOG_PROJECT_TOKEN!, { host: process.env.VITE_POSTHOG_HOST!, flushAt: 1, flushInterval: 0, }); const sessionId = request.headers.get('X-POSTHOG-SESSION-ID'); const distinctId = request.headers.get('X-POSTHOG-DISTINCT-ID'); (context as PostHogContext).posthog = posthog; const response = await posthog.withContext( { sessionId: sessionId ?? undefined, distinctId: distinctId ?? undefined }, next ); await posthog.shutdown().catch(() => {}); return response; };Then, you'll need to register the middleware in your app in the
app/root.tsxfile by exporting it in theRoute.MiddlewareFunction[]array.app/root.tsx
import { posthogMiddleware } from './lib/posthog-middleware'; export const middleware: Route.MiddlewareFunction[] = [ posthogMiddleware, // other middlewares... ];Verify server-side events are captured
Checkpoint
Confirm that you can capture server-side events and see them in your PostHog project
At this point, you should be able to capture server-side events and see them in your PostHog project.
In a route, you can access the PostHog client from the context and capture an event. The middleware assigns the session ID and the distinct ID. This ensures that the system associates events with the correct user and session.
app/routes/api.checkout.ts
import type { PostHogContext } from "../lib/posthog-middleware"; export async function action({ request, context }: Route.ActionArgs) { const body = await request.json(); // ... existing code ... // Access the PostHog client from the context and capture an event const posthog = (context as PostHogContext).posthog; posthog?.capture({ event: 'checkout_completed' }); return Response.json({ success: true, // ... existing code ... }); }10
Next steps
Recommended
Now that you've set up PostHog for React Router, you can start capturing events and exceptions in your app.
To get the most out of PostHog, you should familiarize yourself with the following:
- PostHog Web SDK docs (/docs/libraries/js.md): Learn more about the PostHog Web SDK and how to use it on the client-side.
- PostHog Node SDK docs (/docs/libraries/node.md): Learn more about the PostHog Node SDK and how to use it on the server-side.
- Identify users (/docs/product-analytics/identify.md): Learn more about how to identify users in your app.
- Group analytics (/docs/product-analytics/group-analytics.md): Learn more about how to use group analytics in your app.
- PostHog AI (/docs/posthog-ai.md): After capturing events, use PostHog AI to help you understand your data and build insights.
- Feature flags and experiments (/docs/libraries/react.md#feature-flags): Feature flag and experiment setup is the same as React. You can find more details in the React integration guide.
Still have questions?
Ask PostHog AI
Was this page useful?
HelpfulCould be better
references/ruby-on-rails.md
AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt
Ruby on Rails
PostHog makes it easy to get data about traffic and usage of your Ruby on Rails app. Integrating PostHog enables analytics, custom event capture, feature flags, and automatic exception tracking.
This guide walks you through integrating PostHog into your Rails app using the posthog-rails gem.
Beta: integration via LLM
Install PostHog for Rails in seconds with our wizard by running this prompt with LLM coding agents (/blog/envoy-wizard-llm-agent.md) like Cursor and Bolt, or by running it in your terminal.
npx @posthog/wizard
Learn more (/wizard.md)
Or, to integrate manually, continue with the rest of this guide.
Features
- Automatic exception tracking – Captures unhandled and rescued exceptions
- ActiveJob instrumentation – Tracks background job exceptions
- User context – Automatically associates exceptions with the current user
- Smart filtering – Excludes common Rails exceptions (404s, etc.) by default
- Request context – Adds request metadata and optional PostHog tracing header identity/session context to captured events
- Rails 7.0+ error reporter – Integrates with Rails' built-in error reporting
- Log forwarding – Optionally forwards
Rails.loggeroutput to PostHog Logs (/docs/logs.md) over OpenTelemetry, automatically correlated with request context (Ruby 3.3+)
Installation
Add both gems to your Gemfile:
Gemfile
gem 'posthog-ruby', require: 'posthog'
gem 'posthog-rails'Then run:
Terminal
bundle installIdentifying users
Identifying users is required. Backend events need a
distinct_idthat matches the ID your frontend uses when callingposthog.identify(). Without this, backend events are orphaned — they can't be linked to frontend event captures, session replays (/docs/session-replay.md), LLM traces (/docs/ai-engineering.md), or error tracking (/docs/error-tracking.md).See our guide on identifying users (/docs/getting-started/identify-users.md) for how to set this up.
Generate the initializer
Run the install generator to create the PostHog initializer:
Terminal
rails generate posthog:installThis creates config/initializers/posthog.rb with sensible defaults and documentation.
Configuration
PostHog.init creates a single client instance used across your app. Avoid creating multiple PostHog::Client instances with the same API key, as this can cause dropped events and inconsistent behavior.
The generated initializer includes the most common options:
config/initializers/posthog.rb
# Rails-specific configuration
PostHog::Rails.configure do |config|
config.auto_capture_exceptions = true # Enable automatic exception capture (default: false)
config.report_rescued_exceptions = true # Report exceptions Rails rescues (default: false)
config.auto_instrument_active_job = true # Instrument background jobs (default: false)
config.use_tracing_headers = true # Use PostHog tracing headers for identity/session context (default: true)
config.capture_user_context = true # Include authenticated user info in exceptions (default: true)
config.current_user_method = :current_user # Method to get current user (default: :current_user)
config.user_id_method = nil # Method to get ID from user object (default: auto-detect)
# Add additional exceptions to ignore
config.excluded_exceptions = ['MyCustomError']
end
# Core PostHog client initialization
PostHog.init do |config|
# Required: Your PostHog project API key
config.api_key = '<ph_project_token>'
# Optional: Your PostHog instance URL
config.host = 'https://us.i.posthog.com'
# Optional: Personal API key for feature flags
config.personal_api_key = 'phx_xxxxxxxxx'
# Maximum number of events to queue before dropping (default: 10000)
config.max_queue_size = 10_000
# Send events synchronously on the calling thread (default: false)
config.sync_mode = false
# Feature flags polling interval in seconds (default: 30)
config.feature_flags_polling_interval = 30
# Feature flag request timeout in seconds (default: 3)
config.feature_flag_request_timeout_seconds = 3
# Error callback to detect misconfiguration
config.on_error = proc { |status, msg|
Rails.logger.error("PostHog error: #{msg}")
}
# Before-send callback to modify or drop events
config.before_send = proc { |event|
event[:properties] ||= {}
event[:properties]['environment'] = Rails.env
event
}
# Disable network calls in test mode
config.test_mode = true if Rails.env.test?
endYou can find your project token and instance address in your project settings.
Tip: Use
Rails.application.credentialsto avoid hardcoding API keys. First, add your keys and then reference them in your initializer:Terminal
rails credentials:editconfig/credentials.yml.enc
posthog: api_key: <ph_project_token> host: https://us.i.posthog.com personal_api_key: phx_xxxxxxxxxconfig/initializers/posthog.rb
config.api_key = Rails.application.credentials.posthog[:api_key] config.host = Rails.application.credentials.posthog[:host] config.personal_api_key = Rails.application.credentials.posthog[:personal_api_key]
Capturing events
Track custom events anywhere in your Rails app:
Ruby
PostHog.capture({
distinct_id: current_user.id,
event: 'post_created',
properties: { title: @post.title }
})Identify a user and set their person properties:
Ruby
PostHog.identify({
distinct_id: current_user.id,
properties: {
email: current_user.email,
plan: current_user.plan
}
})The Rails integration delegates methods like capture, identify, alias, group_identify, evaluate_flags, capture_exception, flush, and shutdown to the initialized PostHog::Client.
Request context
PostHog Rails automatically applies request-scoped context to events captured during web requests. Request metadata such as $current_url, $request_method, $request_path, $user_agent, and $ip is added to event properties.
When use_tracing_headers is enabled, PostHog tracing headers (X-PostHog-Distinct-Id and X-PostHog-Session-Id) are also used as default distinct_id and $session_id values. Explicit distinct_id and properties passed to PostHog.capture always take precedence.
If you're using PostHog JS (/docs/libraries/js.md) on the frontend, configure tracing_headers (/docs/libraries/js/config.md#tracing-headers) for your Rails backend hostname so browser requests include the session and distinct ID headers.
Tracing headers are client-controlled analytics context, not authentication or authorization. Pass an authenticated distinct_id explicitly for security-sensitive server-side decisions.
Disable tracing header identity/session capture if you do not want client-supplied tracing headers used for server-side events. Request metadata is still captured:
Ruby
PostHog::Rails.config.use_tracing_headers = falseLogs
To set up PostHog Logs (/docs/logs.md) in your Rails app, follow the Ruby on Rails logs installation guide (/docs/logs/installation/ruby-on-rails.md). The integration forwards Rails.logger output to PostHog Logs over OpenTelemetry, automatically correlated with each request's distinct ID and session ID. Requires Ruby 3.3+.
Error tracking
For full details on setting up error tracking with Rails, see our Rails error tracking installation guide (/docs/error-tracking/installation/ruby-on-rails.md).
Automatic exception tracking
When auto_capture_exceptions is enabled, exceptions are automatically captured:
Ruby
class PostsController < ApplicationController
def show
@post = Post.find(params[:id])
# Any exception here is automatically captured
end
endreport_rescued_exceptions controls whether exceptions Rails rescues (for example, exceptions rendered by Rails error pages) are captured. Enable it along with auto_capture_exceptions for complete error visibility, or leave it disabled to capture only unhandled exceptions.
Manual exception capture
You can also manually capture exceptions:
Ruby
PostHog.capture_exception(
exception,
current_user.id,
{ custom_property: 'value' }
)If you evaluated feature flags for the request, pass the same snapshot to include matching flag properties on the exception event:
Ruby
flags = PostHog.evaluate_flags(current_user.id)
PostHog.capture_exception(
exception,
current_user.id,
{ custom_property: 'value' },
flags: flags
)Background job exceptions
When auto_instrument_active_job is enabled, ActiveJob exceptions are automatically captured with job context:
Ruby
class EmailJob < ApplicationJob
def perform(user_id)
user = User.find(user_id)
UserMailer.welcome(user).deliver_now
# Exceptions are automatically captured
end
endAssociating jobs with users
By default, PostHog extracts a distinct_id from job arguments by looking for a user_id key in hash arguments:
Ruby
# PostHog will automatically use options[:user_id] as the distinct_id
ProcessOrderJob.perform_later(order.id, user_id: current_user.id)For more control, use the posthog_distinct_id class method. The proc or block receives the same arguments as perform:
Ruby
class SendWelcomeEmailJob < ApplicationJob
posthog_distinct_id ->(user, _options) { user.id }
def perform(user, options = {})
UserMailer.welcome(user).deliver_now
end
endYou can also use a block:
Ruby
class ProcessOrderJob < ApplicationJob
posthog_distinct_id do |_order, notify_user_id|
notify_user_id
end
def perform(order, notify_user_id)
# Process the order...
end
endRails 7.0+ error reporter
PostHog integrates with Rails' built-in error reporting:
Ruby
# These errors are automatically sent to PostHog
Rails.error.handle do
# Code that might raise an error
end
Rails.error.record(exception, context: { user_id: current_user.id })PostHog automatically extracts the user's distinct ID from user_id or distinct_id in the context hash. Other context keys are included as properties on the exception event.
User context
PostHog Rails automatically captures authenticated user information from your controllers for exceptions. Authenticated Rails user context takes precedence over client-supplied tracing headers for exception identity.
If your user method has a different name, configure it:
Ruby
PostHog::Rails.config.current_user_method = :logged_in_userUser ID extraction
By default, PostHog Rails auto-detects the user's distinct ID by trying these methods in order:
posthog_distinct_id– Define this on your User model for full controldistinct_id– Common analytics conventionid– Standard ActiveRecord primary keypk– Primary key aliasuuid– For UUID-based primary keys
It also checks hash-like users for id, pk, and uuid keys.
You can configure a specific method:
Ruby
PostHog::Rails.config.user_id_method = :emailOr define a method on your User model:
Ruby
class User < ApplicationRecord
def posthog_distinct_id
"user_#{id}" # or external_id, or any unique identifier
end
endExcluded exceptions
The following exceptions are not reported by default (common 4xx errors):
AbstractController::ActionNotFoundActionController::BadRequestActionController::InvalidAuthenticityTokenActionController::InvalidCrossOriginRequestActionController::MethodNotAllowedActionController::NotImplementedActionController::ParameterMissingActionController::RoutingErrorActionController::UnknownFormatActionController::UnknownHttpMethodActionDispatch::Http::Parameters::ParseErrorActiveRecord::RecordNotFoundActiveRecord::RecordNotUnique
Add more with:
Ruby
PostHog::Rails.config.excluded_exceptions = ['MyException']Feature flags
Evaluate flags once for the current user, then read values from the returned snapshot:
Ruby
class PostsController < ApplicationController
def show
flags = PostHog.evaluate_flags(current_user.id)
if flags.enabled?('new-post-design')
render 'posts/show_new'
else
render 'posts/show'
end
end
endFor multivariate flags and experiments, use get_flag:
Ruby
flags = PostHog.evaluate_flags(current_user.id)
variant = flags.get_flag('checkout-experiment')
if variant == 'test'
# Do something differently
endWhen capturing an event after branching on a flag, pass the same flags snapshot so the event includes the exact flag values used by your code:
Ruby
flags = PostHog.evaluate_flags(current_user.id)
PostHog.capture({
distinct_id: current_user.id,
event: 'checkout_started',
flags: flags.only_accessed
})For local evaluation, ensure you've set personal_api_key:
Ruby
config.personal_api_key = Rails.application.credentials.posthog[:personal_api_key]See our Ruby SDK docs (/docs/libraries/ruby.md#local-evaluation) for details on local evaluation with Puma and Unicorn servers.
Note:
PostHog.is_feature_enabled,PostHog.get_feature_flag,PostHog.get_feature_flag_result,PostHog.get_feature_flag_payload, andPostHog.capture({ ..., send_feature_flags: true })still work during the migration period, but they're deprecated. PreferPostHog.evaluate_flagsfor new code.
Testing
In your test environment, disable network calls with test mode:
config/environments/test.rb
PostHog.init do |config|
config.api_key = '<ph_project_token>'
config.test_mode = true
endOr in your specs:
spec/rails_helper.rb
RSpec.configure do |config|
config.before(:each) do
allow(PostHog).to receive(:capture)
end
endConfiguration reference
Core PostHog options
| Option | Type | Default | Description |
|---|---|---|---|
api_key |
String | required | Your PostHog project token. |
host |
String | https://us.i.posthog.com |
Fully qualified PostHog API host. |
personal_api_key |
String | nil |
Personal API key for local feature flag evaluation and remote config payloads. |
max_queue_size |
Integer | 10000 |
Maximum number of events to keep in the async queue before dropping new events. |
test_mode |
Boolean | false |
Keep events queued and do not send them. Useful for tests. |
sync_mode |
Boolean | false |
Send events synchronously on the calling thread. |
on_error |
Proc | no-op | Callback called as on_error.call(status, error). |
feature_flags_polling_interval |
Integer | 30 |
Seconds between local feature flag definition polls. |
feature_flag_request_timeout_seconds |
Integer | 3 |
Timeout, in seconds, for feature flag requests. |
before_send |
Proc | nil |
Callback that receives the event hash before it is queued or sent. Return a modified event hash, or nil to drop the event. |
The PostHog.init block supports the options above. Less common core options like batch_size, disable_singleton_warning, skip_ssl_verification, and flag_definition_cache_provider can be passed as an options hash to PostHog.init(...); see the Ruby SDK docs (/docs/libraries/ruby.md#configuration) for details.
Rails-specific options
Configure these via PostHog::Rails.configure or PostHog::Rails.config:
| Option | Type | Default | Description |
|---|---|---|---|
auto_capture_exceptions |
Boolean | false |
Automatically capture exceptions. |
report_rescued_exceptions |
Boolean | false |
Report exceptions Rails rescues. |
auto_instrument_active_job |
Boolean | false |
Capture ActiveJob exceptions with job context. |
excluded_exceptions |
Array | [] |
Additional exception class names to ignore. |
use_tracing_headers |
Boolean | true |
Use X-PostHog-Distinct-Id and X-PostHog-Session-Id as request-scoped defaults. |
capture_user_context |
Boolean | true |
Include authenticated user info in exceptions. |
current_user_method |
Symbol | :current_user |
Controller method used to fetch the current user. |
user_id_method |
Symbol | nil |
Method used to extract the distinct ID from the user object. Auto-detects when nil. |
Troubleshooting
Exceptions not being captured
Verify PostHog is initialized:
Ruby
Rails.console > PostHog.initialized? => trueCheck your excluded exceptions list.
Verify middleware is installed:
Ruby
Rails.application.middleware
User context not working
- Verify
current_user_methodmatches your controller method. - Check that the user object responds to
posthog_distinct_id,distinct_id,id,pk, oruuid. - If using a custom identifier, set
PostHog::Rails.config.user_id_method = :your_method.
Feature flags not working
Ensure you've set personal_api_key in your configuration.
Next steps
For any technical questions for how to integrate specific PostHog features into Rails (such as analytics, feature flags, A/B testing, etc.), have a look at our Ruby SDK docs (/docs/libraries/ruby.md).
Still have questions?
Ask PostHog AI
Was this page useful?
HelpfulCould be better
references/ruby.md
AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt
Ruby
The posthog-ruby library provides tracking functionality on the server-side for applications built in Ruby.
It uses an internal queue to make calls fast and non-blocking. It also batches requests and flushes asynchronously, making it perfect to use in any part of your web app or other server-side application that needs performance.
Use a single client instance (singleton) — Create the PostHog client once and reuse it throughout your application. Multiple client instances with the same API key can cause dropped events and inconsistent behavior. The SDK logs a warning if it detects multiple instances.
Installation
Add this to your Gemfile:
Terminal
gem "posthog-ruby"In your app, set your API key before making any calls. If setting a custom host, make sure to include the protocol (e.g. https://).
Ruby
require 'posthog'
posthog = PostHog::Client.new({
api_key: "<ph_project_token>",
host: "https://us.i.posthog.com",
on_error: Proc.new { |status, msg| print msg }
})You can find your project token and instance address in the project settings page in PostHog.
Configuration
Initialize the client with your project token before making any calls:
Ruby
require 'posthog'
posthog = PostHog::Client.new({
api_key: '<ph_project_token>',
host: 'https://us.i.posthog.com',
on_error: Proc.new { |status, msg| print msg }
})Available client options:
| Option | Type | Default | Description |
|---|---|---|---|
api_key |
String | required | Your PostHog project token. |
host |
String | https://us.i.posthog.com |
Fully qualified PostHog API host. Include the protocol, for example https://us.i.posthog.com or https://eu.i.posthog.com. |
personal_api_key |
String | nil |
Personal API key. Required for local feature flag evaluation and remote config payloads. |
max_queue_size |
Integer | 10000 |
Maximum number of events to keep in the async queue before dropping new events. |
batch_size |
Integer | 100 |
Maximum number of events to send in one async batch. |
test_mode |
Boolean | false |
Keep events queued and do not send them. Useful for tests. |
sync_mode |
Boolean | false |
Send events synchronously on the calling thread. Useful in forking environments like Sidekiq and Resque. |
on_error |
Proc | no-op | Callback called as on_error.call(status, error) for API or serialization errors. |
feature_flags_polling_interval |
Integer | 30 |
Seconds between local feature flag definition polls. |
feature_flag_request_timeout_seconds |
Integer | 3 |
Timeout, in seconds, for feature flag requests. |
before_send |
Proc | nil |
Callback that receives the event hash before it is queued or sent. Return a modified event hash, or nil to drop the event. |
disable_singleton_warning |
Boolean | false |
Suppress warnings about multiple clients with the same API key. Use only when you intentionally need multiple clients. |
skip_ssl_verification |
Boolean | false |
Disable SSL certificate verification. Intended only for local development or custom deployments. |
flag_definition_cache_provider |
Object | nil |
Provider for distributed feature flag definition caching. See distributed flag definition caching (#distributed-flag-definition-caching). |
Filtering or modifying events before sending
Use before_send to add, modify, or drop events immediately before the SDK queues or sends them:
Ruby
posthog = PostHog::Client.new({
api_key: '<ph_project_token>',
before_send: Proc.new do |event|
event[:properties] ||= {}
event[:properties]['environment'] = ENV['RACK_ENV']
# Return nil to drop the event
event[:properties]['internal_user'] == true ? nil : event
end
})Flushing and shutting down
For short-lived scripts, call flush before the process exits. Call shutdown when your application is stopping to flush pending events and stop background resources.
Ruby
posthog.capture({ distinct_id: 'user_123', event: 'script_finished' })
posthog.flush
posthog.shutdownIdentifying users
Identifying users is required. Backend events need a
distinct_idthat matches the ID your frontend uses when callingposthog.identify(). Without this, backend events are orphaned — they can't be linked to frontend event captures, session replays (/docs/session-replay.md), LLM traces (/docs/ai-engineering.md), or error tracking (/docs/error-tracking.md).See our guide on identifying users (/docs/getting-started/identify-users.md) for how to set this up.
Identify a user and set their person properties with identify:
Ruby
posthog.identify({
distinct_id: 'distinct_id_of_your_user',
properties: {
email: 'john@doe.com',
pro_user: false
}
})Capturing events
You can send custom events using capture:
Ruby
posthog.capture({
distinct_id: 'distinct_id_of_the_user',
event: 'user_signed_up'
})Tip: We recommend using a
[object] [verb]format for your event names, where[object]is the entity that the behavior relates to, and[verb]is the behavior itself. For example,project created,user signed up, orinvite sent.
Setting event properties
Optionally, you can include additional information with the event by including a properties (/docs/data/events.md#event-properties) object:
Ruby
posthog.capture({
distinct_id: 'distinct_id_of_the_user',
event: 'user_signed_up',
properties: {
login_type: 'email',
is_free_trial: true
}
})Sending pageviews
If you're aiming for a backend-only implementation of PostHog and won't be capturing events from your frontend, you can send pageviews from your backend like so:
Ruby
posthog.capture({
distinct_id: 'distinct_id_of_the_user',
event: '$pageview',
properties: {
'$current_url': 'https://example.com'
}
})capture accepts these fields:
| Field | Type | Description |
|---|---|---|
distinct_id |
String | The user ID. If omitted, framework integrations can provide request context; otherwise the SDK generates a UUID and marks the event as personless. |
event |
String | Event name. Required. |
properties |
Hash | Event properties. |
groups |
Hash | Group analytics mapping from group type to group key. |
timestamp |
Time | When the event occurred. Defaults to the current time. |
message_id |
String | Optional message ID. |
uuid |
String | Optional event UUID used for deduplication. Must be a valid UUID. |
flags |
PostHog::FeatureFlagEvaluations |
Snapshot returned by evaluate_flags. Adds $feature/<key> and $active_feature_flags properties without another /flags request. |
send_feature_flags |
Boolean, Hash, or PostHog::SendFeatureFlagsOptions |
Deprecated. Prefer passing flags: from evaluate_flags. |
Person profiles and properties
The Ruby SDK captures identified events by default. These create person profiles (/docs/data/persons.md). To set person properties (/docs/product-analytics/person-properties.md) in these profiles, include them when capturing an event:
Ruby
posthog.capture({
distinct_id: 'distinct_id',
event: 'event_name',
properties: {
'$set': { name: 'Max Hedgehog' },
'$set_once': { initial_url: '/blog' }
}
})For more details on the difference between $set and $set_once, see our person properties docs (/docs/product-analytics/person-properties.md#what-is-the-difference-between-set-and-set_once).
To capture anonymous events (/docs/data/anonymous-vs-identified-events.md) without person profiles, set the event's $process_person_profile property to false:
Ruby
posthog.capture({
distinct_id: 'distinct_id',
event: 'event_name',
properties: {
'$process_person_profile': false
}
})Alias
Sometimes, you want to assign multiple distinct IDs to a single user. This is helpful when your primary distinct ID is inaccessible. For example, if a distinct ID used on the frontend is not available in your backend.
In this case, you can use alias to assign another distinct ID to the same user.
Ruby
posthog.alias({
distinct_id: 'distinct_id',
alias: 'alias_id'
})We strongly recommend reading our docs on alias (/docs/product-analytics/identify.md#alias-assigning-multiple-distinct-ids-to-the-same-user) to best understand how to correctly use this method.
Feature flags
PostHog's feature flags (/docs/feature-flags.md) enable you to safely deploy and roll back new features as well as target specific users and groups with them.
There are two steps to implement feature flags in Ruby:
Step 1: Evaluate flags once
Call posthog.evaluate_flags() once for the user, then read values from the returned snapshot.
Boolean feature flags
Ruby
flags = posthog.evaluate_flags('distinct_id_of_your_user')
if flags.enabled?('flag-key')
# Do something differently for this user
# Optional: fetch the payload
matched_flag_payload = flags.get_flag_payload('flag-key')
endMultivariate feature flags
Ruby
flags = posthog.evaluate_flags('distinct_id_of_your_user')
enabled_variant = flags.get_flag('flag-key')
if enabled_variant == 'variant-key' # replace 'variant-key' with the key of your variant
# Do something differently for this user
# Optional: fetch the payload
matched_flag_payload = flags.get_flag_payload('flag-key')
endflags.get_flag() returns the variant string for multivariate flags, true for enabled boolean flags, false for disabled flags, and nil when the flag wasn't returned by the evaluation.
Note:
posthog.is_feature_enabled(),posthog.get_feature_flag(),posthog.get_feature_flag_result(),posthog.get_feature_flag_payload(), andcapture({ ..., send_feature_flags: true })still work during the migration period, but they're deprecated. Preferevaluate_flags()for new code.
Step 2: Include feature flag information when capturing events
If you want use your feature flag to breakdown or filter events in your insights (/docs/product-analytics/insights.md), you'll need to include feature flag information in those events. This ensures that the feature flag value is attributed correctly to the event.
Note: This step is only required for events captured using our server-side SDKs or API (/docs/api.md).
There are two methods you can use to include feature flag information in your events:
Method 1: Pass the evaluated flags snapshot to capture()
Pass the same flags object that you used for branching. This attaches the exact flag values from that evaluation and doesn't make another /flags request.
Ruby
flags = posthog.evaluate_flags('distinct_id_of_your_user')
if flags.enabled?('flag-key')
# Do something differently for this user
end
posthog.capture({
distinct_id: 'distinct_id_of_your_user',
event: 'event_name',
flags: flags,
})By default, this attaches every flag in the snapshot using $feature/<flag-key> properties and $active_feature_flags.
To reduce event property bloat, pass a filtered snapshot:
Ruby
# Attach only flags accessed with enabled?() or get_flag() before this call
posthog.capture({
distinct_id: 'distinct_id_of_your_user',
event: 'event_name',
flags: flags.only_accessed,
})
# Attach only specific flags
posthog.capture({
distinct_id: 'distinct_id_of_your_user',
event: 'event_name',
flags: flags.only(['checkout-flow', 'new-dashboard']),
})only_accessed is order-dependent. If you call it before accessing any flags with enabled?() or get_flag(), no feature flag properties are attached.
Method 2: Include the $feature/feature_flag_name property manually
In the event properties, include $feature/feature_flag_name: variant_key:
Ruby
posthog.capture({
distinct_id: 'distinct_id_of_your_user',
event: 'event_name',
properties: {
# Replace feature-flag-key with your flag key and 'variant-key' with the key of your variant
'$feature/feature-flag-key': 'variant-key',
},
})Evaluating only specific flags
By default, evaluate_flags() evaluates every flag for the user. If you only need a few flags, pass flag_keys to request only those flags:
Ruby
flags = posthog.evaluate_flags(
'distinct_id_of_your_user',
flag_keys: ['checkout-flow', 'new-dashboard'],
)Evaluating locally only
If you want to skip the remote /flags request and only use locally cached definitions, pass only_evaluate_locally: true:
Ruby
flags = posthog.evaluate_flags(
'distinct_id_of_your_user',
only_evaluate_locally: true,
)Disabling GeoIP for flag evaluation
Pass disable_geoip: true to disable GeoIP lookup for remote flag evaluation:
Ruby
flags = posthog.evaluate_flags(
'distinct_id_of_your_user',
disable_geoip: true,
)Sending $feature_flag_called events
Capturing $feature_flag_called events enables PostHog to know when a flag was accessed by a user and provide analytics and insights (/docs/product-analytics/insights.md) on the flag. With evaluate_flags(), the SDK sends this event when you call flags.enabled?() or flags.get_flag() for a flag.
The SDK deduplicates these events per (distinct_id, flag, value) in a local cache. If you reinitialize the PostHog client, the cache resets and $feature_flag_called events may be sent again. PostHog handles duplicates, so duplicate $feature_flag_called events don't affect your analytics.
flags.get_flag_payload() doesn't send $feature_flag_called events and doesn't count as an access for only_accessed.
Advanced: Overriding server properties
Sometimes, you may want to evaluate feature flags using person properties (/docs/product-analytics/person-properties.md), groups (/docs/product-analytics/group-analytics.md), or group properties that haven't been ingested yet, or were set incorrectly earlier.
You can provide properties to evaluate the flag with by using the person properties, groups, and group properties arguments. PostHog will then use these values to evaluate the flag, instead of any properties currently stored on your PostHog server.
For example:
Ruby
flags = posthog.evaluate_flags(
'distinct_id_of_the_user',
person_properties: {
property_name: 'value'
},
groups: {
your_group_type: 'your_group_id',
another_group_type: 'your_group_id',
},
group_properties: {
your_group_type: {
group_property_name: 'value'
},
another_group_type: {
group_property_name: 'value'
},
},
)
if flags.enabled?('flag-key')
# Do something differently for this user
endOverriding GeoIP properties
By default, a user's GeoIP properties are set using the IP address they use to capture events on the frontend. You may want to override the these properties when evaluating feature flags. A common reason to do this is when you're not using PostHog on your frontend, so the user has no GeoIP properties.
You can override GeoIP properties by including them in the person_properties parameter when evaluating feature flags. This is useful when you're evaluating flags on your backend and want to use the client's location instead of your server's location.
The following GeoIP properties can be overridden:
$geoip_country_code$geoip_country_name$geoip_city_name$geoip_city_confidence$geoip_continent_code$geoip_continent_name$geoip_latitude$geoip_longitude$geoip_postal_code$geoip_subdivision_1_code$geoip_subdivision_1_name$geoip_subdivision_2_code$geoip_subdivision_2_name$geoip_subdivision_3_code$geoip_subdivision_3_name$geoip_time_zone
Simply include any of these properties in the person_properties parameter alongside your other person properties when calling feature flags.
Request timeout
You can configure the feature_flag_request_timeout_seconds parameter when initializing your PostHog client to set a flag request timeout. This helps prevent your code from being blocked if PostHog's servers are too slow to respond. By default, this is set to 3 seconds.
Ruby
posthog = PostHog::Client.new({
# rest of your configuration...
feature_flag_request_timeout_seconds: 3 # Time in seconds. Defaults to 3.
})Legacy single-flag methods
The following methods are still available during the migration period, but are deprecated. Prefer evaluate_flags for new code.
| Method | Replacement |
|---|---|
posthog.is_feature_enabled(flag_key, distinct_id, ...) |
posthog.evaluate_flags(distinct_id, ...).enabled?(flag_key) |
posthog.get_feature_flag(flag_key, distinct_id, ...) |
posthog.evaluate_flags(distinct_id, ...).get_flag(flag_key) |
posthog.get_feature_flag_payload(flag_key, distinct_id, ...) |
posthog.evaluate_flags(distinct_id, ...).get_flag_payload(flag_key) |
posthog.get_feature_flag_result(flag_key, distinct_id, ...) |
posthog.evaluate_flags(distinct_id, ...) and read get_flag / get_flag_payload |
posthog.capture({ ..., send_feature_flags: true }) |
posthog.capture({ ..., flags: flags }) |
Local Evaluation
Evaluating feature flags requires making a request to PostHog for each flag. However, you can improve performance by evaluating flags locally. Instead of making a request for each flag, PostHog will periodically request and store feature flag definitions locally, enabling you to evaluate flags without making additional requests.
It is best practice to use local evaluation flags when possible, since this enables you to resolve flags faster and with fewer API calls.
For details on how to implement local evaluation, see our local evaluation guide (/docs/feature-flags/local-evaluation.md).
Evaluating feature flags locally in unicorn server
If you have preload_app true in your unicorn config, you can use the after_fork hook (which is part of the unicorn's configuration) to enable the feature flag cache to receive the updates from PostHog.
Ruby
after_fork do |_server, _worker|
$posthog = PostHog::Client.new({
api_key: '<ph_project_token>',
personal_api_key: '<ph_personal_api_key>',
host: 'https://us.i.posthog.com',
on_error: Proc.new { |status, msg| print msg }
})
endEvaluating feature flags locally in a Puma server
If you use Puma with multiple workers, you can use the on_worker_boot hook (which is part of Puma's configuration) to enable the feature flag cache to receive updates from PostHog.
Ruby
on_worker_boot do
$posthog = PostHog::Client.new({
api_key: '<ph_project_token>',
personal_api_key: '<ph_personal_api_key>',
host: 'https://us.i.posthog.com',
on_error: Proc.new { |status, msg| print msg }
})
endDistributed flag definition caching
flag_definition_cache_provider shares locally evaluated feature flag definitions across multiple workers or processes. The provider object must implement:
flag_definitions– returns cached definitions as a hash with:flags,:group_type_mapping, and:cohorts, ornilif empty.should_fetch_flag_definitions?– returnstrueif this process should fetch fresh definitions from PostHog.on_flag_definitions_received(data)– stores freshly fetched definitions.shutdown– releases locks or other resources.
Ruby
posthog = PostHog::Client.new({
api_key: '<ph_project_token>',
personal_api_key: '<ph_personal_api_key>',
flag_definition_cache_provider: my_cache_provider
})Remote config payloads
Use get_remote_config_payload to fetch the decrypted remote config payload for a flag. This requires personal_api_key.
Ruby
payload = posthog.get_remote_config_payload('flag-key')Experiments (A/B tests)
Since experiments (/docs/experiments/start-here.md) use feature flags, the code for running an experiment is very similar to the feature flags code:
Ruby
flags = posthog.evaluate_flags('user_distinct_id')
variant = flags.get_flag('experiment-feature-flag-key')
if variant == 'variant-name'
# Do something
endIt's also possible to run experiments without using feature flags (/docs/experiments/running-experiments-without-feature-flags.md).
Group analytics
Group analytics allows you to associate an event with a group (e.g. teams, organizations, etc.). Read the Group Analytics (/docs/user-guides/group-analytics.md) guide for more information.
Note: This is a paid feature and is not available on the open-source or free cloud plan. Learn more on the pricing page (/pricing.md).
Capture an event and associate it with a group:
Ruby
posthog.capture({
distinct_id: 'distinct_id_of_the_user',
event: 'movie_played',
properties: {
movie_id: '123',
category: 'romcom'
},
groups: {
'company': 'company_id_in_your_db'
}
})Update properties on a group:
Ruby
posthog.group_identify({
group_type: 'company',
group_key: 'company_id_in_your_db',
properties: {
name: 'Awesome Inc.'
}
})The name is a special property which is used in the PostHog UI for the name of the group. If you don't specify a name property, the group ID will be used instead.
If the optional distinct_id is not provided in the group identify call, it defaults to $#{group_type}_#{group_key} (e.g., $company_company_id_in_your_db in the example above). This default behavior will result in each group appearing as a separate person in PostHog. To avoid this, it's often more practical to use a consistent distinct_id, such as group_identifier.
Exception capture
You can capture exceptions using the posthog-ruby library. This enables you to see stack traces and debug errors in your application. Learn more in our error tracking docs (/docs/error-tracking/installation/ruby.md).
Using Rails?
The posthog-rails (/docs/libraries/ruby-on-rails.md) gem provides automatic exception capture, ActiveJob instrumentation, and user context out of the box. See our Rails error tracking guide (/docs/error-tracking/installation/ruby-on-rails.md) for details.
For non-Rails Ruby applications, you can manually capture exceptions with capture_exception:
Ruby
begin
# Code that might raise an exception
raise StandardError, 'Something went wrong'
rescue => e
posthog.capture_exception(
e,
'user_distinct_id',
{
custom_property: 'custom_value'
}
)
endThe capture_exception method accepts the following parameters:
| Parameter | Type | Description |
|---|---|---|
exception |
Exception, String, or exception-like object |
The exception to capture. Required. |
distinct_id |
String |
The distinct ID of the user. Optional; request context can provide a default, otherwise the SDK generates a UUID. |
additional_properties |
Hash |
Additional properties to attach to the exception event. Optional. |
flags |
PostHog::FeatureFlagEvaluations |
Optional keyword argument. Adds the same feature flag properties as capture({ flags: flags }). |
You can also override the fingerprint (/docs/error-tracking/fingerprints.md) to customize how exceptions are grouped into issues:
Ruby
posthog.capture_exception(
e,
'user_distinct_id',
{
'$exception_fingerprint': 'CustomExceptionGroup'
}
)Debug mode
The Ruby SDK logs warnings by default. You can change the log level to DEBUG to debug the client:
Ruby
posthog.logger.level = Logger::DEBUGYou can also replace the SDK logger globally:
Ruby
PostHog::Logging.logger = Rails.loggerTest helpers
When test_mode: true, events remain queued. You can inspect and clear the queue in tests:
Ruby
posthog = PostHog::Client.new({ api_key: '<ph_project_token>', test_mode: true })
posthog.capture({ distinct_id: 'user_123', event: 'test_event' })
posthog.queued_messages
posthog.dequeue_last_message
posthog.clearThank you
This library is largely based on the analytics-ruby package.
Still have questions?
Ask PostHog AI
Was this page useful?
HelpfulCould be better
references/svelte.md
AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt
Svelte
PostHog makes it easy to get data about traffic and usage of your Svelte app. Integrating PostHog into your site enables analytics about user behavior, custom events capture, session recordings, feature flags, and more.
This guide walks you through integrating PostHog into your SvelteKit app using the JavaScript Web (/docs/libraries/js.md) and Node.js (/docs/libraries/node.md) SDKs.
Beta: integration via LLM
Install PostHog for Svelte in seconds with our wizard by running this prompt with LLM coding agents (/blog/envoy-wizard-llm-agent.md) like Cursor and Bolt, or by running it in your terminal.
npx @posthog/wizard
Learn more (/wizard.md)
Or, to integrate manually, continue with the rest of this guide.
Client-side setup
Install posthog-js using your package manager:
npm
npm install --save posthog-jsYarn
yarn add posthog-jspnpm
pnpm add posthog-jsBun
bun add posthog-jsIf your site sets a Content-Security-Policy, it needs to allow PostHog. This applies to the snippet and to package installs alike: the SDK lazy-loads extra bundles (session replay, surveys) from PostHog's CDN, and sends events to the ingestion host. PostHog serves from subdomains of
posthog.comthat change over time, so allow the wildcard:script-src 'self' https://*.posthog.com; connect-src 'self' https://*.posthog.com; worker-src 'self' blob: data:;
script-srccovers the snippet and the lazy-loaded bundles,connect-srccovers event ingestion and feature flags, andworker-srccovers session replay. The toolbar needs a few more (/docs/advanced/content-security-policy.md), or use a reverse proxy (/docs/advanced/proxy.md) so everything is first-party. Failing to do so causes silent failures wherecaptureandidentifycalls never send, so the integration looks complete while zero events arrive. Rememberconnect-srcfalls back todefault-src, sodefault-src 'self'blocks event delivery even when the script itself is bundled.
Then, if you haven't created a root layout already, create a new file called +layout.js in your src/routes folder In this file, check the environment is the browser, and initialize PostHog if so. You can get both your API key and instance address in your project settings.
routes/+layout.js
import posthog from 'posthog-js'
import { browser } from '$app/environment';
export const load = async () => {
if (browser) {
posthog.init('<ph_project_token>', {
api_host: 'https://us.i.posthog.com',
defaults: '2026-05-30',
})
}
return
};Identifying users
Identifying users is required. Call
posthog.identify('your-user-id')after login to link events to a known user. This is what connects frontend event captures, session replays (/docs/session-replay.md), LLM traces (/docs/ai-engineering.md), and error tracking (/docs/error-tracking.md) to the same person — and lets backend events link back too.Use a stable ID from your auth system when possible, not an email or display name. Send those as person properties instead. If your app has no other stable key, email works as a fallback if they are unique. Never a shared literal like
"anonymous"or"user", which pools many people onto one person and corrupts their data. When no ID is available at all, skip the identify and retain the anonymous distinct ID that's automatically assigned.Call
posthog.reset()on logout, so the next person to use the browser doesn't inherit the last one's identity.See our guide on identifying users (/docs/getting-started/identify-users.md) for how to set this up.
If your app calls your own backend, tracing_headers adds X-POSTHOG-DISTINCT-ID and X-POSTHOG-SESSION-ID to matching fetch and XMLHttpRequest requests. This lets server-side SDKs link backend events, errors, and LLM traces back to frontend sessions and replays. Use hostnames only, without protocols or paths.
JavaScript
posthog.init('<ph_project_token>', {
api_host: 'https://us.i.posthog.com',
// Optional: send PostHog session/user context to your backend
tracing_headers: ['api.example.com'],
})This works in local development too, but match on the hostname alone: use 'localhost', not 'localhost:3000'. Ports are never part of a hostname, so a value with one in it never matches anything. localhost and 127.0.0.1 are also different hostnames — use whichever your app actually calls.
Tracing headers help you attribute events across front and backend consistently. When this isn't available, use your server-side stable IDs to deduce the matching distinctId, and pass it in when capturing the event.
❗️ If you intend on using session replays with a server-side rendered Svelte app ensure that your asset URLs are configured to be relative (/docs/session-replay/troubleshooting.md#ensure-assets-are-imported-from-the-base-URL-in-Svelte).
Set up a reverse proxy (recommended)
We recommend setting up a reverse proxy (/docs/advanced/proxy.md), so that events are less likely to be intercepted by tracking blockers.
We have our own managed reverse proxy service (/docs/advanced/proxy/managed-reverse-proxy.md), which is free for all PostHog Cloud users, routes through our infrastructure, and makes setting up your proxy easy.
If you don't want to use our managed service then there are several other options for creating a reverse proxy, including using Cloudflare (/docs/advanced/proxy/cloudflare.md), AWS Cloudfront (/docs/advanced/proxy/cloudfront.md), and Vercel (/docs/advanced/proxy/vercel.md).
Grouping products in one project (recommended)
If you have multiple customer-facing products (e.g. a marketing website + mobile app + web app), it's best to install PostHog on them all and group them in one project (/docs/settings/projects.md).
This makes it possible to track users across their entire journey (e.g. from visiting your marketing website to signing up for your product), or how they use your product across multiple platforms.
Add IPs to Firewall/WAF allowlists (recommended)
For certain features like heatmaps (/docs/toolbar/heatmaps.md), your Web Application Firewall (WAF) may be blocking PostHog's requests to your site. Add these IP addresses to your WAF allowlist or rules to let PostHog access your site.
EU: 3.75.65.221, 18.197.246.42, 3.120.223.253
US: 44.205.89.55, 52.4.194.122, 44.208.188.173
These are public, stable IPs used by PostHog services.
PostHog captures heatmap screenshots using Browserless, which has its own IP addresses. Browserless publishes the current list here.
An allowlist does not help when your app has a private address. For apps on an internal network, see internal and intranet applications (/docs/session-replay/troubleshooting.md#internal-and-intranet-applications).
Server-side setup
Install posthog-node using your package manager:
npm
npm install posthog-node --saveYarn
yarn add posthog-nodepnpm
pnpm add posthog-nodeBun
bun add posthog-nodeThen, initialize the PostHog Node client where you'd like to use it on the server side. For example, in a load function:
routes/+page.server.js
import { PostHog } from 'posthog-node';
export async function load() {
const posthog = new PostHog('<ph_project_token>', { host: 'https://us.i.posthog.com' });
posthog.capture({
distinctId: 'distinct_id_of_the_user',
event: 'event_name',
})
await posthog.shutdown()
}Note: Make sure to always call
posthog.shutdown()after capturing events from the server-side. PostHog queues events into larger batches, and this call forces all batched events to be flushed immediately.
Feature flags
To use client-side feature flags, import PostHog into your Svelte component and check if the feature is enabled (while ensuring the code only runs in the browser).
routes/+page.svelte
<script>
import posthog from 'posthog-js'
import { browser } from '$app/environment'
import { onMount } from 'svelte'
let coolFeature = $state(false)
onMount(() => {
if (browser) {
coolFeature = posthog.isFeatureEnabled('cool-feature')
}
})
</script>
{#if coolFeature}
<p>Welcome to the cool feature!</p>
{/if}To use server-side feature flags, import PostHog into your SvelteKit load function and check if the feature is enabled.
routes/+page.server.js
import { PostHog } from 'posthog-node';
const client = new PostHog(
'<ph_project_token>',
{ host: 'https://us.i.posthog.com' }
);
export async function load() {
const distinctId = 'distinct_id_of_the_user';
const megaFeature = await client.isFeatureEnabled(
'mega-feature',
distinctId
);
return {
megaFeature
};
}See our JavaScript Web (/docs/libraries/js/usage.md#feature-flags) and Node (/docs/libraries/node.md#feature-flags) docs for more details.
Configuring session replay for server-side rendered apps
By default, Svelte uses relative asset paths during server-side rending. This causes issues with PostHog's ability to record sessions.
To fix this, set the config to not use relative paths in svelte.config.js:
JavaScript
kit: {
paths: {
relative: false,
},
},Next steps
For any technical questions for how to integrate specific PostHog features into Svelte (such as analytics, feature flags, A/B testing, surveys, etc.), have a look at our JavaScript Web (/docs/libraries/js/usage.md) and Node (/docs/libraries/node.md) SDK docs.
Alternatively, the following tutorials can help you get started:
- How to set up Svelte analytics, feature flags, and more (/tutorials/svelte-analytics.md)
- How to set up A/B tests in Svelte (/tutorials/svelte-ab-tests.md)
- How to set up surveys in Svelte (/tutorials/svelte-surveys.md)
Still have questions?
Ask PostHog AI
Was this page useful?
HelpfulCould be better
references/tanstack-start.md
AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt
TanStack Start
This tutorial shows how to integrate PostHog with a TanStack Start app for both client-side and server-side analytics.
Installation
Install the required packages:
Terminal
npm install @posthog/react posthog-node@posthog/react- React package for our JS Web SDK (/docs/libraries/js.md) for client-side usageposthog-node- PostHog Node.js SDK (/docs/libraries/node.md) for server-side event capture
Identifying users
Identifying users is required. Call
posthog.identify('your-user-id')after login to link events to a known user. This is what connects frontend event captures, session replays (/docs/session-replay.md), LLM traces (/docs/ai-engineering.md), and error tracking (/docs/error-tracking.md) to the same person — and lets backend events link back too.Use a stable ID from your auth system when possible, not an email or display name. Send those as person properties instead. If your app has no other stable key, email works as a fallback if they are unique. Never a shared literal like
"anonymous"or"user", which pools many people onto one person and corrupts their data. When no ID is available at all, skip the identify and retain the anonymous distinct ID that's automatically assigned.Call
posthog.reset()on logout, so the next person to use the browser doesn't inherit the last one's identity.See our guide on identifying users (/docs/getting-started/identify-users.md) for how to set this up.
If your app calls your own backend, tracing_headers adds X-POSTHOG-DISTINCT-ID and X-POSTHOG-SESSION-ID to matching fetch and XMLHttpRequest requests. This lets server-side SDKs link backend events, errors, and LLM traces back to frontend sessions and replays. Use hostnames only, without protocols or paths.
JavaScript
posthog.init('<ph_project_token>', {
api_host: 'https://us.i.posthog.com',
// Optional: send PostHog session/user context to your backend
tracing_headers: ['api.example.com'],
})This works in local development too, but match on the hostname alone: use 'localhost', not 'localhost:3000'. Ports are never part of a hostname, so a value with one in it never matches anything. localhost and 127.0.0.1 are also different hostnames — use whichever your app actually calls.
Tracing headers help you attribute events across front and backend consistently. When this isn't available, use your server-side stable IDs to deduce the matching distinctId, and pass it in when capturing the event.
Initialize PostHog on the client
Wrap your app with PostHogProvider in your root route with your project token, host, and other options.
import CspAllowancesCallout from "../_snippets/csp-allowances-callout.mdx"
<CspAllowancesCallout />tsx file=src/routes/__root.tsx
// src/routes/__root.tsx
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
import { PostHogProvider } from '@posthog/react'
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
],
}),
shellComponent: RootDocument,
})
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<PostHogProvider
apiKey="<ph_project_token>"
options={{
api_host: 'https://us.i.posthog.com',
defaults: '2026-05-30',
capture_exceptions: true
}}
>
{children}
</PostHogProvider>
<Scripts />
</body>
</html>
)
}Once the provider is in place, PostHog automatically captures pageviews, sessions, and web vitals.
Capture events on the client
Use the usePostHog hook from @posthog/react in any component to capture custom events:
src/routes/checkout.tsx
import { usePostHog } from '@posthog/react'
function CheckoutButton({ orderId, total }: { orderId: string; total: number }) {
const posthog = usePostHog()
const handleClick = () => {
posthog.capture('checkout_started', {
order_id: orderId,
total: total,
})
}
return <button onClick={handleClick}>Checkout</button>
}Identify users
Call posthog.identify() when a user logs in to link their events to a user ID:
TSX
import { usePostHog } from '@posthog/react'
function LoginForm() {
const posthog = usePostHog()
const handleLogin = async (userId: string, email: string) => {
// ... your login logic
posthog.identify(userId, {
email: email,
})
posthog.capture('user_logged_in')
}
}Call posthog.reset() on logout to clear the identified user.
Initialize PostHog on the server
Create a server-side PostHog client using posthog-node. Use a singleton pattern so you reuse the same client across requests:
src/utils/posthog-server.ts
// src/utils/posthog-server.ts
import { PostHog } from 'posthog-node'
let posthogClient: PostHog | null = null
export function getPostHogClient() {
if (!posthogClient) {
posthogClient = new PostHog(
'<ph_project_token>',
{
host: 'https://us.i.posthog.com',
flushAt: 1,
flushInterval: 0,
},
)
}
return posthogClient
}Capture events on the server
Use the server client in TanStack Start API routes to capture events server-side. Server-side capture is useful for tracking events that shouldn't be spoofable from the client, like purchases or authentication:
src/routes/api/checkout.ts
// src/routes/api/checkout.ts
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { getPostHogClient } from '../../utils/posthog-server'
export const Route = createFileRoute('/api/checkout')({
server: {
handlers: {
POST: async ({ request }) => {
const body = await request.json()
const posthog = getPostHogClient()
posthog.capture({
distinctId: body.userId,
event: 'item_purchased',
properties: {
item_id: body.itemId,
price: body.price,
source: 'api',
},
})
return json({ success: true })
},
},
},
})The server-side capture call requires a distinctId (the user identifier), an event name, and optional properties.
Next steps
Installing the JS Web SDK and Node SDK means all of their functionality is available in your TanStack Start project. To learn more about this, have a look at our JS Web SDK docs (/docs/libraries/js/usage.md) and Node SDK docs (/docs/libraries/node.md).
Still have questions?
Ask PostHog AI
Was this page useful?
HelpfulCould be better
references/usage.md
AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt
iOS SDK usage
Capturing events
You can send custom events using capture:
Swift
PostHogSDK.shared.capture("user_signed_up")Tip: We recommend using a
[object] [verb]format for your event names, where[object]is the entity that the behavior relates to, and[verb]is the behavior itself. For example,project created,user signed up, orinvite sent.
Setting event properties
Optionally, you can include additional information with the event by including a properties (/docs/data/events.md#event-properties) object:
Swift
PostHogSDK.shared.capture("user_signed_up", properties: ["login_type": "email"], userProperties: ["is_free_trial": true])Autocapture
PostHog autocapture automatically tracks the following events for you:
- Application Opened – when the app is opened from a closed state or when the app comes to the foreground (e.g. from the app switcher)
- Application Backgrounded – when the app is sent to the background by the user
- Application Installed – when the app is installed
- Application Updated – when the app is updated
- $screen – when the user navigates (if using
UIViewController) - $autocapture – when the user interacts with elements in a screen (
UIKit based) andcaptureElementInteractionsis enabled - $rageclick – when the user rapidly taps in the same area (iOS/macCatalyst,
UIKit based)
🚧 Note:
$autocaptureand$rageclickare captured from UIKit interactions. Some SwiftUI views use UIKit under the hood (for example,TextField→UITextFieldandToggle→UISwitch), so those interactions may also be autocaptured. In other SwiftUI cases, interactions might still be captured, but element metadata (such as$elements_chain) may be incomplete.
Capturing screen views
With configuration.captureScreenViews (/docs/libraries/ios/configuration.md#all-configuration-options) set as true, PostHog will try to record all screen changes automatically.
If you want to manually send a new screen capture event, use the screen function.
Swift
PostHogSDK.shared.screen("Dashboard", properties: ["fromIcon": "bottom"])Important: While
captureScreenViewsworks with bothUIKitandSwiftUI, the screen names captured inSwiftUImay not be very meaningful as they are based on internal SwiftUI view identifiers. ForSwiftUIapplications, we recommend turning this option off and instead using the.postHogScreenView()view modifier (see next section) to capture screen views with meaningful names.
Note: You can use the
BeforeSendBlockto filter or drop any undesired screen events, giving you control over which screen views are sent to PostHog. See Amending, dropping or sampling events (/docs/libraries/ios.md#amending-dropping-or-sampling-events) for implementation examples.
Capturing screen views in SwiftUI
To track a screen view in SwiftUI, apply the postHogScreenView modifier to your full-screen views. PostHog will send a $screen event when the onAppear action is executed and will infer a screen name based on the view's type. You can provide a custom name and event properties if needed.
HomeView.swift
// This will trigger a screen view event with $screen_name: "HomeViewContent"
struct HomeView: View {
var body: some View {
HomeViewContent()
.postHogScreenView()
}
}
// This will trigger a screen view event with $screen_name: "My Home View" and an additional event property from_button: "start"
struct HomeView: View {
var body: some View {
HomeViewContent()
.postHogScreenView("My Home View", ["from_button": "start"])
}
}In SwiftUI, views can range from entire screens to small UI components. Unlike UIKit, SwiftUI doesn't clearly distinguish between these levels, which makes automatic tracking of full-screen views harder.
Adding a custom label on autocaptured elements
PostHog automatically captures interactions with various UI elements in your app, but these interactions are often identified by element type names (e.g., UIButton, UITextField, UILabel).
While this provides basic tracking, it can be challenging to pinpoint specific interactions with particular elements in your analytics. To make your data more meaningful and actionable, you can assign custom labels to any autocaptured element. These labels act as descriptive identifiers, making it easier to identify, filter, and analyze events in your reports.
Adding a custom label in UIKit
To assign a custom label to a UIView, use the postHogLabel property:
Swift
let view = UIView()
view.postHogLabel = "usernameTextField"In this example, interactions with the UITextField will be captured with an additional identifier "usernameTextField".
Adding a custom label in SwiftUI
In SwiftUI, use the .postHogLabel(_:) modifier instead:
Swift
var body: some View {
...
TextField("username", text: $username)
.postHogLabel("usernameTextField")
}Since SwiftUI's TextField uses UITextField under the hood, interactions with it will be autocaptured with the additional identifier "usernameTextField".
Example of generated analytics data
The generated analytics element in the examples above will have the following form:
Swift
<UITextField id="usernameTextField">text value</UITextField>Filtering for labeled autocaptured elements in reports
To locate and filter interactions with specific elements in PostHog reports, you can use Autocapture element filters, such as:
- Tag Name (
UITextFieldin this example) - Text (
text valuein this example) - CSS Selector (the generated
idattribute in this example)
In the examples above, we can filter for the specific text field using the CSS Selector #usernameTextField
Interaction autocapture
Interaction autocapture records when users interact with UI elements in your app. This includes:
- User interactions like
touch,swipe,pan,pinch,rotation,long_press,scroll - Control types
value_changed,submit,toggle,primary_action,menu_action,change
Interaction autocapture is not enabled by default. You can enable it by setting captureElementInteractions to true in the config.
Swift
let config = PostHogConfig(projectToken: "<ph_project_token>", host: "https://us.i.posthog.com")
config.captureElementInteractions = true // Disabled by default
PostHogSDK.shared.setup(config)Rage click autocapture
Note: Rage click autocapture for iOS/macCatalyst is available in version 3.51.0+.
A rage click is when a user taps an area multiple times in quick succession (e.g more than 3 taps in 1 second).
This is captured as a $rageclick event. You can use this event to identify opportunities to improve your UI, since it's a good indication that users may be frustrated with your product.
It is enabled by default (rageClickConfig.enabled = true).
Swift
let config = PostHogConfig(projectToken: "<ph_project_token>", host: "https://us.i.posthog.com")
config.rageClickConfig.enabled = true // Enabled by default
config.rageClickConfig.minimumTapCount = 3 // Optional, default is 3
config.rageClickConfig.thresholdPoints = 30 // Optional, default is 30
config.rageClickConfig.timeoutInterval = 1.0 // Optional, default is 1.0s
PostHogSDK.shared.setup(config)Autocapture configuration
You can enable or disable autocapture through the PostHogConfig object. Find more details about autocapture configuration in the configuration page (/docs/libraries/ios/configuration.md#autocapture-configuration).
Preventing sensitive data capture
To exclude specific UI elements from autocapture or Session Replay, add ph-no-capture as either an accessibilityLabel or accessibilityIdentifier. See privacy controls (/docs/session-replay/privacy?tab=iOS.md) for masking behavior and iOS examples.
Identifying users
We highly recommend reading our section on Identifying users (/docs/integrate/identifying-users.md) to better understand how to correctly use this method.
Using identify, you can associate events with specific users. This enables you to gain full insights as to how they're using your product across different sessions, devices, and platforms.
An identify call has the following arguments:
distinct_idwhich uniquely identifies your user in your databaseuserProperties: Optional. A dictionary with key:value pairs to set the person properties (/docs/product-analytics/person-properties.md)
userPropertiesSetOnce: Optional. Similar to
userProperties. See the difference betweenuserPropertiesanduserPropertiesSetOnce(/docs/product-analytics/person-properties.md#what-is-the-difference-between-set-and-set_once)
Swift
PostHogSDK.shared.identify("user_id_from_your_database",
userProperties: ["name": "Peter Griffin", "email": "peter@familyguy.com"],
userPropertiesSetOnce: ["date_of_first_log_in": "2024-03-01"])You should call identify as soon as you're able to. Typically, this is after your user logs in. This ensures that events sent during your user's sessions are correctly associated with them.
When you call identify, all previously tracked anonymous events will be linked to the user.
Get the current user's distinct ID
You may find it helpful to get the current user's distinct ID. For example, to check whether you've already called identify for a user or not.
To do this, call getDistinctId(). This returns either the ID automatically generated by PostHog or the ID that has been passed by a call to identify().
Alias
Sometimes, you want to assign multiple distinct IDs to a single user. This is helpful when your primary distinct ID is inaccessible. For example, if a distinct ID used on the frontend is not available in your backend.
In this case, you can use alias to assign another distinct ID to the same user.
Swift
PostHogSDK.shared.alias("alias_id")We strongly recommend reading our docs on alias (/docs/product-analytics/identify.md#alias-assigning-multiple-distinct-ids-to-the-same-user) to best understand how to correctly use this method.
Anonymous vs identified events
PostHog captures two types of events: anonymous and identified (/docs/data/anonymous-vs-identified-events.md)
Identified events enable you to attribute events to specific users, and attach person properties (/docs/product-analytics/person-properties.md). They're best suited for logged-in users.
Scenarios where you want to capture identified events are:
- Tracking logged-in users in B2B and B2C SaaS apps
- Doing user segmented product analysis
- Growth and marketing teams wanting to analyze the complete conversion lifecycle
Anonymous events are events without individually identifiable data. They're best suited for web analytics (/docs/web-analytics.md) or apps where users aren't logged in.
Scenarios where you want to capture anonymous events are:
- Tracking a marketing website
- Content-focused sites
- B2C apps where users don't sign up or log in
Under the hood, the key difference between identified and anonymous events is that for identified events we create a person profile (/docs/data/persons.md) for the user, whereas for anonymous events we do not.
Important: Due to the reduced cost of processing them, anonymous events can be up to 4x cheaper than identified ones, so we recommended you only capture identified events when needed.
How to capture anonymous events
The iOS SDK captures anonymous events by default. However, this may change depending on your personProfiles config (/docs/libraries/ios/configuration.md#all-configuration-options) when initializing PostHog:
personProfiles: .identifiedOnly(recommended) (default) - Anonymous events are captured by default. PostHog only captures identified events for users where person profiles (/docs/data/persons.md) have already been created.personProfiles: .always- Capture identified events for all events.personProfiles: .never- Capture anonymous events for all events.
For example:
iOS
let config = PostHogConfig(
projectToken: POSTHOG_PROJECT_TOKEN,
host: POSTHOG_HOST
)
config.personProfiles = .identifiedOnly
PostHogSDK.shared.setup(config)How to capture identified events
If you've set the personProfiles config (/docs/libraries/ios/configuration.md#all-configuration-options) to .identifiedOnly (the default option), anonymous events are captured by default. Then, to capture identified events, call any of the following functions:
identify()(/docs/product-analytics/identify.md)alias()(/docs/product-analytics/identify.md#alias-assigning-multiple-distinct-ids-to-the-same-user)group()(/docs/product-analytics/group-analytics.md)
When you call any of these functions, it creates a person profile (/docs/data/persons.md) for the user. Once this profile is created, all subsequent events for this user will be captured as identified events.
Alternatively, you can set personProfiles to .always to capture identified events by default.
Setting person properties
To set properties (/docs/product-analytics/person-properties.md) on your users via an event, you can leverage the event properties userProperties and userPropertiesSetOnce.
When capturing an event, you can pass a property called $set as an event property, and specify its value to be an object with properties to be set on the user that will be associated with the user who triggered the event.
Swift
PostHogSDK.shared.capture("signed_up", properties: ["plan": "Pro++"], userProperties: ["user_property_name": "your_value"])userPropertiesSetOnce works just like userProperties, except that it will only set the property if the user doesn't already have that property set.
Swift
PostHogSDK.shared.capture("signed_up", properties: ["plan": "Pro++"], userPropertiesSetOnce: ["user_property_name": "your_value"])Use setPersonProperties when you want to update the current person's profile without also capturing a custom event. This sends a $set event to PostHog.
Swift
PostHogSDK.shared.setPersonProperties(userPropertiesToSet: ["plan": "Pro++"])
PostHogSDK.shared.setPersonProperties(
userPropertiesToSet: ["plan": "Pro++"],
userPropertiesToSetOnce: ["first_seen_source": "ios"]
)Super properties
Super properties are properties associated with events that are set once and then sent with every capture call, be it a $screen, or anything else.
They are set using PostHogSDK.shared.register, which takes a properties object as a parameter, and they persist across sessions.
For example, take a look at the following call:
Swift
PostHogSDK.shared.register(["team_id": 22])The call above ensures that every event sent by the user will include "team_id": 22. This way, if you filtered events by property using team_id = 22, it would display all events captured on that user after the PostHogSDK.shared.register call, since they all include the specified Super Property.
However, please note that this does not store properties against the User, only against their events. To store properties against the User object, you should use PostHogSDK.shared.identify. More information on this can be found on the Sending User Information section (#sending-user-information).
Removing stored super properties
Super properties persist across sessions so you have to explicitly remove them if they are no longer relevant. To stop sending a super property with events, you can use PostHogSDK.shared.unregister, like so:
Swift
PostHogSDK.shared.unregister("team_id")This removes the super property and subsequent events will not include it.
If you are doing this as part of a user logging out, you can instead simply use PostHogSDK.shared.reset which clears all super properties and more.
Reset after logout
To reset the user's ID and anonymous ID after logout, call reset. See Identifying users (/docs/product-analytics/identify.md#reset) for the shared reset guidance and iOS example.
Group analytics
Group analytics allows you to associate the events for that person's session with a group (e.g. teams, organizations, etc.). See Group Analytics (/docs/product-analytics/group-analytics.md) for iOS examples and implementation details.
Note: This is a paid feature and is not available on the open-source or free cloud plan. Learn more on the pricing page (/pricing.md).
Opt out of data capture
You can completely opt users out from data capture by default or on a per-person basis. See Complete opt-out (/docs/product-analytics/privacy.md#complete-opt-out) for iOS examples.
Feature flags
PostHog's feature flags (/docs/feature-flags.md) enable you to safely deploy and roll back new features as well as target specific users and groups with them.
Boolean feature flags
Swift
if let result = PostHogSDK.shared.getFeatureFlagResult("flag-key"), result.enabled {
// Do something differently for this user
// Optional: fetch the payload from the same evaluation result
let matchedFlagPayload = result.payload
}Multivariate feature flags
Swift
if let result = PostHogSDK.shared.getFeatureFlagResult("flag-key"), result.variant == "variant-key" { // replace "variant-key" with the key of your variant
// Do something differently for this user
// Optional: fetch the payload from the same evaluation result
let matchedFlagPayload = result.payload
}Typed payloads
If your payload is a JSON object, you can decode it into a Decodable type:
Swift
struct FlagPayload: Decodable {
let title: String
}
if let result = PostHogSDK.shared.getFeatureFlagResult("flag-key"),
let payload = result.payloadAs(FlagPayload.self) {
// Use payload.title
}Inspecting all feature flags
You can inspect all currently loaded feature flags with getAllFeatureFlags(). It returns each flag's key, enabled state, variant, and payload, and does not send a $feature_flag_called event, so calling it won't affect your experiment results or flag usage analytics:
Swift
for flag in PostHogSDK.shared.getAllFeatureFlags() ?? [] {
print(flag.key, flag.enabled, flag.variant as Any, flag.payload as Any)
}Reloading feature flags
Feature flag values are cached. If something has changed with your user and you'd like to refetch their flag values, call:
Swift
PostHogSDK.shared.reloadFeatureFlags()Ensuring flags are loaded before usage
Every time a user opens the app, we send a request in the background to fetch the feature flags that apply to that user. We store those flags in the storage.
This means that for most screens, the feature flags are available immediately – except for the first time a user visits.
To handle this, you can use the didReceiveFeatureFlags notification to wait for the feature flag request to finish:
Swift
class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
// register for `didReceiveFeatureFlags` notification before SDK initialization
NotificationCenter.default.addObserver(
self,
selector: #selector(receiveFeatureFlags),
name: PostHogSDK.didReceiveFeatureFlags,
object: nil
)
let POSTHOG_PROJECT_TOKEN = "<ph_project_token>"
// usually 'https://us.i.posthog.com' or 'https://eu.i.posthog.com'
let POSTHOG_HOST = "https://us.i.posthog.com"
let config = PostHogConfig(projectToken: POSTHOG_PROJECT_TOKEN, host: POSTHOG_HOST)
PostHogSDK.shared.setup(config)
return true
}
// The "receiveFeatureFlags" method will be called when the SDK receives the feature flags from the server.
@objc func receiveFeatureFlags() {
print("receiveFeatureFlags called")
}
}Alternatively, you can use the completion block of the reloadFeatureFlags(_:) method. This allows you to execute logic immediately after the flags are reloaded:
Swift
// Reload feature flags and check if a specific feature is enabled
PostHogSDK.shared.reloadFeatureFlags {
if PostHogSDK.shared.isFeatureEnabled("flag-key") {
// do something
}
}Tracking feature usage
To track when someone sees or interacts with a feature, use captureFeatureView and captureFeatureInteraction.
Swift
PostHogSDK.shared.captureFeatureView(flag: "flag-key", flagVariant: "variant-key")
PostHogSDK.shared.captureFeatureInteraction(flag: "flag-key", flagVariant: "variant-key")Bootstrapping flags
Since there is a delay between initializing PostHog and fetching feature flags, feature flags are not always available immediately. This makes them unusable if you want to do something like redirecting a user to a different page based on a feature flag.
To have your feature flags available immediately, you can initialize PostHog with precomputed values until it has had a chance to fetch them. This is called bootstrapping. After the SDK fetches feature flags from PostHog, it will use those flag values instead of bootstrapped ones.
Set config.bootstrap before calling setup() to seed identity and flag values before the first /flags response (requires iOS SDK 3.66.0+):
Swift
let config = PostHogConfig(projectToken: "<ph_project_token>", host: "https://us.i.posthog.com")
config.bootstrap = PostHogBootstrapConfig(
distinctId: "distinct_id_of_your_user",
isIdentifiedId: true,
featureFlags: [
"flag-1": true,
"variant-flag": "control"
],
featureFlagPayloads: nil
)
PostHogSDK.shared.setup(config)- Bootstrapped identity applies during setup. On a fresh install, setting it before
setup()means events captured synchronously during initialization (likeApplication Installed) carry your distinct ID instead of the SDK-generated UUID.- An anonymous bootstrap (
isIdentifiedId: false, the default) seeds the anonymous ID only when none is persisted yet. Once an anonymous ID exists on disk, or the person has been identified, the SDK ignores it. - An identified bootstrap (
isIdentifiedId: true) is for a signed-in identity available to your app (for example, from a backend session token). On a fresh install, it seeds the distinct ID, marks the person identified, and generates a separate device ID. On a returning install, a matching anonymous ID is marked identified without emitting$identify; a different anonymous ID is merged viaidentify()when person profiles are enabled. This emits$identifyunless capturing is opted out. A different, already-identified person is left untouched.
- An anonymous bootstrap (
- Bootstrapped flags are served until the first
/flagsresponse, then replaced. A complete/flagsresponse takes over entirely, so bootstrapped-only keys don't persist past it. Only enabled flags are seeded: atrueboolean or a non-empty variant string. Afalseor empty value is dropped, matching posthog-js. Seed payloads with the separatefeatureFlagPayloadsoption. Flag values and payloads must be JSON-serializable, or they're dropped. Bootstrapped flags are cleared onreset().
The feature-flags-loaded signal fires as soon as bootstrapped flags are applied, so startup logic can read them immediately. These SDKs don't support the sessionID bootstrap option. When person profiles are set to never, the SDK preserves a different anonymous identity instead of merging it into an identified bootstrap.
See the SDK bootstrapping guide (/docs/libraries/bootstrapping.md) for the cross-SDK overview.
Experiments (A/B tests)
Since experiments (/docs/experiments/start-here.md) use feature flags, the code for running an experiment is very similar to the feature flags code. See adding experiment code (/docs/experiments/adding-experiment-code.md) for iOS examples.
It's also possible to run experiments without using feature flags (/docs/experiments/running-experiments-without-feature-flags.md).
A note about IDFA (identifier for advertisers) collection in iOS 14
Starting with iOS 14, Apple will further restrict apps that track users. Any references to Apple's AdSupport framework, even in strings, will trip the App Store's static analysis.
Hence starting with posthog-ios version 1.2.0 we have removed all references to Apple's AdSupport framework.
Session replay
Note: Session replay is currently only available on iOS. For future macOS support, please follow and upvote this GitHub issue.
To set up session replay (/docs/session-replay/mobile.md) in your project, all you need to do is install the iOS SDK, enable "Record user sessions" in your project settings and enable the sessionReplay option.
Surveys
Surveys (/docs/surveys.md) launched with popover presentation (/docs/surveys/creating-surveys.md#presentation) are automatically shown to users matching the display conditions (/docs/surveys/creating-surveys.md#display-conditions) you set up.
Error tracking
To set up error tracking in your project, see the error tracking docs (/docs/error-tracking.md).
Debug mode
If you're not seeing the expected events being captured, the feature flags being evaluated, or the surveys being shown, you can enable debug mode to see what's happening.
You can enable debug mode by setting the debug option to true in the PostHogConfig object. A common pattern is to set this to true in development environments only for local development.
Swift
let config = PostHogConfig(projectToken: "<ph_project_token>", host: "https://us.i.posthog.com")
config.debug = true
PostHogSDK.shared.setup(config)This will enable verbose logs about the inner workings of the SDK.
You can also toggle debug by calling the PostHogSDK.shared.debug() method in your code.
Swift
// Enable debug mode
PostHogSDK.shared.debug(true)
// Disable debug mode
PostHogSDK.shared.debug(false)Still have questions?
Ask PostHog AI
Was this page useful?
HelpfulCould be better
references/vue-js.md
AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt
Vue.js
PostHog makes it easy to get data about usage of your Vue.js app. Integrating PostHog into your app enables analytics about user behavior, custom events capture, session replays, feature flags, and more.
This guide walks you through integrating PostHog into your app for both Vue 2 and Vue 3. We'll use the JavaScript Web SDK (/docs/libraries/js.md) for this.
For integrating PostHog into a Nuxt.js app, see our Nuxt guide (/docs/libraries/nuxt-js.md).
Prerequisites
To follow this guide along, you need:
- A PostHog account
- A running Vue.js app
Setting up PostHog
Start by installing posthog-js using your package manager:
npm
npm install --save posthog-jsYarn
yarn add posthog-jspnpm
pnpm add posthog-jsBun
bun add posthog-jsIf your site sets a Content-Security-Policy, it needs to allow PostHog. This applies to the snippet and to package installs alike: the SDK lazy-loads extra bundles (session replay, surveys) from PostHog's CDN, and sends events to the ingestion host. PostHog serves from subdomains of
posthog.comthat change over time, so allow the wildcard:script-src 'self' https://*.posthog.com; connect-src 'self' https://*.posthog.com; worker-src 'self' blob: data:;
script-srccovers the snippet and the lazy-loaded bundles,connect-srccovers event ingestion and feature flags, andworker-srccovers session replay. The toolbar needs a few more (/docs/advanced/content-security-policy.md), or use a reverse proxy (/docs/advanced/proxy.md) so everything is first-party. Failing to do so causes silent failures wherecaptureandidentifycalls never send, so the integration looks complete while zero events arrive. Rememberconnect-srcfalls back todefault-src, sodefault-src 'self'blocks event delivery even when the script itself is bundled.
Next, depending on your Vue version, we recommend initializing PostHog using the composition API or as a plugin.
Vue 3: Composition API
We use the Composition API as it provides better accessibility, maintainability, and type safety.
PostHog initializes as a singleton, so you can initialize it in your main.ts file before you mount your app. This ensures PostHog is initialized before any other code runs.
src/main.ts
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import posthog from "posthog-js";
const app = createApp(App);
posthog.init(import.meta.env.VITE_POSTHOG_PROJECT_TOKEN || '<ph_project_token>', {
api_host: import.meta.env.VITE_POSTHOG_HOST || 'https://us.i.posthog.com',
defaults: '2026-05-30',
});
app.use(createPinia())
app.use(router)
app.config.errorHandler = (err, instance, info) => {
posthog.captureException(err)
}
app.mount('#app')Then, you can access PostHog throughout your app just by importing it from posthog-js.
TypeScript
// src/App.vue
<script setup>
import posthog from 'posthog-js'
const handleClick = () => {
posthog.capture('button_clicked')
}
</script>Once done, PostHog will begin autocapturing (/docs/product-analytics/autocapture.md) events and pageviews (if enabled) and is ready to use throughout your app.
Vue 2: Plugins
Start by creating a plugins folder and adding a posthog.js file to that folder. In posthog.js, initialize PostHog using the install method with your project token and host. You can find these in your project settings.
JavaScript
// src/plugins/posthog.js
import posthog from 'posthog-js'
export default {
install(Vue) {
posthog.init('<ph_project_token>', {
api_host: 'https://us.i.posthog.com',
defaults: '2026-05-30'
})
Vue.prototype.$posthog = posthog
}
}Next, in main.js, import and use the plugin.
JavaScript
// src/main.js
import Vue from 'vue'
import App from './App.vue'
import PosthogPlugin from './plugins/posthog'
Vue.config.productionTip = false
Vue.use(PosthogPlugin)
new Vue({
render: h => h(App),
}).$mount('#app')This makes PostHog available as this.$posthog in any Vue component.
Identifying users
Identifying users is required. Call
posthog.identify('your-user-id')after login to link events to a known user. This is what connects frontend event captures, session replays (/docs/session-replay.md), LLM traces (/docs/ai-engineering.md), and error tracking (/docs/error-tracking.md) to the same person — and lets backend events link back too.Use a stable ID from your auth system when possible, not an email or display name. Send those as person properties instead. If your app has no other stable key, email works as a fallback if they are unique. Never a shared literal like
"anonymous"or"user", which pools many people onto one person and corrupts their data. When no ID is available at all, skip the identify and retain the anonymous distinct ID that's automatically assigned.Call
posthog.reset()on logout, so the next person to use the browser doesn't inherit the last one's identity.See our guide on identifying users (/docs/getting-started/identify-users.md) for how to set this up.
If your app calls your own backend, tracing_headers adds X-POSTHOG-DISTINCT-ID and X-POSTHOG-SESSION-ID to matching fetch and XMLHttpRequest requests. This lets server-side SDKs link backend events, errors, and LLM traces back to frontend sessions and replays. Use hostnames only, without protocols or paths.
JavaScript
posthog.init('<ph_project_token>', {
api_host: 'https://us.i.posthog.com',
// Optional: send PostHog session/user context to your backend
tracing_headers: ['api.example.com'],
})This works in local development too, but match on the hostname alone: use 'localhost', not 'localhost:3000'. Ports are never part of a hostname, so a value with one in it never matches anything. localhost and 127.0.0.1 are also different hostnames — use whichever your app actually calls.
Tracing headers help you attribute events across front and backend consistently. When this isn't available, use your server-side stable IDs to deduce the matching distinctId, and pass it in when capturing the event.
Capturing custom events, using feature flags, and more
Once you have PostHog initialized, there is a lot more you can do with it beyond autocapture, pageviews, and pageleaves. You can find the full details in our JavaScript SDK docs (/docs/libraries/js/usage.md), but we'll cover a few examples here.
Vue 3: Composition API
To capture custom events, evaluate feature flags, and use any of the other PostHog features, you can use the posthog object returned from the usePostHog composable like this:
JavaScript
// src/App.vue
<script setup>
import { RouterView } from 'vue-router'
import { usePostHog } from './composables/usePostHog'
const { posthog } = usePostHog()
const handleClick = () => {
posthog.capture('button_clicked', { location: 'homepage' })
}
</script>
<template>
<div>
<button @click="handleClick">Click me!</button>
</div>
<RouterView />
</template>Feature flags with reactive updates
When using feature flags on pages that users navigate to directly, the flags may not be loaded when the component first renders. To ensure your UI updates reactively when flags load, create a composable that returns a reactive ref:
JavaScript
// src/composables/usePostHogFeatureFlag.ts
import { ref, type Ref } from 'vue'
import { usePostHog } from './usePostHog'
export function usePostHogFeatureFlag(
feature: string,
): Ref<string | boolean | undefined> {
const { posthog } = usePostHog()
const flag = ref(posthog.getFeatureFlag(feature))
posthog.onFeatureFlags(() => {
flag.value = posthog.getFeatureFlag(feature)
})
return flag
}Then use it in your components:
JavaScript
// src/App.vue
<script setup>
import { RouterView } from 'vue-router'
import { usePostHog } from './composables/usePostHog'
import { usePostHogFeatureFlag } from './composables/usePostHogFeatureFlag'
const { posthog } = usePostHog()
const isFeatureEnabled = usePostHogFeatureFlag('test-flag')
const handleClick = () => {
posthog.capture('button_clicked', { location: 'homepage' })
}
</script>
<template>
<div>
<button @click="handleClick">Click me!</button>
<p>Is feature flag enabled? {{ isFeatureEnabled ? 'Yes' : 'No' }}</p>
</div>
<RouterView />
</template>This ensures your component will automatically update when feature flags load, even if the page is accessed directly.
Vue 2: Plugins
To capture custom events, evaluate feature flags, and use any of the other PostHog features, you can use the $posthog object returned from the plugin like this:
JavaScript
// src/components/AboutPage.vue
<template>
<div class="about">
<h1>About Page</h1>
<button @click="handleClick">Test PostHog</button>
<router-link to="/">Go to Home</router-link>
<p>Feature enabled? {{ isFeatureEnabled ? 'Yes' : 'No' }}</p>
</div>
</template>
<script>
export default {
name: 'AboutPage',
data() {
return {
isFeatureEnabled: false
}
},
created() {
this.isFeatureEnabled = this.$posthog.isFeatureEnabled('test-flag')
},
methods: {
handleClick() {
this.$posthog.capture('button_clicked')
}
}
}
</script>Set up a reverse proxy (recommended)
We recommend setting up a reverse proxy (/docs/advanced/proxy.md), so that events are less likely to be intercepted by tracking blockers.
We have our own managed reverse proxy service (/docs/advanced/proxy/managed-reverse-proxy.md), which is free for all PostHog Cloud users, routes through our infrastructure, and makes setting up your proxy easy.
If you don't want to use our managed service then there are several other options for creating a reverse proxy, including using Cloudflare (/docs/advanced/proxy/cloudflare.md), AWS Cloudfront (/docs/advanced/proxy/cloudfront.md), and Vercel (/docs/advanced/proxy/vercel.md).
Grouping products in one project (recommended)
If you have multiple customer-facing products (e.g. a marketing website + mobile app + web app), it's best to install PostHog on them all and group them in one project (/docs/settings/projects.md).
This makes it possible to track users across their entire journey (e.g. from visiting your marketing website to signing up for your product), or how they use your product across multiple platforms.
Add IPs to Firewall/WAF allowlists (recommended)
For certain features like heatmaps (/docs/toolbar/heatmaps.md), your Web Application Firewall (WAF) may be blocking PostHog's requests to your site. Add these IP addresses to your WAF allowlist or rules to let PostHog access your site.
EU: 3.75.65.221, 18.197.246.42, 3.120.223.253
US: 44.205.89.55, 52.4.194.122, 44.208.188.173
These are public, stable IPs used by PostHog services.
PostHog captures heatmap screenshots using Browserless, which has its own IP addresses. Browserless publishes the current list here.
An allowlist does not help when your app has a private address. For apps on an internal network, see internal and intranet applications (/docs/session-replay/troubleshooting.md#internal-and-intranet-applications).
Next steps
For any technical questions for how to integrate specific PostHog features into Vue (such as analytics, feature flags, A/B testing, or surveys), have a look at our JavaScript Web (/docs/libraries/js/usage.md) SDK docs.
Alternatively, the following tutorials can help you get started:
- How to set up analytics in Vue (/tutorials/vue-analytics.md)
- How to set up feature flags in Vue (/tutorials/vue-feature-flags.md)
- How to set up A/B tests in Vue (/tutorials/vue-ab-tests.md)
- How to set up surveys in Vue (/tutorials/vue-surveys.md)
Still have questions?
Ask PostHog AI
Was this page useful?
HelpfulCould be better
Frontmatter written into each target's SKILL.md.
Common
No fields set for this target.