From f98f2b2b9245f5b02b44be50da5888892e28a365 Mon Sep 17 00:00:00 2001 From: maxjneto Date: Sun, 31 May 2026 12:17:14 -0300 Subject: [PATCH 1/6] feat(observability): add PostHog AI observability, cookie consent, and analytics foundation Backend: wire PostHog posthog-python client; instrument every phase function (discovery, alignment, generation, refinement, audit, completion) with $ai_trace / $ai_span events via a new services/observability.py helper. Guardrails also capture span events on violations. Frontend: initialise posthog-js (opt-out by default), add CookieConsentBanner that drives opt-in/opt-out, wire Clerk user identity to PostHog, add initial product events (phase_entered, document_created) and session-recording mask selectors for editor content. Co-Authored-By: Claude Sonnet 4.6 --- packages/backend/.env.example | 7 ++ packages/backend/app/ai/core/__init__.py | 3 +- packages/backend/app/ai/core/client.py | 10 ++- packages/backend/app/config.py | 6 ++ packages/backend/app/guardrails/input.py | 25 ++++++-- packages/backend/app/guardrails/output.py | 6 ++ packages/backend/app/phases/alignment/ai.py | 7 ++ .../backend/app/phases/alignment/contract.py | 7 ++ .../backend/app/phases/alignment/function.py | 19 +++++- packages/backend/app/phases/audit/ai.py | 7 +- packages/backend/app/phases/audit/function.py | 19 +++++- .../backend/app/phases/completion/function.py | 7 ++ packages/backend/app/phases/discovery/ai.py | 7 ++ .../backend/app/phases/discovery/function.py | 29 ++++++++- packages/backend/app/phases/generation/ai.py | 14 +++- .../backend/app/phases/generation/function.py | 27 +++++++- .../backend/app/phases/generation/helpers.py | 13 +++- packages/backend/app/phases/refinement/ai.py | 7 ++ .../backend/app/phases/refinement/function.py | 29 ++++++++- .../backend/app/phases/refinement/helpers.py | 32 ++++++++-- packages/backend/app/routers/documents.py | 5 +- .../backend/app/services/observability.py | 48 ++++++++++++++ packages/backend/pyproject.toml | 1 + packages/frontend/.env.example | 4 ++ packages/frontend/package.json | 1 + packages/frontend/src/App.tsx | 64 ++++++++++++++----- .../phases/discovery/InitialInput.tsx | 2 + .../phases/editor/EditorCenterPanel.tsx | 2 +- .../components/shared/CookieConsentBanner.tsx | 53 +++++++++++++++ .../components/shared/MarkdownRenderer.tsx | 2 +- .../components/shared/NewDocumentDialog.tsx | 3 + .../src/hooks/useClerkPosthogIdentity.ts | 17 +++++ packages/frontend/src/pages/DocumentPage.tsx | 8 ++- 33 files changed, 441 insertions(+), 50 deletions(-) create mode 100644 packages/backend/app/services/observability.py create mode 100644 packages/frontend/src/components/shared/CookieConsentBanner.tsx create mode 100644 packages/frontend/src/hooks/useClerkPosthogIdentity.ts diff --git a/packages/backend/.env.example b/packages/backend/.env.example index d76c12f..e81251a 100644 --- a/packages/backend/.env.example +++ b/packages/backend/.env.example @@ -9,3 +9,10 @@ INNGEST_SIGNING_KEY= CLERK_JWKS_URL=https://.clerk.accounts.dev/.well-known/jwks.json # Comma-separated list of allowed frontend origins (matches the `azp` JWT claim) CLERK_AUTHORIZED_PARTIES=["http://localhost:5173"] + +# PostHog analytics (optional — leave POSTHOG_ENABLED=false to disable entirely) +POSTHOG_API_KEY= +POSTHOG_HOST=https://us.i.posthog.com +POSTHOG_ENABLED=false + +POSTHOG_REDACT_PROMPTS=true diff --git a/packages/backend/app/ai/core/__init__.py b/packages/backend/app/ai/core/__init__.py index 5efebf8..1c3abd7 100644 --- a/packages/backend/app/ai/core/__init__.py +++ b/packages/backend/app/ai/core/__init__.py @@ -1,4 +1,4 @@ -from app.ai.core.client import DEFAULT_MODEL, GUARDRAIL_MODEL, client +from app.ai.core.client import DEFAULT_MODEL, GUARDRAIL_MODEL, client, posthog_client from app.ai.core.output_cleaner import ( clean_section_output, promote_h1_headings, @@ -18,6 +18,7 @@ __all__ = [ "client", + "posthog_client", "DEFAULT_MODEL", "GUARDRAIL_MODEL", "truncate_section", diff --git a/packages/backend/app/ai/core/client.py b/packages/backend/app/ai/core/client.py index 839c406..9b77eb4 100644 --- a/packages/backend/app/ai/core/client.py +++ b/packages/backend/app/ai/core/client.py @@ -1,10 +1,16 @@ -from openai import AsyncOpenAI +from posthog import Posthog +from posthog.ai.openai import AsyncOpenAI from app.config import settings +posthog_client: Posthog | None = None +if settings.POSTHOG_ENABLED and settings.POSTHOG_API_KEY: + posthog_client = Posthog(settings.POSTHOG_API_KEY, host=settings.POSTHOG_HOST) + client = AsyncOpenAI( base_url=settings.AZURE_OPENAI_ENDPOINT, - api_key=settings.AZURE_OPENAI_API_KEY + api_key=settings.AZURE_OPENAI_API_KEY, + posthog_client=posthog_client, ) DEFAULT_MODEL = settings.AZURE_OPENAI_DEPLOYMENT GUARDRAIL_MODEL = settings.AZURE_OPENAI_GUARDRAIL_DEPLOYMENT diff --git a/packages/backend/app/config.py b/packages/backend/app/config.py index ccfb216..d184318 100644 --- a/packages/backend/app/config.py +++ b/packages/backend/app/config.py @@ -30,6 +30,12 @@ class Settings(BaseSettings): GUIDED_DOCUMENT_COST: int = 3 EDITOR_DOCUMENT_COST: int = 1 + # PostHog analytics — all disabled by default; set POSTHOG_ENABLED=true to activate + POSTHOG_API_KEY: str = "" + POSTHOG_HOST: str = "https://us.i.posthog.com" + POSTHOG_ENABLED: bool = False + POSTHOG_REDACT_PROMPTS: bool = True + model_config = {"env_file": ENV_FILE, "extra": "ignore"} diff --git a/packages/backend/app/guardrails/input.py b/packages/backend/app/guardrails/input.py index 94540a9..9d0c961 100644 --- a/packages/backend/app/guardrails/input.py +++ b/packages/backend/app/guardrails/input.py @@ -103,7 +103,13 @@ def validate_refinement_message(message: str) -> ValidationError | None: } -async def validate_message_intent(message: str, section_type: str, section_summary: str | None = None) -> ValidationError | None: +async def validate_message_intent( + message: str, + section_type: str, + section_summary: str | None = None, + posthog_distinct_id: str | None = None, + posthog_properties: dict | None = None, +) -> ValidationError | None: """LLM-based intent classifier: rejects messages unrelated to document editing. Failures are swallowed — a guardrail error must never block the user. @@ -115,18 +121,23 @@ async def validate_message_intent(message: str, section_type: str, section_summa system_prompt = load_yaml_prompt("guardrails", "intent_classification", "system") try: - response = await client.chat.completions.create( - model=GUARDRAIL_MODEL, - messages=[ + create_kwargs: dict = { + "model": GUARDRAIL_MODEL, + "messages": [ {"role": "system", "content": system_prompt}, { "role": "user", "content": f"Section: {section_type}\nSummary: {section_summary}\nMessage: {message}", }, ], - response_format=_INTENT_SCHEMA, - temperature=0.0, - ) + "response_format": _INTENT_SCHEMA, + "temperature": 1.0, + } + if posthog_distinct_id is not None: + create_kwargs["posthog_distinct_id"] = posthog_distinct_id + if posthog_properties is not None: + create_kwargs["posthog_properties"] = posthog_properties + response = await client.chat.completions.create(**create_kwargs) result = json.loads(response.choices[0].message.content) if not result.get("is_valid", True): logger.warning( diff --git a/packages/backend/app/guardrails/output.py b/packages/backend/app/guardrails/output.py index f2919da..0612cf3 100644 --- a/packages/backend/app/guardrails/output.py +++ b/packages/backend/app/guardrails/output.py @@ -17,6 +17,8 @@ async def call_with_retry( temperature: float = 0.3, required_fields: list[str] | None = None, section_type: str | None = None, + posthog_distinct_id: str | None = None, + posthog_properties: dict | None = None, ) -> Any: """ Call the AI model and validate the structured response. @@ -37,6 +39,10 @@ async def call_with_retry( kwargs["tools"] = tools if tool_choice is not None: kwargs["tool_choice"] = tool_choice + if posthog_distinct_id is not None: + kwargs["posthog_distinct_id"] = posthog_distinct_id + if posthog_properties is not None: + kwargs["posthog_properties"] = posthog_properties response = await client.chat.completions.create(**kwargs) error = _validate_response(response, required_fields) diff --git a/packages/backend/app/phases/alignment/ai.py b/packages/backend/app/phases/alignment/ai.py index 1df76d1..dba2a07 100644 --- a/packages/backend/app/phases/alignment/ai.py +++ b/packages/backend/app/phases/alignment/ai.py @@ -60,6 +60,8 @@ async def generate_alignment( rejected_sections: list[dict] | None = None, db: AsyncSession | None = None, document_type_id: uuid.UUID | None = None, + posthog_distinct_id: str | None = None, + doc_id: str | None = None, ) -> dict: """Generate 1-2 sentence summaries for each section using per-section discovery contexts.""" logger.info( @@ -85,6 +87,11 @@ async def generate_alignment( response_format=ALIGNMENT_SCHEMA, temperature=0.3, required_fields=["summaries"], + posthog_distinct_id=posthog_distinct_id, + posthog_properties=( + {"$ai_trace_id": doc_id, "$ai_parent_id": f"alignment-{doc_id}"} + if doc_id else None + ), ) log_usage("alignment", response.usage) diff --git a/packages/backend/app/phases/alignment/contract.py b/packages/backend/app/phases/alignment/contract.py index 8f2a953..e4a01ae 100644 --- a/packages/backend/app/phases/alignment/contract.py +++ b/packages/backend/app/phases/alignment/contract.py @@ -54,6 +54,8 @@ async def extract_document_contract( user_preferences: str | None = None, db: AsyncSession | None = None, document_type_id: uuid.UUID | None = None, + posthog_distinct_id: str | None = None, + doc_id: str | None = None, ) -> dict: """Extract a structured document contract from the approved alignment summaries.""" logger.info("[AI:contract] extracting document contract") @@ -83,6 +85,11 @@ async def extract_document_contract( response_format=CONTRACT_SCHEMA, temperature=0.1, required_fields=["entities", "decisions", "terminology", "constraints"], + posthog_distinct_id=posthog_distinct_id, + posthog_properties=( + {"$ai_trace_id": doc_id, "$ai_parent_id": f"alignment-{doc_id}"} + if doc_id else None + ), ) log_usage("contract", response.usage) diff --git a/packages/backend/app/phases/alignment/function.py b/packages/backend/app/phases/alignment/function.py index 16ded85..55772cf 100644 --- a/packages/backend/app/phases/alignment/function.py +++ b/packages/backend/app/phases/alignment/function.py @@ -9,6 +9,7 @@ from app.phases._shared.concurrency import DOC_CONCURRENCY from app.phases._shared.failure import workflow_on_failure from app.services import db as db_service +from app.services.observability import capture_span @inngest_client.create_function( @@ -21,6 +22,7 @@ async def function(ctx: inngest.Context): step = ctx.step doc_id = ctx.event.data["document_id"] document_type_id = ctx.event.data.get("document_type_id") + user_id = ctx.event.data.get("user_id", "") logger.info("[orchestrator] ALIGNMENT start | doc_id={}", doc_id) @@ -66,6 +68,8 @@ async def _generate_summaries(sc=section_contexts): None, db=db, document_type_id=uuid.UUID(document_type_id) if document_type_id else None, + posthog_distinct_id=user_id, + doc_id=doc_id, ) summaries = await step.run(f"generate-summaries-{iteration}", _generate_summaries) @@ -105,6 +109,8 @@ async def _extract_contract(s=summaries, sc=section_contexts): user_preferences=doc.user_preferences, db=db, document_type_id=uuid.UUID(document_type_id) if document_type_id else None, + posthog_distinct_id=user_id, + doc_id=doc_id, ) import json as _json raw = _json.dumps(contract) @@ -119,11 +125,22 @@ async def _extract_contract(s=summaries, sc=section_contexts): doc_id, ) + async def _capture_phase_span(): + capture_span( + user_id, doc_id, + span_id=f"alignment-{doc_id}", + span_name="alignment", + input_state="Section contexts reviewed", + output_state="Alignment approved", + ) + + await step.run("capture-span-alignment", _capture_phase_span) + logger.info("[orchestrator] emitting alignment.completed | doc_id={}", doc_id) await step.send_event( "emit-alignment-completed", inngest.Event( name="docforge/alignment.completed", - data={"document_id": doc_id, "document_type_id": document_type_id}, + data={"document_id": doc_id, "document_type_id": document_type_id, "user_id": user_id}, ), ) diff --git a/packages/backend/app/phases/audit/ai.py b/packages/backend/app/phases/audit/ai.py index c71e727..bdac65b 100644 --- a/packages/backend/app/phases/audit/ai.py +++ b/packages/backend/app/phases/audit/ai.py @@ -88,7 +88,7 @@ def build_audit_context( return "\n\n".join(parts) -async def run_audit(doc_id: str, document_type_id: str | None = None) -> dict: +async def run_audit(doc_id: str, document_type_id: str | None = None, posthog_distinct_id: str | None = None) -> dict: """Run audit on all 4 finalized sections.""" logger.info("[AI:audit] run_audit | doc_id={}", doc_id) from app.services import db as db_service @@ -129,6 +129,11 @@ async def run_audit(doc_id: str, document_type_id: str | None = None) -> dict: response_format=AUDIT_SCHEMA, temperature=0.2, required_fields=["has_problems", "problems"], + posthog_distinct_id=posthog_distinct_id, + posthog_properties=( + {"$ai_trace_id": doc_id, "$ai_parent_id": f"audit-{doc_id}"} + if posthog_distinct_id else None + ), ) log_usage("audit", response.usage) diff --git a/packages/backend/app/phases/audit/function.py b/packages/backend/app/phases/audit/function.py index 4e1022d..798a017 100644 --- a/packages/backend/app/phases/audit/function.py +++ b/packages/backend/app/phases/audit/function.py @@ -8,6 +8,7 @@ from app.phases._shared.concurrency import DOC_CONCURRENCY from app.phases._shared.failure import workflow_on_failure from app.services import db as db_service +from app.services.observability import capture_span @inngest_client.create_function( @@ -20,6 +21,7 @@ async def function(ctx: inngest.Context): step = ctx.step doc_id = ctx.event.data["document_id"] document_type_id = ctx.event.data.get("document_type_id") + user_id = ctx.event.data.get("user_id", "") logger.info("[orchestrator] AUDIT start | doc_id={}", doc_id) @@ -31,7 +33,7 @@ async def _set_audit(): async def _run_audit(): from app.phases.audit.ai import run_audit - return await run_audit(doc_id, document_type_id=document_type_id) + return await run_audit(doc_id, document_type_id=document_type_id, posthog_distinct_id=user_id) result = await step.run("run-audit", _run_audit) @@ -55,10 +57,23 @@ async def _save_findings(r=result): await step.run("save-findings", _save_findings) + async def _capture_phase_span(r=result): + problems = r.get("problems", []) + output = f"Audit complete. {len(problems)} issue(s) found." if problems else "Audit complete. No issues found." + capture_span( + user_id, doc_id, + span_id=f"audit-{doc_id}", + span_name="audit", + input_state="Document sections reviewed", + output_state=output, + ) + + await step.run("capture-span-audit", _capture_phase_span) + await step.send_event( "emit-audit-completed", inngest.Event( name="docforge/document.audit_completed", - data={"document_id": doc_id, "document_type_id": document_type_id}, + data={"document_id": doc_id, "document_type_id": document_type_id, "user_id": user_id}, ), ) diff --git a/packages/backend/app/phases/completion/function.py b/packages/backend/app/phases/completion/function.py index 7445401..48ae8df 100644 --- a/packages/backend/app/phases/completion/function.py +++ b/packages/backend/app/phases/completion/function.py @@ -6,6 +6,7 @@ from app.phases._shared.concurrency import DOC_CONCURRENCY from app.phases._shared.failure import workflow_on_failure from app.services import db as db_service +from app.services.observability import capture_trace @inngest_client.create_function( @@ -17,6 +18,7 @@ async def function(ctx: inngest.Context): step = ctx.step doc_id = ctx.event.data["document_id"] + user_id = ctx.event.data.get("user_id", "") logger.info("[orchestrator] COMPLETED | doc_id={}", doc_id) @@ -25,3 +27,8 @@ async def _set_completed(): await db_service.set_phase(db, doc_id, "completed") await step.run("set-phase-completed", _set_completed) + + async def _close_trace(): + capture_trace(user_id, doc_id, output_state="Document complete") + + await step.run("capture-trace-complete", _close_trace) diff --git a/packages/backend/app/phases/discovery/ai.py b/packages/backend/app/phases/discovery/ai.py index 2977b13..c8f5f9f 100644 --- a/packages/backend/app/phases/discovery/ai.py +++ b/packages/backend/app/phases/discovery/ai.py @@ -59,6 +59,8 @@ async def analyze_discovery( section_role: str, db: AsyncSession, document_type_id: uuid.UUID | None, + posthog_distinct_id: str | None = None, + doc_id: str | None = None, ) -> dict: """Analyze if context is sufficient for a specific section, or generate follow-up questions.""" logger.info( @@ -80,6 +82,11 @@ async def analyze_discovery( response_format=DISCOVERY_SCHEMA, temperature=0.3, required_fields=["is_sufficient", "follow_up_questions"], + posthog_distinct_id=posthog_distinct_id, + posthog_properties=( + {"$ai_trace_id": doc_id, "$ai_parent_id": f"discovery-{section_key}-{doc_id}"} + if doc_id else None + ), ) log_usage("discovery", response.usage) diff --git a/packages/backend/app/phases/discovery/function.py b/packages/backend/app/phases/discovery/function.py index 08aa8ab..77fbf7e 100644 --- a/packages/backend/app/phases/discovery/function.py +++ b/packages/backend/app/phases/discovery/function.py @@ -12,6 +12,7 @@ from app.phases._shared.concurrency import DOC_CONCURRENCY from app.phases._shared.failure import workflow_on_failure from app.services import db as db_service +from app.services.observability import capture_span @inngest_client.create_function( @@ -26,6 +27,7 @@ async def function(ctx: inngest.Context): document_context = ctx.event.data["document_context"] user_preferences = ctx.event.data["user_preferences"] document_type_id = ctx.event.data.get("document_type_id") + user_id = ctx.event.data.get("user_id", "") logger.info("[orchestrator] DISCOVERY start | doc_id={}", doc_id) @@ -104,6 +106,8 @@ async def _analyze(): section_role=sr, db=db, document_type_id=uuid.UUID(document_type_id) if document_type_id else None, + posthog_distinct_id=user_id, + doc_id=doc_id, ) return _analyze @@ -182,11 +186,34 @@ async def _save_context(sid=section_id_str, ctx_text=context_to_save): await step.run(f"save-context-{section_key}", _save_context) + async def _capture_section_span(sk=section_key, ctx_text=context_to_save): + capture_span( + user_id, doc_id, + span_id=f"discovery-{sk}-{doc_id}", + span_name=sk, + parent_id=f"discovery-{doc_id}", + input_state=document_context, + output_state=ctx_text[:1000] if ctx_text else "", + ) + + await step.run(f"capture-span-discovery-{section_key}", _capture_section_span) + + async def _capture_phase_span(): + capture_span( + user_id, doc_id, + span_id=f"discovery-{doc_id}", + span_name="discovery", + input_state=document_context, + output_state="Discovery complete", + ) + + await step.run("capture-span-discovery", _capture_phase_span) + logger.info("[orchestrator] emitting discovery.completed | doc_id={}", doc_id) await step.send_event( "emit-discovery-completed", inngest.Event( name="docforge/discovery.completed", - data={"document_id": doc_id, "document_type_id": document_type_id}, + data={"document_id": doc_id, "document_type_id": document_type_id, "user_id": user_id}, ), ) diff --git a/packages/backend/app/phases/generation/ai.py b/packages/backend/app/phases/generation/ai.py index 5df243d..71c4a4a 100644 --- a/packages/backend/app/phases/generation/ai.py +++ b/packages/backend/app/phases/generation/ai.py @@ -93,6 +93,8 @@ async def generate_section( document_contract: dict | None = None, db: AsyncSession | None = None, document_type_id: uuid.UUID | None = None, + posthog_distinct_id: str | None = None, + doc_id: str | None = None, ) -> str: """Generate full markdown content for a section.""" logger.info("[AI:generation] generating section | type={}", section_type) @@ -109,6 +111,11 @@ async def generate_section( {"role": "user", "content": user_content}, ], temperature=0.5, + posthog_distinct_id=posthog_distinct_id, + posthog_properties=( + {"$ai_trace_id": doc_id, "$ai_parent_id": f"generation-{section_type}-{doc_id}"} + if doc_id else None + ), ) log_usage("generation", response.usage, section_type=section_type) @@ -121,7 +128,7 @@ async def generate_section( return content -async def refine_cross_references(doc_id: str, document_type_id: str | None = None) -> None: +async def refine_cross_references(doc_id: str, document_type_id: str | None = None, posthog_distinct_id: str | None = None) -> None: """Run a coherence pass on all sections to ensure cross-references are accurate.""" from app.database import async_session from app.services import db as db_service @@ -175,6 +182,11 @@ async def refine_cross_references(doc_id: str, document_type_id: str | None = No section_type=section_type, messages=messages, temperature=0.2, + posthog_distinct_id=posthog_distinct_id, + posthog_properties=( + {"$ai_trace_id": doc_id, "$ai_parent_id": f"generation-{section_type}-{doc_id}"} + if posthog_distinct_id else None + ), ) log_usage("generation:coherence", response.usage, section_type=section_type) diff --git a/packages/backend/app/phases/generation/function.py b/packages/backend/app/phases/generation/function.py index 7405f82..69c81d4 100644 --- a/packages/backend/app/phases/generation/function.py +++ b/packages/backend/app/phases/generation/function.py @@ -9,6 +9,7 @@ from app.phases._shared.failure import workflow_on_failure from app.phases.generation.helpers import generate_section_root from app.services import db as db_service +from app.services.observability import capture_span @inngest_client.create_function( @@ -21,6 +22,7 @@ async def function(ctx: inngest.Context): step = ctx.step doc_id = ctx.event.data["document_id"] document_type_id = ctx.event.data.get("document_type_id") + user_id = ctx.event.data.get("user_id", "") logger.info("[orchestrator] GENERATION start | doc_id={}", doc_id) @@ -50,20 +52,39 @@ async def _get_section_order(): for section_key in section_order: async def _generate(sk=section_key): logger.info("[orchestrator] generating section {} | doc_id={}", sk, doc_id) - await generate_section_root(doc_id, sk) + content = await generate_section_root(doc_id, sk, posthog_distinct_id=user_id) + capture_span( + user_id, doc_id, + span_id=f"generation-{sk}-{doc_id}", + span_name=sk, + parent_id=f"generation-{doc_id}", + input_state=f"Generate section: {sk}", + output_state=content[:1000] if content else "", + ) await step.run(f"generate-{section_key}", _generate) async def _coherence_pass(): from app.phases.generation.ai import refine_cross_references - await refine_cross_references(doc_id, document_type_id=document_type_id) + await refine_cross_references(doc_id, document_type_id=document_type_id, posthog_distinct_id=user_id) await step.run("coherence-pass", _coherence_pass) + async def _capture_phase_span(): + capture_span( + user_id, doc_id, + span_id=f"generation-{doc_id}", + span_name="generation", + input_state="Sections generated", + output_state="Generation complete", + ) + + await step.run("capture-span-generation", _capture_phase_span) + logger.info("[orchestrator] emitting generation.completed | doc_id={}", doc_id) await step.send_event( "emit-generation-completed", inngest.Event( name="docforge/generation.completed", - data={"document_id": doc_id, "document_type_id": document_type_id}, + data={"document_id": doc_id, "document_type_id": document_type_id, "user_id": user_id}, ), ) diff --git a/packages/backend/app/phases/generation/helpers.py b/packages/backend/app/phases/generation/helpers.py index 0584225..6a1afc2 100644 --- a/packages/backend/app/phases/generation/helpers.py +++ b/packages/backend/app/phases/generation/helpers.py @@ -45,8 +45,12 @@ async def _build_cross_section_context_from_db(db, doc_id: uuid.UUID, target_typ return _build_cross_section_context(sections, target_type) -async def generate_section_root(doc_id: str, section_type: str) -> None: - """Generate the initial version of a section using AI.""" +async def generate_section_root( + doc_id: str, + section_type: str, + posthog_distinct_id: str | None = None, +) -> str: + """Generate the initial version of a section using AI. Returns the generated content.""" from app.phases.generation.ai import generate_section logger.info("[helpers] generate_section_root | doc_id={} type={}", doc_id, section_type) @@ -54,7 +58,7 @@ async def generate_section_root(doc_id: str, section_type: str) -> None: doc = await db_service.get_document_detail(db, uuid.UUID(doc_id)) section = next((s for s in doc.sections if s.section_type == section_type), None) if not section: - return + return "" cross_section_context = _build_cross_section_context(doc.sections, section_type) @@ -77,6 +81,8 @@ async def generate_section_root(doc_id: str, section_type: str) -> None: document_contract=contract_dict, db=db, document_type_id=doc.document_type_id, + posthog_distinct_id=posthog_distinct_id, + doc_id=doc_id, ) content = clean_section_output(content, section_type) @@ -92,3 +98,4 @@ async def generate_section_root(doc_id: str, section_type: str) -> None: section_type, len(content or ""), ) + return content or "" diff --git a/packages/backend/app/phases/refinement/ai.py b/packages/backend/app/phases/refinement/ai.py index eec6288..063eacc 100644 --- a/packages/backend/app/phases/refinement/ai.py +++ b/packages/backend/app/phases/refinement/ai.py @@ -154,6 +154,8 @@ async def refine_section( document_contract: dict | None = None, db: AsyncSession | None = None, document_type_id: uuid.UUID | None = None, + posthog_distinct_id: str | None = None, + doc_id: str | None = None, ) -> dict: """Process a refinement interaction using function calling.""" logger.info( @@ -184,6 +186,11 @@ async def refine_section( tools=REFINEMENT_TOOLS, tool_choice=tool_choice, temperature=0.4, + posthog_distinct_id=posthog_distinct_id, + posthog_properties=( + {"$ai_trace_id": doc_id, "$ai_parent_id": f"refinement-{section_type}-{doc_id}"} + if doc_id else None + ), ) log_usage("refinement", response.usage, section_type=section_type) diff --git a/packages/backend/app/phases/refinement/function.py b/packages/backend/app/phases/refinement/function.py index fdfd6b4..392bfd8 100644 --- a/packages/backend/app/phases/refinement/function.py +++ b/packages/backend/app/phases/refinement/function.py @@ -9,6 +9,7 @@ from app.phases._shared.failure import workflow_on_failure from app.phases.refinement.helpers import process_edit, process_question from app.services import db as db_service +from app.services.observability import capture_span @inngest_client.create_function( @@ -21,6 +22,7 @@ async def start_function(ctx: inngest.Context): """Set up refinement phase. Actions are handled by action_function.""" step = ctx.step doc_id = ctx.event.data["document_id"] + user_id = ctx.event.data.get("user_id", "") logger.info("[orchestrator] REFINEMENT start | doc_id={}", doc_id) @@ -31,6 +33,26 @@ async def _set_refinement(): await step.run("set-phase-refinement", _set_refinement) + async def _capture_refinement_spans(): + async with async_session() as db: + doc = await db_service.get_document_detail(db, doc_id) + capture_span( + user_id, doc_id, + span_id=f"refinement-{doc_id}", + span_name="refinement", + input_state="User refinement phase started", + ) + for section in doc.sections: + capture_span( + user_id, doc_id, + span_id=f"refinement-{section.section_type}-{doc_id}", + span_name=section.section_type, + parent_id=f"refinement-{doc_id}", + input_state="Awaiting user edits", + ) + + await step.run("capture-span-refinement", _capture_refinement_spans) + @inngest_client.create_function( fn_id="handle-section-action", @@ -50,6 +72,7 @@ async def action_function(ctx: inngest.Context): doc_id = ctx.event.data["document_id"] section_id = ctx.event.data["section_id"] action = ctx.event.data["action_type"] + user_id = ctx.event.data.get("user_id", "") logger.info( "[orchestrator] section action | doc_id={} section_id={} action={}", @@ -60,12 +83,12 @@ async def action_function(ctx: inngest.Context): if action == "request_edit": async def _apply_edit(): - await process_edit(section_id, ctx.event.data["prompt"]) + await process_edit(section_id, ctx.event.data["prompt"], posthog_distinct_id=user_id) await step.run("apply-edit", _apply_edit) elif action == "ask_question": async def _answer_question(): - await process_question(section_id, ctx.event.data["message"]) + await process_question(section_id, ctx.event.data["message"], posthog_distinct_id=user_id) await step.run("answer-question", _answer_question) elif action == "finalize": @@ -91,6 +114,6 @@ async def _check_finalized(): "emit-refinement-completed", inngest.Event( name="docforge/document.refinement_completed", - data={"document_id": doc_id, "document_type_id": doc_type_id}, + data={"document_id": doc_id, "document_type_id": doc_type_id, "user_id": user_id}, ), ) diff --git a/packages/backend/app/phases/refinement/helpers.py b/packages/backend/app/phases/refinement/helpers.py index e39d10b..347924b 100644 --- a/packages/backend/app/phases/refinement/helpers.py +++ b/packages/backend/app/phases/refinement/helpers.py @@ -78,7 +78,7 @@ async def _build_cross_section_context_from_db(db, doc_id: uuid.UUID, target_typ return _build_cross_section_context(sections, target_type) -async def process_edit(section_id: str, prompt: str) -> None: +async def process_edit(section_id: str, prompt: str, posthog_distinct_id: str | None = None) -> None: """Process an edit request via AI refinement.""" from app.phases.refinement.ai import refine_section @@ -95,6 +95,8 @@ async def process_edit(section_id: str, prompt: str) -> None: return doc = section.document + _phog_id = posthog_distinct_id or str(doc.user_id) + _doc_id = str(doc.id) cross_context = _build_cross_section_context_from_db(db, doc.id, section.section_type) chat_history = await _get_chat_history(db, section.id) contract_dict = await _get_contract_dict(db, doc.id) @@ -102,7 +104,15 @@ async def process_edit(section_id: str, prompt: str) -> None: await db_service.add_chat_message(db, doc.id, section.id, "user", prompt) from app.guardrails import validate_message_intent - intent_err = await validate_message_intent(prompt, section.section_type, section.summary) + intent_err = await validate_message_intent( + prompt, section.section_type, section.summary, + posthog_distinct_id=_phog_id, + posthog_properties={ + "$ai_trace_id": _doc_id, + "$ai_parent_id": f"refinement-{section.section_type}-{_doc_id}", + "$ai_span_name": "edit-guardrail", + }, + ) if intent_err: await db_service.add_chat_message(db, doc.id, section.id, "agent", intent_err.message) return @@ -120,6 +130,8 @@ async def process_edit(section_id: str, prompt: str) -> None: document_contract=contract_dict, db=db, document_type_id=doc.document_type_id, + posthog_distinct_id=_phog_id, + doc_id=_doc_id, ) except BadRequestError as exc: body = exc.body if isinstance(exc.body, dict) else {} @@ -155,7 +167,7 @@ async def process_edit(section_id: str, prompt: str) -> None: await db_service.add_chat_message(db, doc.id, section.id, "agent", ai_result["reply"]) -async def process_question(section_id: str, message: str) -> None: +async def process_question(section_id: str, message: str, posthog_distinct_id: str | None = None) -> None: """Process a question about a section via AI.""" from app.phases.refinement.ai import refine_section @@ -172,6 +184,8 @@ async def process_question(section_id: str, message: str) -> None: return doc = section.document + _phog_id = posthog_distinct_id or str(doc.user_id) + _doc_id = str(doc.id) cross_context = _build_cross_section_context_from_db(db, doc.id, section.section_type) chat_history = await _get_chat_history(db, section.id) contract_dict = await _get_contract_dict(db, doc.id) @@ -179,7 +193,15 @@ async def process_question(section_id: str, message: str) -> None: await db_service.add_chat_message(db, doc.id, section.id, "user", message) from app.guardrails import validate_message_intent - intent_err = await validate_message_intent(message, section.section_type, section.summary) + intent_err = await validate_message_intent( + message, section.section_type, section.summary, + posthog_distinct_id=_phog_id, + posthog_properties={ + "$ai_trace_id": _doc_id, + "$ai_parent_id": f"refinement-{section.section_type}-{_doc_id}", + "$ai_span_name": "question-guardrail", + }, + ) if intent_err: await db_service.add_chat_message(db, doc.id, section.id, "agent", intent_err.message) return @@ -197,6 +219,8 @@ async def process_question(section_id: str, message: str) -> None: document_contract=contract_dict, db=db, document_type_id=doc.document_type_id, + posthog_distinct_id=_phog_id, + doc_id=_doc_id, ) except BadRequestError as exc: body = exc.body if isinstance(exc.body, dict) else {} diff --git a/packages/backend/app/routers/documents.py b/packages/backend/app/routers/documents.py index 7b37892..490b9b7 100644 --- a/packages/backend/app/routers/documents.py +++ b/packages/backend/app/routers/documents.py @@ -19,6 +19,7 @@ validate_document_context, validate_refinement_message, ) +from app.services.observability import capture_trace from app.inngest_client import inngest_client from app.models import ( AuditFinding, @@ -256,6 +257,7 @@ async def create_document( await db.commit() # Dispatch Inngest event + capture_trace(str(current_user.id), str(doc.id), span_name=doc.title, input_state=payload.document_context) try: await inngest_client.send( inngest.Event( @@ -265,6 +267,7 @@ async def create_document( "document_context": payload.document_context, "user_preferences": payload.user_preferences or "", "document_type_id": str(doc_type.id), + "user_id": str(current_user.id), }, ) ) @@ -474,7 +477,7 @@ async def dispatch_event( detail="Message limit reached for this section (10/10).", ) - event_data = {**payload.data, "document_id": str(document_id)} + event_data = {**payload.data, "document_id": str(document_id), "user_id": str(current_user.id)} try: await inngest_client.send( diff --git a/packages/backend/app/services/observability.py b/packages/backend/app/services/observability.py new file mode 100644 index 0000000..8235122 --- /dev/null +++ b/packages/backend/app/services/observability.py @@ -0,0 +1,48 @@ +"""PostHog AI observability helpers — trace and span capture for document workflows.""" +from app.ai.core.client import posthog_client + + +def capture_trace( + distinct_id: str, + doc_id: str, + *, + span_name: str | None = None, + input_state: str | None = None, + output_state: str | None = None, +) -> None: + if posthog_client is None: + return + properties: dict = {"$ai_trace_id": doc_id} + if span_name is not None: + properties["$ai_span_name"] = span_name + if input_state is not None: + properties["$ai_input_state"] = input_state + if output_state is not None: + properties["$ai_output_state"] = output_state + posthog_client.capture(distinct_id=distinct_id, event="$ai_trace", properties=properties) + + +def capture_span( + distinct_id: str, + doc_id: str, + span_id: str, + span_name: str, + *, + parent_id: str | None = None, + input_state: str | None = None, + output_state: str | None = None, +) -> None: + if posthog_client is None: + return + properties: dict = { + "$ai_trace_id": doc_id, + "$ai_span_id": span_id, + "$ai_span_name": span_name, + } + if parent_id is not None: + properties["$ai_parent_id"] = parent_id + if input_state is not None: + properties["$ai_input_state"] = input_state + if output_state is not None: + properties["$ai_output_state"] = output_state + posthog_client.capture(distinct_id=distinct_id, event="$ai_span", properties=properties) diff --git a/packages/backend/pyproject.toml b/packages/backend/pyproject.toml index 3a574d8..74c0e09 100644 --- a/packages/backend/pyproject.toml +++ b/packages/backend/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "python-jose[cryptography]>=3.3.0", "httpx>=0.27.0", "pyyaml>=6.0.0", + "posthog>=7.16.2", ] [project.optional-dependencies] diff --git a/packages/frontend/.env.example b/packages/frontend/.env.example index cb94f12..8e5fdaa 100644 --- a/packages/frontend/.env.example +++ b/packages/frontend/.env.example @@ -1,2 +1,6 @@ VITE_API_BASE=http://localhost:8000/api VITE_CLERK_PUBLISHABLE_KEY=pk_test_... + +# PostHog analytics (optional — leave blank to disable) +VITE_POSTHOG_KEY= +VITE_POSTHOG_HOST=https://us.i.posthog.com diff --git a/packages/frontend/package.json b/packages/frontend/package.json index ddb393f..79dd15a 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -24,6 +24,7 @@ "react-router-dom": "^7.14.2", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.0.2", + "posthog-js": "^1.240.0", "zustand": "^5.0.12" }, "devDependencies": { diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index ee2fb38..1283cdf 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -1,7 +1,12 @@ +import { useState } from "react"; +import posthog from "posthog-js"; +import { PostHogProvider } from "posthog-js/react"; import { BrowserRouter, Routes, Route } from "react-router-dom"; import { ClerkProvider } from "@clerk/clerk-react"; import { HomePage, DocumentPage, LoadingPage, LandingPage, LegalPage, HowItWorksPage, PricingPage, McpPage, BillingPage } from "./pages"; import { ProtectedRoute } from "./components/shared"; +import CookieConsentBanner from "./components/shared/CookieConsentBanner"; +import { useClerkPosthogIdentity } from "./hooks/useClerkPosthogIdentity"; const CLERK_PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string; @@ -9,24 +14,49 @@ if (!CLERK_PUBLISHABLE_KEY) { throw new Error("VITE_CLERK_PUBLISHABLE_KEY is not set"); } +if (import.meta.env.VITE_POSTHOG_KEY) { + posthog.init(import.meta.env.VITE_POSTHOG_KEY as string, { + api_host: (import.meta.env.VITE_POSTHOG_HOST as string) ?? "https://us.i.posthog.com", + opt_out_capturing_by_default: true, + capture_pageview: true, + capture_pageleave: true, + session_recording: { + maskTextSelector: "[data-ph-mask], .editor-content, .markdown-preview", + }, + }); +} + +function AppInner() { + const [consentGiven, setConsentGiven] = useState(false); + useClerkPosthogIdentity(consentGiven); + return ( + <> + setConsentGiven(true)} /> + + } /> + } /> + } /> + } /> + }> + } /> + } /> + } /> + } /> + } /> + + + + ); +} + export default function App() { return ( - - - - } /> - } /> - } /> - } /> - }> - } /> - } /> - } /> - } /> - } /> - - - - + + + + + + + ); } diff --git a/packages/frontend/src/components/phases/discovery/InitialInput.tsx b/packages/frontend/src/components/phases/discovery/InitialInput.tsx index 6d51f04..8c5e833 100644 --- a/packages/frontend/src/components/phases/discovery/InitialInput.tsx +++ b/packages/frontend/src/components/phases/discovery/InitialInput.tsx @@ -77,6 +77,7 @@ export function InitialInput({ onSubmit }: InitialInputProps) { Describe the problem your document solves. The more detail, the better.