diff --git a/context/commandments.yaml b/context/commandments.yaml index a04e20d0..8b42f732 100644 --- a/context/commandments.yaml +++ b/context/commandments.yaml @@ -66,7 +66,9 @@ commandments: - posthog is the Python SDK package name - Install dependencies with `pip install posthog` or `pip install -r requirements.txt` and do NOT use unquoted version specifiers like `>=` directly in shell commands - 'In CLIs and scripts: MUST call posthog.shutdown() before exit or all events are lost' - - Always use the Posthog() class constructor (instance-based API) instead of module-level posthog.api_key config + - 'Initialize with the Posthog() class constructor — `posthog_client = Posthog("", host="https://us.i.posthog.com")` — and share that ONE instance across the app. Module-level `posthog.api_key = ...` config is the legacy pattern: when a docs snippet shows it, translate it to the constructor. (Exception: Django — its documented apps.py setup is module-level by design; follow the Django docs there)' + - 'With a constructor-built client, call capture(), capture_exception(), feature_enabled(), and get_feature_flag_payload() ON THAT INSTANCE. Do NOT mix in the module-level helpers (`from posthog import capture`) — they route to a separate hidden default client configured only by module-level settings, so events sent through them are silently dropped when you initialized via the constructor' + - 'The context helpers new_context(), identify_context(), tag(), and set_context_session() DO work with an instance client: import them module-level (`from posthog import new_context, identify_context, tag`) or call them as instance methods — the context stack is process-global. Instance capture calls inside `with new_context():` automatically pick up the context distinct_id and tags, so no distinct_id argument is needed there; pass distinct_id explicitly only when capturing outside a context' - Always include enable_exception_autocapture=True in the Posthog() constructor to automatically track exceptions - NEVER send PII in capture() event properties — no emails, full names, phone numbers, physical addresses, IP addresses, or user-generated content - PII belongs in identify() person properties, NOT in capture() event properties. Safe event properties are metadata like message_length, form_type, boolean flags. @@ -221,14 +223,14 @@ commandments: react-native: - posthog-react-native is the React Native SDK package name - - Use react-native-config to load POSTHOG_PROJECT_TOKEN and POSTHOG_HOST from .env (variables are embedded at build time, not runtime) + - 'In bare React Native (non-Expo) projects, use react-native-config to load POSTHOG_PROJECT_TOKEN and POSTHOG_HOST from .env (variables are embedded at build time, not runtime). Expo projects must NOT use react-native-config — when the expo rules are also present, they take precedence over this line' - react-native-svg is a required peer dependency of posthog-react-native (used by the surveys feature) and must be installed alongside it - Place PostHogProvider INSIDE NavigationContainer for React Navigation v7 compatibility expo: - posthog-react-native is the React Native SDK package name (same as bare RN) - - Use expo-constants with app.config.js extras for POSTHOG_PROJECT_TOKEN and POSTHOG_HOST (NOT react-native-config) - - Access config via `Constants.expoConfig?.extra?.posthogProjectToken` in your posthog.ts config file + - 'Load PostHog config from EXPO_PUBLIC_-prefixed env vars via process.env (e.g. `process.env.EXPO_PUBLIC_POSTHOG_KEY`) — the standard Expo approach when the project has no app.config.js; when the project already manages config through app.config.js extras, expo-constants extras also works. NEVER use react-native-config in Expo — this rule overrides the bare-React-Native env-var rule when both appear' + - 'When using the app.config.js extras approach, access config via `Constants.expoConfig?.extra?.posthogProjectToken` in your posthog.ts config file' - For expo-router, wrap PostHogProvider in app/_layout.tsx and manually track screens with `posthog.screen(pathname, params)` in a useEffect flutter: diff --git a/context/skills/self-driving/references/3b-enable-products.md b/context/skills/self-driving/references/3b-enable-products.md index 119a76a6..0970dbdc 100644 --- a/context/skills/self-driving/references/3b-enable-products.md +++ b/context/skills/self-driving/references/3b-enable-products.md @@ -18,11 +18,13 @@ Emit: ## Tools -Reach `products-enable` through the PostHog `exec` tool (`info products-enable`, then `call products-enable `). +The purpose-built `products-enable` tool is the preferred path, but it is **not yet published on the PostHog MCP** — check for it exactly once with `info products-enable` and branch on the result. Do not spend turns searching for it under other names. The fallback is `project-settings-update`, which flips the same products as raw project-settings fields. ## Do -1. Call `products-enable` to turn the products on: +1. Enable the products. Run `info products-enable` once: + + **If the tool exists**, call it: ``` { "products": ["session_replay", "error_tracking", "conversations"] } @@ -30,7 +32,15 @@ Reach `products-enable` through the PostHog `exec` tool (`info products-enable`, It is idempotent and server-owned — the response is `{ "results": { : "enabled" | "already_enabled" } }`. The run prompt's "Project state read at auth time" block tells you which are already ON, so you can leave those out (re-sending is harmless either way). Record the per-product result — the report lists it. - If the call is rejected for permissions (e.g. some of these need project admin the user lacks), don't abort: record a follow-up to enable them from a project-admin account, and continue. **A rejection here does not block the next step** — enabling a product (this step) and enabling its signal source (step 4) are independent calls, so step 4 still switches the sources on. They simply sit idle until the products are on, then pick up data with no re-setup. + **If the tool is not found** (the expected case today), call `project-settings-update` instead — PATCH semantics, only the fields you send change: + +``` +{ "id": "@current", "session_recording_opt_in": true, "autocapture_exceptions_opt_in": true, "conversations_enabled": true } +``` + + Leave out any field the "Project state read at auth time" block shows as already ON. One difference from `products-enable`: this does not mint the Support widget token, so if Conversations was previously off, record a follow-up to finish Support setup in the PostHog UI even when the call succeeds. + + If either call is rejected for permissions, don't abort — that outcome is expected on some tokens: the wizard's token carries the narrow `product_enablement:write` scope minted for `products-enable`, not the broader project write access `project-settings-update` needs. Record a follow-up to enable the products from a project-admin account (Settings → Session replay / Error tracking / Conversations), and continue. **A rejection here does not block the next step** — enabling a product (this step) and enabling its signal source (step 4) are independent calls, so step 4 still switches the sources on. They simply sit idle until the products are on, then pick up data with no re-setup. 2. **Web app** (this repo serves a browser frontend / loads `posthog-js`): the server flip only takes effect if the client init doesn't override it. Find the `posthog.init(...)` call and check its options: - `disable_session_recording: true` cancels the replay enable → remove that option (or set it `false`). diff --git a/example-apps/fastapi/app/main.py b/example-apps/fastapi/app/main.py index 3ceed504..a3940303 100644 --- a/example-apps/fastapi/app/main.py +++ b/example-apps/fastapi/app/main.py @@ -3,20 +3,17 @@ 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.posthog_client import posthog_client from app.routers import api, main -settings = get_settings() - # Setup templates templates_dir = Path(__file__).parent / "templates" templates = Jinja2Templates(directory=str(templates_dir)) @@ -25,11 +22,7 @@ @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 + # PostHog client is constructed in app.posthog_client at import time # Initialize database and seed default user init_db() @@ -48,8 +41,7 @@ async def lifespan(app: FastAPI): yield # Shutdown: Flush PostHog events - if not settings.posthog_disabled: - posthog.flush() + posthog_client.flush() app = FastAPI( diff --git a/example-apps/fastapi/app/middleware.py b/example-apps/fastapi/app/middleware.py index 1b063c82..06465fe1 100644 --- a/example-apps/fastapi/app/middleware.py +++ b/example-apps/fastapi/app/middleware.py @@ -18,7 +18,7 @@ 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. + can just call posthog_client.capture() without setting up context each time. Uses pure ASGI interface for better performance than BaseHTTPMiddleware. """ diff --git a/example-apps/fastapi/app/posthog_client.py b/example-apps/fastapi/app/posthog_client.py new file mode 100644 index 00000000..c50386c1 --- /dev/null +++ b/example-apps/fastapi/app/posthog_client.py @@ -0,0 +1,22 @@ +"""Shared PostHog client instance. + +The SDK's module-level helpers (`from posthog import capture`) route to a +separate default client configured only by module-level settings — mixing them +with a constructor-built client silently drops events. All captures and flag +checks must go through this instance. Context helpers (new_context, +identify_context, tag) are safe to import module-level: the context stack is +process-global and applies to captures from this instance. +""" + +from posthog import Posthog + +from app.config import get_settings + +settings = get_settings() + +posthog_client = Posthog( + settings.posthog_project_token, + host=settings.posthog_host, + debug=settings.debug, + disabled=settings.posthog_disabled, +) diff --git a/example-apps/fastapi/app/routers/api.py b/example-apps/fastapi/app/routers/api.py index 276bb5f0..cfb37e28 100644 --- a/example-apps/fastapi/app/routers/api.py +++ b/example-apps/fastapi/app/routers/api.py @@ -2,12 +2,11 @@ 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 +from app.posthog_client import posthog_client router = APIRouter() @@ -23,7 +22,7 @@ async def consider_burrito( safe_count = max(0, min(burrito_count, MAX_BURRITO_COUNT)) new_count = safe_count + 1 - capture("burrito_considered", properties={"total_considerations": new_count}) + posthog_client.capture("burrito_considered", properties={"total_considerations": new_count}) response = JSONResponse({"success": True, "count": new_count}) response.set_cookie( @@ -47,7 +46,7 @@ async def test_error( raise Exception("Test exception from critical operation") except Exception as e: if should_capture: - event_id = posthog.capture_exception(e) + event_id = posthog_client.capture_exception(e) return JSONResponse( { "error": "Operation failed", @@ -83,8 +82,8 @@ async def trigger_error( else: raise Exception(error_message) except Exception as e: - posthog.capture_exception(e) - capture( + posthog_client.capture_exception(e) + posthog_client.capture( "error_triggered", properties={"error_type": safe_error_type, "error_message": error_message}, ) @@ -123,7 +122,7 @@ async def generate_activity_report( row_count = len(report_data) - capture( + posthog_client.capture( "report_generated", properties={ "report_type": safe_report_type, diff --git a/example-apps/fastapi/app/routers/main.py b/example-apps/fastapi/app/routers/main.py index ffb0719f..cf108e1f 100644 --- a/example-apps/fastapi/app/routers/main.py +++ b/example-apps/fastapi/app/routers/main.py @@ -3,11 +3,12 @@ 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 posthog import identify_context, new_context + +from app.posthog_client import posthog_client from app.dependencies import ( CurrentUser, @@ -49,7 +50,7 @@ async def login( is_new_user = user.record_login(db) with new_context(): identify_context(user.email) - capture( + posthog_client.capture( "user_logged_in", properties={ "$set": {"email": user.email, "is_staff": user.is_staff}, @@ -115,7 +116,7 @@ async def signup( with new_context(): identify_context(user.email) - capture( + posthog_client.capture( "user_signed_up", properties={ "$set": {"email": user.email, "is_staff": user.is_staff}, @@ -138,7 +139,7 @@ async def signup( @router.get("/logout") async def logout(current_user: RequiredUser): """Logout and capture event.""" - capture("user_logged_out") + posthog_client.capture("user_logged_out") response = RedirectResponse(url="/", status_code=302) response.delete_cookie(key="session_token") @@ -151,10 +152,10 @@ async def dashboard( current_user: RequiredUser, ): """Dashboard with feature flag demonstration.""" - capture("dashboard_viewed", properties={"is_staff": current_user.is_staff}) + posthog_client.capture("dashboard_viewed", properties={"is_staff": current_user.is_staff}) # Check feature flag - show_new_feature = posthog.feature_enabled( + show_new_feature = posthog_client.feature_enabled( "new-dashboard-feature", current_user.email, person_properties={ @@ -164,7 +165,7 @@ async def dashboard( ) # Get feature flag payload - feature_config = posthog.get_feature_flag_payload( + feature_config = posthog_client.get_feature_flag_payload( "new-dashboard-feature", current_user.email ) @@ -196,7 +197,7 @@ async def burrito( @router.get("/profile", response_class=HTMLResponse) async def profile(request: Request, current_user: RequiredUser): """User profile page.""" - capture("profile_viewed") + posthog_client.capture("profile_viewed") return templates.TemplateResponse( request, "profile.html", {"current_user": current_user} @@ -214,7 +215,7 @@ async def update_profile( fields_changed = current_user.update_profile(db, name=name) if fields_changed: - capture( + posthog_client.capture( "profile_updated", properties={ "username": current_user.email, diff --git a/example-apps/flask/app/__init__.py b/example-apps/flask/app/__init__.py index a3f67db8..3241896a 100644 --- a/example-apps/flask/app/__init__.py +++ b/example-apps/flask/app/__init__.py @@ -1,13 +1,11 @@ """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 +from app.extensions import db, login_manager, posthog_client def create_app(config_name="default"): @@ -19,11 +17,9 @@ def create_app(config_name="default"): 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"] + # PostHog client is constructed in app.extensions; align debug logging + # with the active app config + posthog_client.debug = app.config["DEBUG"] # Import models after db is initialized from app.models import User diff --git a/example-apps/flask/app/api/routes.py b/example-apps/flask/app/api/routes.py index 8a24cc57..45adee42 100644 --- a/example-apps/flask/app/api/routes.py +++ b/example-apps/flask/app/api/routes.py @@ -1,11 +1,11 @@ """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 posthog import identify_context, new_context from app.api import api_bp +from app.extensions import posthog_client @api_bp.route("/burrito/consider", methods=["POST"]) @@ -19,7 +19,7 @@ def consider_burrito(): # PostHog: Capture custom event with new_context(): identify_context(current_user.email) - capture("burrito_considered", properties={"total_considerations": burrito_count}) + posthog_client.capture("burrito_considered", properties={"total_considerations": burrito_count}) return jsonify({"success": True, "count": burrito_count}) @@ -45,7 +45,7 @@ def test_error(): # Manually capture this specific exception in PostHog with new_context(): identify_context(current_user.email) - event_id = posthog.capture_exception(e) + event_id = posthog_client.capture_exception(e) return jsonify({ "error": "Operation failed", diff --git a/example-apps/flask/app/extensions.py b/example-apps/flask/app/extensions.py index 43665757..19bfcd33 100644 --- a/example-apps/flask/app/extensions.py +++ b/example-apps/flask/app/extensions.py @@ -2,9 +2,24 @@ from flask_login import LoginManager from flask_sqlalchemy import SQLAlchemy +from posthog import Posthog + +from app.config import Config db = SQLAlchemy() +# Shared PostHog client. The SDK's module-level helpers (`from posthog import +# capture`) route to a separate default client configured only by module-level +# settings — mixing them with a constructor-built client silently drops events. +# All captures and flag checks must go through this instance. Context helpers +# (new_context, identify_context, tag) are safe to import module-level: the +# context stack is process-global and applies to captures from this instance. +posthog_client = Posthog( + Config.POSTHOG_PROJECT_TOKEN, + host=Config.POSTHOG_HOST, + disabled=Config.POSTHOG_DISABLED, +) + login_manager = LoginManager() login_manager.login_view = "main.home" login_manager.login_message = "Please log in to access this page." diff --git a/example-apps/flask/app/main/routes.py b/example-apps/flask/app/main/routes.py index e5a3ab13..de12dfcb 100644 --- a/example-apps/flask/app/main/routes.py +++ b/example-apps/flask/app/main/routes.py @@ -1,10 +1,10 @@ """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, tag +from posthog import identify_context, new_context, tag +from app.extensions import posthog_client from app.main import main_bp from app.models import User @@ -32,7 +32,7 @@ def home(): tag("is_staff", user.is_staff) tag("date_joined", user.date_joined.isoformat()) - capture("user_logged_in", properties={"login_method": "password"}) + posthog_client.capture("user_logged_in", properties={"login_method": "password"}) return redirect(url_for("main.dashboard")) else: @@ -75,7 +75,7 @@ def signup(): tag("is_staff", user.is_staff) tag("date_joined", user.date_joined.isoformat()) - capture("user_signed_up", properties={"signup_method": "form"}) + posthog_client.capture("user_signed_up", properties={"signup_method": "form"}) # Log the user in login_user(user) @@ -92,7 +92,7 @@ def logout(): # PostHog: Capture logout event before session ends with new_context(): identify_context(current_user.email) - capture("user_logged_out") + posthog_client.capture("user_logged_out") logout_user() return redirect(url_for("main.home")) @@ -105,10 +105,10 @@ def dashboard(): # PostHog: Capture dashboard view with new_context(): identify_context(current_user.email) - capture("dashboard_viewed", properties={"is_staff": current_user.is_staff}) + posthog_client.capture("dashboard_viewed", properties={"is_staff": current_user.is_staff}) # Check feature flag - show_new_feature = posthog.feature_enabled( + show_new_feature = posthog_client.feature_enabled( "new-dashboard-feature", current_user.email, person_properties={ @@ -118,7 +118,7 @@ def dashboard(): ) # Get feature flag payload - feature_config = posthog.get_feature_flag_payload( + feature_config = posthog_client.get_feature_flag_payload( "new-dashboard-feature", current_user.email ) @@ -144,6 +144,6 @@ def profile(): # PostHog: Capture profile view with new_context(): identify_context(current_user.email) - capture("profile_viewed") + posthog_client.capture("profile_viewed") return render_template("profile.html")