Skip to content

Commit 8bbbfe2

Browse files
LEANDERANTONYclaude
andcommitted
feat: PostHog funnel instrumentation — five server-side events
The backend emitted exactly one custom PostHog event (feedback_submitted), so the product funnel was invisible — no job searches, analyses, exports, or plan-limit friction. This adds five server-side capture_event calls at the load-bearing funnel points: - job_searched (routers/jobs.py) — every /jobs/search - resume_uploaded (routers/workspace.py) — /resume/upload - analysis_started (routers/workspace.py) — sync + async analyze - artifact_exported (routers/workspace.py) — /artifacts/export - quota_blocked (quota.py) — check_and_increment + enforce_llm_budget Events fire only after the operation succeeds, mirror the existing feedback_submitted pattern, and carry no PII (counts / modes / tiers / formats only). capture_event now maps a falsy distinct_id to "anonymous" so unauthenticated funnel volume isn't dropped. A new _event_identity helper resolves (user_id, tier) best-effort and never raises. See DEVLOG Day 77. Also removes a pre-existing unused Form import. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d61fa00 commit 8bbbfe2

8 files changed

Lines changed: 383 additions & 7 deletions

File tree

backend/observability.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,10 @@ def capture_event(
266266
267267
``distinct_id`` is the Supabase user id from the auth context on
268268
the request scope — never a session token or anything that could
269-
leak credentials.
269+
leak credentials. A falsy ``distinct_id`` (an anonymous or
270+
unresolved caller) falls back to the constant ``"anonymous"`` so
271+
the event is still counted — funnel volume must not silently drop
272+
just because a caller wasn't signed in.
270273
271274
All events automatically include ``product: "jobagent"`` so the
272275
shared PostHog project can split events by product on the
@@ -280,7 +283,7 @@ def capture_event(
280283
merged.update(properties)
281284
with suppress(Exception):
282285
_posthog_client.capture(
283-
distinct_id=distinct_id,
286+
distinct_id=distinct_id or "anonymous",
284287
event=event,
285288
properties=merged,
286289
)

backend/quota.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
from datetime import datetime, timezone
4848
from typing import Optional
4949

50+
from backend.observability import capture_event
5051
from backend.tiers import (
5152
TIER_CAPS,
5253
UNLIMITED,
@@ -512,6 +513,19 @@ def check_and_increment(
512513
delta=1,
513514
)
514515
except _QuotaExceededAtBackend as exc:
516+
# PostHog funnel event — quota friction. Captured here rather
517+
# than in the global 429 handler because this is where the
518+
# user id is in scope; covers every metered TIER_CAPS counter.
519+
capture_event(
520+
distinct_id=user_id,
521+
event="quota_blocked",
522+
properties={
523+
"counter": exc.counter_name,
524+
"tier": tier,
525+
"cap": exc.cap,
526+
"current": exc.current,
527+
},
528+
)
515529
raise _build_quota_exceeded_error(
516530
counter_name=exc.counter_name,
517531
current=exc.current,
@@ -682,6 +696,17 @@ def enforce_llm_budget(
682696
)
683697
if used < cap:
684698
return
699+
# PostHog funnel event — the weekly LLM-token budget is exhausted.
700+
capture_event(
701+
distinct_id=normalized_user_id,
702+
event="quota_blocked",
703+
properties={
704+
"counter": LLM_TOKENS_COUNTER,
705+
"tier": tier,
706+
"cap": cap,
707+
"current": used,
708+
},
709+
)
685710
raise QuotaExceededError(
686711
"You've used your weekly AI usage allowance on this plan. "
687712
"Upgrade for a higher limit, or wait for the weekly reset.",

backend/routers/jobs.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
CACHED_JOBS_HEALTHCHECK_MONITOR_SLUG,
1515
CACHED_JOBS_REFRESH_MONITOR_CONFIG,
1616
CACHED_JOBS_REFRESH_MONITOR_SLUG,
17+
capture_event,
1718
sentry_cron_monitor,
1819
)
1920
from backend.rate_limit import LIMIT_LLM, limiter
@@ -94,6 +95,20 @@ def search_jobs(
9495

9596
domain_query = payload.to_domain()
9697
result = service.search(domain_query) if live else service.search_cached(domain_query)
98+
# PostHog funnel event — the top of the job-application funnel.
99+
# Server-side capture, fire-and-forget; carries no PII (counts +
100+
# tier only). `quota_user_id` is "" for anonymous callers, which
101+
# capture_event maps to the "anonymous" distinct id.
102+
capture_event(
103+
distinct_id=quota_user_id,
104+
event="job_searched",
105+
properties={
106+
"mode": "live" if live else "cached",
107+
"result_count": len(getattr(result, "results", []) or []),
108+
"has_query": bool((payload.query or "").strip()),
109+
"tier": tier,
110+
},
111+
)
97112
return JobSearchResponseModel.from_domain(result)
98113

99114

backend/routers/workspace.py

Lines changed: 89 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
1+
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
22
from fastapi.responses import StreamingResponse
33

44
from backend.observability import capture_event
@@ -125,6 +125,35 @@ def _resolve_export_tier(access_token: str, refresh_token: str):
125125
return resolve_user_tier(app_user)
126126

127127

128+
def _event_identity(access_token: str, refresh_token: str) -> tuple[str, str]:
129+
"""Best-effort ``(user_id, tier)`` for a PostHog funnel event.
130+
131+
NEVER raises — analytics attribution must not be able to break a
132+
route. An anonymous or unresolvable caller resolves to
133+
``("", "free")``; ``capture_event`` maps the empty id to the
134+
"anonymous" distinct id, so the event is still counted.
135+
"""
136+
if not (access_token and refresh_token):
137+
return ("", "free")
138+
try:
139+
auth_context = resolve_authenticated_context(
140+
access_token=access_token,
141+
refresh_token=refresh_token,
142+
)
143+
except Exception: # noqa: BLE001 — analytics identity is best-effort
144+
return ("", "free")
145+
app_user = getattr(auth_context, "app_user", None) if auth_context else None
146+
if app_user is None:
147+
return ("", "free")
148+
return (str(getattr(app_user, "id", "") or ""), resolve_user_tier(app_user))
149+
150+
151+
def _file_extension(filename: str | None) -> str:
152+
"""Lower-cased file extension without the dot, or "" when absent."""
153+
name = str(filename or "")
154+
return name.rsplit(".", 1)[-1].lower() if "." in name else ""
155+
156+
128157
def _resolve_openai_service(access_token: str, refresh_token: str):
129158
"""Best-effort OpenAIService for an authenticated request.
130159
@@ -243,13 +272,24 @@ def upload_resume(
243272
"""
244273
access_token, refresh_token = auth_tokens
245274
try:
246-
return parse_resume_upload(
275+
result = parse_resume_upload(
247276
filename=payload.filename,
248277
mime_type=payload.mime_type,
249278
content_base64=payload.content_base64,
250279
access_token=access_token or "",
251280
refresh_token=refresh_token or "",
252281
)
282+
# PostHog funnel event — resume intake (upload path).
283+
user_id, tier = _event_identity(access_token or "", refresh_token or "")
284+
capture_event(
285+
distinct_id=user_id,
286+
event="resume_uploaded",
287+
properties={
288+
"tier": tier,
289+
"file_type": _file_extension(payload.filename),
290+
},
291+
)
292+
return result
253293
except AppError as error:
254294
_raise_http_error(error)
255295

@@ -673,7 +713,7 @@ def analyze_workspace(
673713
):
674714
access_token, refresh_token = auth_tokens
675715
try:
676-
return run_workspace_analysis(
716+
result = run_workspace_analysis(
677717
resume_text=payload.resume_text,
678718
resume_filetype=payload.resume_filetype,
679719
resume_source=payload.resume_source,
@@ -684,6 +724,21 @@ def analyze_workspace(
684724
access_token=access_token or "",
685725
refresh_token=refresh_token or "",
686726
)
727+
# PostHog funnel event — the core product action (supervised
728+
# pipeline). `mode` distinguishes the synchronous route from
729+
# the async job route below.
730+
user_id, tier = _event_identity(access_token or "", refresh_token or "")
731+
capture_event(
732+
distinct_id=user_id,
733+
event="analysis_started",
734+
properties={
735+
"mode": "sync",
736+
"tier": tier,
737+
"premium": bool(payload.premium),
738+
"run_assisted": bool(payload.run_assisted),
739+
},
740+
)
741+
return result
687742
except AppError as error:
688743
_raise_http_error(error)
689744

@@ -700,7 +755,7 @@ def start_workspace_analysis_job_route(
700755
):
701756
access_token, refresh_token = auth_tokens
702757
try:
703-
return start_workspace_analysis_job(
758+
result = start_workspace_analysis_job(
704759
resume_text=payload.resume_text,
705760
resume_filetype=payload.resume_filetype,
706761
resume_source=payload.resume_source,
@@ -710,6 +765,19 @@ def start_workspace_analysis_job_route(
710765
access_token=access_token or "",
711766
refresh_token=refresh_token or "",
712767
)
768+
# PostHog funnel event — async variant of the supervised
769+
# pipeline. Emitted once the job is accepted onto the queue.
770+
user_id, tier = _event_identity(access_token or "", refresh_token or "")
771+
capture_event(
772+
distinct_id=user_id,
773+
event="analysis_started",
774+
properties={
775+
"mode": "async",
776+
"tier": tier,
777+
"premium": bool(payload.premium),
778+
},
779+
)
780+
return result
713781
except WorkspaceRunJobCapacityError:
714782
raise HTTPException(
715783
status_code=503,
@@ -1107,13 +1175,29 @@ def export_workspace_artifact_route(
11071175
themes=(payload.resume_theme, payload.cover_letter_theme),
11081176
)
11091177
try:
1110-
return export_workspace_artifact(
1178+
result = export_workspace_artifact(
11111179
workspace_snapshot=payload.workspace_snapshot,
11121180
artifact_kind=payload.artifact_kind,
11131181
export_format=payload.export_format,
11141182
resume_theme=payload.resume_theme,
11151183
cover_letter_theme=payload.cover_letter_theme,
11161184
)
1185+
# PostHog funnel event — the conversion point: the user took an
1186+
# artifact away. Theme + format properties show which export
1187+
# options actually get used.
1188+
user_id, tier = _event_identity(access_token or "", refresh_token or "")
1189+
capture_event(
1190+
distinct_id=user_id,
1191+
event="artifact_exported",
1192+
properties={
1193+
"artifact_kind": payload.artifact_kind,
1194+
"export_format": payload.export_format,
1195+
"resume_theme": payload.resume_theme,
1196+
"cover_letter_theme": payload.cover_letter_theme,
1197+
"tier": tier,
1198+
},
1199+
)
1200+
return result
11171201
except AppError as error:
11181202
_raise_http_error(error)
11191203

docs/DEVLOG.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3908,3 +3908,52 @@ checks, both hard-failure paths, and the degraded → ERROR-log alerting
39083908
contract; `health_stats` store tests and `/admin/refresh-healthcheck`
39093909
endpoint tests round it out. `cached_jobs_health_stats` is applied to
39103910
the live database.
3911+
3912+
## Day 77: PostHog funnel instrumentation — five server-side events
3913+
3914+
A PostHog audit (the "Job Agent — Product Health" dashboard built
3915+
alongside this work) found the product almost entirely
3916+
un-instrumented: the backend emitted exactly one custom event,
3917+
`feedback_submitted`. PostHog could see feedback and browser
3918+
autocapture and nothing else — no job searches, no analyses, no
3919+
exports, no plan-limit friction. You cannot visualise a funnel you
3920+
never recorded.
3921+
3922+
This slice records it. Five server-side `capture_event` calls at the
3923+
load-bearing points of the job-application funnel:
3924+
3925+
- **`job_searched`** (`routers/jobs.py`) — every `/jobs/search`. Props:
3926+
cached-vs-live mode, result count, has-query, tier.
3927+
- **`resume_uploaded`** (`routers/workspace.py`) — `/resume/upload`.
3928+
Props: file type, tier.
3929+
- **`analysis_started`** (`routers/workspace.py`) — both the sync
3930+
`/analyze` and the async `/analyze-jobs` route, distinguished by a
3931+
`mode` property. The core product action.
3932+
- **`artifact_exported`** (`routers/workspace.py`) —
3933+
`/artifacts/export`. Props: artifact kind, format, themes, tier —
3934+
the conversion point.
3935+
- **`quota_blocked`** (`quota.py`) — emitted at the two
3936+
`QuotaExceededError` raise sites that carry a user id
3937+
(`check_and_increment`, covering all metered counters, and
3938+
`enforce_llm_budget`). The friction signal: which plan limit are
3939+
users hitting.
3940+
3941+
Design notes. Every event is emitted from the router (or, for
3942+
`quota_blocked`, the quota module) — never the `src/` core, which must
3943+
not depend on `backend/`. Each fires only AFTER the operation
3944+
succeeds, mirroring the existing `feedback_submitted` precedent;
3945+
`capture_event` is fire-and-forget and swallows every exception, so an
3946+
analytics failure can never break a route. A new `_event_identity`
3947+
helper resolves `(user_id, tier)` best-effort and never raises.
3948+
`capture_event` now maps a falsy `distinct_id` to the constant
3949+
`"anonymous"` so an unauthenticated job search still counts toward
3950+
funnel volume instead of silently dropping. No PII in any property —
3951+
counts, modes, tiers, formats, themes only.
3952+
3953+
Verification: 877 tests green (full suite minus the network-bound
3954+
`nightly_eval` + quality runners; the one `.env`-only retention
3955+
failure is unrelated and green on CI). Six new tests — `quota_blocked`
3956+
on both raise paths, `job_searched`, `resume_uploaded`,
3957+
`analysis_started`, `artifact_exported` — added to the existing quota,
3958+
app, and workspace suites. Also removed a pre-existing unused `Form`
3959+
import flagged by ruff in `routers/workspace.py`.

tests/backend/test_quota.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,3 +636,48 @@ def test_token_meter_isolates_users_and_weeks():
636636

637637
def test_read_llm_token_usage_blank_user_is_zero():
638638
assert read_llm_token_usage("", "free") == 0
639+
640+
641+
# ─── PostHog quota_blocked funnel event ─────────────────────────────────
642+
643+
644+
def test_quota_blocked_event_captured_on_cap_breach(monkeypatch):
645+
"""A cap-breaching check_and_increment emits a `quota_blocked`
646+
PostHog event (counter + tier + cap) before the 429 is raised."""
647+
events = []
648+
monkeypatch.setattr(
649+
quota, "capture_event", lambda **kwargs: events.append(kwargs)
650+
)
651+
for _ in range(3):
652+
check_and_increment("tailored_applications", "user-q", "free")
653+
with pytest.raises(QuotaExceededError):
654+
check_and_increment("tailored_applications", "user-q", "free")
655+
656+
# Only the rejected 4th call emits — the 3 successes do not.
657+
assert len(events) == 1
658+
event = events[0]
659+
assert event["event"] == "quota_blocked"
660+
assert event["distinct_id"] == "user-q"
661+
assert event["properties"]["counter"] == "tailored_applications"
662+
assert event["properties"]["tier"] == "free"
663+
assert event["properties"]["cap"] == 3
664+
assert event["properties"]["current"] == 3
665+
666+
667+
def test_quota_blocked_event_captured_on_llm_budget_rejection(monkeypatch):
668+
"""An exhausted weekly LLM-token meter emits `quota_blocked` with
669+
the llm_tokens counter."""
670+
monkeypatch.setitem(quota.TIER_CAPS["free"], LLM_TOKENS_COUNTER, 100)
671+
events = []
672+
monkeypatch.setattr(
673+
quota, "capture_event", lambda **kwargs: events.append(kwargs)
674+
)
675+
record_llm_token_usage("user-llm", 100)
676+
with pytest.raises(QuotaExceededError):
677+
enforce_llm_budget("user-llm", "free")
678+
679+
assert len(events) == 1
680+
assert events[0]["event"] == "quota_blocked"
681+
assert events[0]["distinct_id"] == "user-llm"
682+
assert events[0]["properties"]["counter"] == LLM_TOKENS_COUNTER
683+
assert events[0]["properties"]["tier"] == "free"

0 commit comments

Comments
 (0)