Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions backend/posthog_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""
PostHog client initialization for error tracking.

This module provides optional PostHog integration. If POSTHOG_API_KEY
is not set, all tracking functions become no-ops and the application
continues to work normally.

Usage:
from posthog_client import capture_exception, capture_event, posthog_client

# In exception handlers:
capture_exception(exc, {"path": "/api/foo", "user_id": "123"})

# For custom events:
capture_event("backend-server", "api_error", {"status_code": 500})
"""

import logging
import os
import sys
import traceback
from typing import Any, Optional

from dotenv import load_dotenv

load_dotenv()

logger = logging.getLogger(__name__)

# Environment configuration
POSTHOG_API_KEY = (os.getenv("POSTHOG_API_KEY") or "").strip()
POSTHOG_HOST = (os.getenv("POSTHOG_HOST") or "https://us.i.posthog.com").strip()

# Initialize PostHog client (None if not configured)
posthog_client: Optional[Any] = None

if POSTHOG_API_KEY:
try:
from posthog import Posthog

posthog_client = Posthog(
project_api_key=POSTHOG_API_KEY,
host=POSTHOG_HOST,
)
logger.info(f"PostHog error tracking initialized (host: {POSTHOG_HOST})")
except Exception as e:
logger.warning(f"Failed to initialize PostHog client: {e}")
posthog_client = None
else:
logger.info("POSTHOG_API_KEY not set - error tracking disabled")


def capture_exception(
exception: BaseException,
properties: Optional[dict[str, Any]] = None,
distinct_id: str = "backend-server",
) -> None:
"""
Capture an exception to PostHog for error tracking.

Args:
exception: The exception to capture
properties: Additional properties to include with the error
distinct_id: The user/system identifier (defaults to "backend-server")
"""
if posthog_client is None:
return

try:
# Build exception properties
exc_type = type(exception).__name__
exc_message = str(exception)
exc_traceback = "".join(
traceback.format_exception(type(exception), exception, exception.__traceback__)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we have to do all this? isn't this stuff builtin to Posthog? ("exception autocapture"?)

)

error_properties = {
"$exception_type": exc_type,
"$exception_message": exc_message,
"$exception_stack_trace_raw": exc_traceback,
"exception_type": exc_type,
"exception_message": exc_message,
}

if properties:
error_properties.update(properties)

posthog_client.capture(
distinct_id=distinct_id,
event="$exception",
properties=error_properties,
)
except Exception as e:
# Never let PostHog errors break the application
logger.warning(f"Failed to capture exception to PostHog: {e}")


def capture_event(
distinct_id: str,
event: str,
properties: Optional[dict[str, Any]] = None,
) -> None:
"""
Capture a custom event to PostHog.

Args:
distinct_id: The user/system identifier
event: The event name
properties: Additional properties to include with the event
"""
if posthog_client is None:
return

try:
posthog_client.capture(
distinct_id=distinct_id,
event=event,
properties=properties or {},
)
except Exception as e:
# Never let PostHog errors break the application
logger.warning(f"Failed to capture event to PostHog: {e}")


def shutdown() -> None:
"""Flush and shutdown the PostHog client gracefully."""
if posthog_client is not None:
try:
posthog_client.shutdown()
except Exception as e:
logger.warning(f"Error shutting down PostHog client: {e}")
52 changes: 50 additions & 2 deletions backend/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@
from typing import Annotated, Any, Dict, List, Literal

import nlp
import posthog_client
import uvicorn
from dotenv import load_dotenv
from fastapi import BackgroundTasks, Body, FastAPI
from fastapi import BackgroundTasks, Body, FastAPI, Request
from fastapi.exception_handlers import request_validation_exception_handler
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
Expand Down Expand Up @@ -137,6 +138,8 @@ class RequestLog(Log):
async def app_lifespan(app: FastAPI):
await nlp.warmup_nlp()
yield
# Shutdown PostHog client gracefully
posthog_client.shutdown()

app = FastAPI(lifespan=app_lifespan)

Expand All @@ -152,12 +155,56 @@ async def app_lifespan(app: FastAPI):
)


# PostHog Error Tracking Middleware
@app.middleware("http")
async def error_tracking_middleware(request: Request, call_next):
"""Capture unhandled exceptions to PostHog for error tracking."""
try:
response = await call_next(request)
return response
except Exception as exc:
posthog_client.capture_exception(
exc,
properties={
"path": request.url.path,
"method": request.method,
"query_params": str(request.query_params),
},
)
raise


@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc):
async def validation_exception_handler(request: Request, exc: RequestValidationError):
print(f"The client sent invalid data!: {exc}")
posthog_client.capture_exception(
exc,
properties={
"path": request.url.path,
"method": request.method,
"error_type": "validation_error",
},
)
return await request_validation_exception_handler(request, exc)


@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
"""Catch-all exception handler for unhandled errors."""
logger.error(f"Unhandled exception: {exc}", exc_info=True)
posthog_client.capture_exception(
exc,
properties={
"path": request.url.path,
"method": request.method,
"error_type": "unhandled_exception",
},
)
return JSONResponse(
status_code=500,
content={"detail": "Internal server error"},
)


# Routes

Expand Down Expand Up @@ -282,6 +329,7 @@ async def ping() -> PingResponse:
return PingResponse(timestamp=datetime.now())



# Log viewer endpoint
class LogsPollRequest(BaseModel):
log_positions: Dict[str, int]
Expand Down
20 changes: 20 additions & 0 deletions backend/tests/test_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,25 @@ def test_nlp_imports():
assert hasattr(nlp, "chat_stream")


def test_posthog_client_imports():
"""Verify the posthog_client module can be imported without errors.

This is critical: the module must import successfully even when
POSTHOG_API_KEY is not set.
"""
import posthog_client # noqa: F401

# Verify the module has expected functions (they should be no-ops when disabled)
assert hasattr(posthog_client, "capture_exception")
assert hasattr(posthog_client, "capture_event")
assert hasattr(posthog_client, "shutdown")

# These functions should not raise even when PostHog is disabled
posthog_client.capture_exception(Exception("test"))
posthog_client.capture_event("test", "test_event")
posthog_client.shutdown()


def test_server_imports():
"""Verify the server module can be imported without errors."""
import server # noqa: F401
Expand All @@ -43,3 +62,4 @@ def test_server_routes_registered():
assert "/api/reflections" in routes
assert "/api/chat" in routes
assert "/api/log" in routes
assert "/api/test-error" in routes # PostHog error tracking test endpoint
2 changes: 2 additions & 0 deletions docker-compose-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ services:
- PORT=5000
- DEBUG=False
- OPENAI_API_KEY=${OPENAI_API_KEY}
- POSTHOG_API_KEY=${POSTHOG_API_KEY:-}
- POSTHOG_HOST=${POSTHOG_HOST:-https://us.i.posthog.com}
volumes:
- /opt/thoughtful/logs:/app/backend/logs

Expand Down
5 changes: 5 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ services:
build:
context: ./frontend
dockerfile: Dockerfile
args:
POSTHOG_KEY: ${POSTHOG_KEY:-}
POSTHOG_HOST: ${POSTHOG_HOST:-https://e.thoughtful-ai.com/}
depends_on:
- backend
restart: unless-stopped
Expand All @@ -15,6 +18,8 @@ services:
- PORT=5000
- OPENAI_API_KEY=${OPENAI_API_KEY}
- LOG_SECRET=${LOG_SECRET}
- POSTHOG_API_KEY=${POSTHOG_API_KEY:-}
- POSTHOG_HOST=${POSTHOG_HOST:-https://e.thoughtful-ai.com/}
restart: unless-stopped

experiment:
Expand Down
3 changes: 3 additions & 0 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@

# Build output
dist/

# Playwright
node_modules/
/test-results/
Expand Down
5 changes: 5 additions & 0 deletions frontend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ RUN npm ci

COPY . ./

ARG POSTHOG_KEY=""
ARG POSTHOG_HOST="https://e.thoughtful-ai.com/"
ENV POSTHOG_KEY=$POSTHOG_KEY
ENV POSTHOG_HOST=$POSTHOG_HOST

RUN npm run build && ls -la

# Production stage
Expand Down
Loading