1- from fastapi import APIRouter , Depends , File , Form , HTTPException , Request , UploadFile
1+ from fastapi import APIRouter , Depends , File , HTTPException , Request , UploadFile
22from fastapi .responses import StreamingResponse
33
44from 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+
128157def _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
0 commit comments