Skip to content

Commit 4e29b5a

Browse files
Merge PR #5: Wire Sentry + PostHog observability + EU cookie consent banner
Mirrors HelpmateAI's observability stack into AI Job Agent. Sentry (jobagent-backend + jobagent-frontend) + PostHog (shared free-tier project, product=jobagent tag) + GDPR cookie banner. Source-map upload via SENTRY_AUTH_TOKEN. Code mappings live for both projects. CI green on b53d8c1 after the leaky-detail allowlist line-drift fix.
1 parent 17afdfb commit 4e29b5a

22 files changed

Lines changed: 4791 additions & 69 deletions

.env.example

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,36 @@ AIJOBAGENT_LEMONSQUEEZY_PRODUCT_VARIANT_BUSINESS=
133133
NEXT_PUBLIC_LEMONSQUEEZY_STORE_ID=
134134
NEXT_PUBLIC_LEMONSQUEEZY_PRODUCT_VARIANT_PRO=
135135
NEXT_PUBLIC_LEMONSQUEEZY_PRODUCT_VARIANT_BUSINESS=
136+
137+
## Observability — Sentry (error tracking + tracing + AI agents
138+
## monitoring + Logs + Crons) + PostHog (product analytics +
139+
## session replay + LLM analytics). All optional; the integrations
140+
## are no-ops when their DSN / API key is empty, so dev environments
141+
## and CI don't have to carry the secrets.
142+
##
143+
## Backend (server-only):
144+
SENTRY_DSN=
145+
SENTRY_TRACES_SAMPLE_RATE=0.1
146+
SENTRY_PROFILES_SAMPLE_RATE=0.05
147+
SENTRY_SEND_DEFAULT_PII=false
148+
## Falls back to BackendSettings.service_version when unset.
149+
SENTRY_RELEASE=
150+
POSTHOG_API_KEY=
151+
POSTHOG_HOST=https://eu.i.posthog.com
152+
## Tag attached to every Sentry event + PostHog server-side event
153+
## so dashboards can slice production from staging / preview deploys.
154+
AIJOBAGENT_ENVIRONMENT=development
155+
156+
## Source-map upload via withSentryConfig (frontend/next.config.ts).
157+
## Generate at Sentry -> Settings -> Auth Tokens with project:releases
158+
## + project:read scopes. Vercel's Sentry integration auto-provisions
159+
## this; if you install that, leave this blank.
160+
SENTRY_AUTH_TOKEN=
161+
162+
## Frontend (Next.js inlines NEXT_PUBLIC_* into the JS bundle):
163+
NEXT_PUBLIC_SENTRY_DSN=
164+
NEXT_PUBLIC_SENTRY_ENVIRONMENT=development
165+
NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE=0.1
166+
NEXT_PUBLIC_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE=1.0
167+
NEXT_PUBLIC_POSTHOG_KEY=
168+
NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com

backend/app.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
1+
from contextlib import asynccontextmanager
2+
from typing import AsyncIterator
3+
14
from fastapi import FastAPI, Request
25
from fastapi.middleware.cors import CORSMiddleware
36
from fastapi.responses import JSONResponse
47
from slowapi.errors import RateLimitExceeded
58
from slowapi.middleware import SlowAPIMiddleware
69

710
from backend.config import get_backend_settings
11+
from backend.observability import (
12+
initialize_observability,
13+
shutdown_observability,
14+
)
815
from backend.rate_limit import limiter, rate_limit_exceeded_handler
916
from backend.routers.auth import router as auth_router
1017
from backend.routers.billing import router as billing_router
@@ -16,9 +23,35 @@
1623

1724
settings = get_backend_settings()
1825

26+
# Initialize Sentry + PostHog BEFORE ``FastAPI()`` so Sentry's ASGI
27+
# middleware wraps the app at construction time. Both are no-ops when
28+
# their respective DSN / API key env vars are unset, so the order is
29+
# safe in environments that haven't wired the integration on yet
30+
# (local dev, CI, the test suite). See ``backend/observability.py``
31+
# for the bootstrap details.
32+
initialize_observability(settings)
33+
34+
35+
@asynccontextmanager
36+
async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
37+
# ``yield`` is the boundary: startup happens above (we already
38+
# initialized observability at import time, so there's no setup
39+
# work to do here), shutdown happens below.
40+
try:
41+
yield
42+
finally:
43+
# Flush the PostHog buffer on graceful termination. PostHog also
44+
# registers its own atexit handler, but explicit drain via lifespan
45+
# ensures buffered events leave the process even when the worker
46+
# is killed by a signal that the interpreter atexit chain doesn't
47+
# reach (eg. SIGTERM on a container redeploy).
48+
shutdown_observability()
49+
50+
1951
app = FastAPI(
2052
title=settings.service_name,
2153
version=settings.service_version,
54+
lifespan=_lifespan,
2255
)
2356

2457

@@ -85,6 +118,29 @@ def root():
85118
}
86119

87120

121+
@app.get("/health/sentry-debug")
122+
def sentry_debug() -> None:
123+
"""Raise an unhandled exception so Sentry sees the issue end-to-end.
124+
125+
Used once at deploy time to confirm the DSN, environment, and
126+
release tag are wired. The route returns no JSON — Sentry's
127+
FastAPI integration catches the raise, ships the event, and
128+
FastAPI's default 500 handler returns "Internal Server Error" to
129+
the caller. There is intentionally no auth on this route: it must
130+
be callable from anywhere with curl. Remove or gate it behind a
131+
feature flag if your threat model objects.
132+
133+
Path is OUTSIDE ``settings.api_prefix`` (no /api/) so it doesn't
134+
accidentally appear in the workspace-API surface that auth /
135+
middleware paths assume. Matches HelpmateAI's convention exactly.
136+
"""
137+
# ``division by zero`` is the canonical Sentry-tutorial sample;
138+
# keeping it deliberately recognizable so anyone reading the issue
139+
# title in Sentry knows it's a smoke test, not a real bug.
140+
division_by_zero = 1 / 0 # noqa: F841 — intentional crash for Sentry verification
141+
return None
142+
143+
88144
app.include_router(health_router, prefix=settings.api_prefix)
89145
app.include_router(jobs_router, prefix=settings.api_prefix)
90146
app.include_router(jobs_admin_router, prefix=settings.api_prefix)

backend/config.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,20 @@ class BackendSettings:
2929
auth_cookie_domain: str
3030
auth_cookie_secure: bool
3131
auth_cookie_samesite: str
32+
# Observability — Sentry + PostHog. All four are optional; when the
33+
# DSN / API key is empty the observability bootstrap is a no-op (no
34+
# network, no SDK init). ``environment`` and ``release`` are used
35+
# by both vendors to slice events by deploy. ``release`` defaults
36+
# to the service_version when unset so a forgotten SENTRY_RELEASE
37+
# still groups events by something stable.
38+
sentry_dsn: str
39+
sentry_traces_sample_rate: float
40+
sentry_profiles_sample_rate: float
41+
sentry_send_default_pii: bool
42+
sentry_release: str
43+
posthog_api_key: str
44+
posthog_host: str
45+
observability_environment: str
3246

3347

3448
def _parse_bool(value: str, default: bool) -> bool:
@@ -42,6 +56,21 @@ def _parse_bool(value: str, default: bool) -> bool:
4256
return default
4357

4458

59+
def _parse_float(value: str, default: float) -> float:
60+
"""Lenient float parser for sample-rate env vars.
61+
62+
Empty / malformed values fall back to ``default`` rather than
63+
raising — the observability layer must never crash backend boot
64+
just because someone fat-fingered a sample rate."""
65+
stripped = (value or "").strip()
66+
if not stripped:
67+
return default
68+
try:
69+
return float(stripped)
70+
except (TypeError, ValueError):
71+
return default
72+
73+
4574
def get_backend_settings() -> BackendSettings:
4675
frontend_app_url = (
4776
os.getenv("FRONTEND_APP_URL", "http://localhost:3000").strip()
@@ -70,9 +99,27 @@ def get_backend_settings() -> BackendSettings:
7099
raw_samesite if raw_samesite in {"lax", "strict", "none"} else "lax"
71100
)
72101

102+
# Observability — never raise from env parsing; missing values
103+
# collapse to safe defaults so a fresh checkout boots without any
104+
# Sentry / PostHog config at all (local dev, CI). The
105+
# observability bootstrap then sees an empty DSN/key and bails.
106+
service_version = "0.2.0"
107+
sentry_dsn = (os.getenv("SENTRY_DSN") or "").strip()
108+
sentry_traces_sample_rate = _parse_float(os.getenv("SENTRY_TRACES_SAMPLE_RATE", ""), 0.1)
109+
sentry_profiles_sample_rate = _parse_float(os.getenv("SENTRY_PROFILES_SAMPLE_RATE", ""), 0.05)
110+
sentry_send_default_pii = _parse_bool(os.getenv("SENTRY_SEND_DEFAULT_PII", ""), False)
111+
sentry_release = (os.getenv("SENTRY_RELEASE") or service_version).strip() or service_version
112+
posthog_api_key = (os.getenv("POSTHOG_API_KEY") or "").strip()
113+
posthog_host = (os.getenv("POSTHOG_HOST") or "https://eu.i.posthog.com").strip()
114+
observability_environment = (
115+
os.getenv("AIJOBAGENT_ENVIRONMENT")
116+
or os.getenv("ENVIRONMENT")
117+
or "development"
118+
).strip()
119+
73120
return BackendSettings(
74121
service_name="AI Job Application Agent Backend",
75-
service_version="0.2.0",
122+
service_version=service_version,
76123
api_prefix="/api",
77124
backend_base_url=JOB_BACKEND_BASE_URL,
78125
frontend_app_url=frontend_app_url,
@@ -84,4 +131,12 @@ def get_backend_settings() -> BackendSettings:
84131
auth_cookie_domain=auth_cookie_domain,
85132
auth_cookie_secure=auth_cookie_secure,
86133
auth_cookie_samesite=auth_cookie_samesite,
134+
sentry_dsn=sentry_dsn,
135+
sentry_traces_sample_rate=sentry_traces_sample_rate,
136+
sentry_profiles_sample_rate=sentry_profiles_sample_rate,
137+
sentry_send_default_pii=sentry_send_default_pii,
138+
sentry_release=sentry_release,
139+
posthog_api_key=posthog_api_key,
140+
posthog_host=posthog_host,
141+
observability_environment=observability_environment,
87142
)

0 commit comments

Comments
 (0)