From 9e91fa5733d2261e3a58c8467025e04653d57baf Mon Sep 17 00:00:00 2001 From: "kapitmax07@gmail.com" Date: Wed, 12 Aug 2026 16:30:19 -0400 Subject: [PATCH 1/5] fix: restore Secrets Manager secret resource in 5_database, widen tfstate gitignore The aws_secretsmanager_secret resource had been dropped locally in favor of a hardcoded secret_id on aws_secretsmanager_secret_version, which only creates a version inside an already-existing secret. A fresh terraform apply (as Guide 5 walks students through) would fail with ResourceNotFoundException since nothing actually creates the secret. Reverts the working tree back to the already-correct version committed on this branch. Also widen the tfstate gitignore pattern from *.tfstate.backup to *.tfstate.* so timestamped backup files (which contain DB credentials in plaintext) are excluded too. --- .gitignore | 2 +- terraform/5_database/main.tf | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 23c5d41a..7017e62b 100644 --- a/.gitignore +++ b/.gitignore @@ -220,7 +220,7 @@ __marimo__/ .terraform/ terraform.tfstate.d/ *.tfstate -*.tfstate.backup +*.tfstate.* # Lambda deployment packages lambda_function.zip diff --git a/terraform/5_database/main.tf b/terraform/5_database/main.tf index 7e5c895b..4dc3772a 100644 --- a/terraform/5_database/main.tf +++ b/terraform/5_database/main.tf @@ -38,7 +38,7 @@ resource "random_password" "db_password" { resource "aws_secretsmanager_secret" "db_credentials" { name = "alex-aurora-credentials-${random_id.suffix.hex}" recovery_window_in_days = 0 # For development - immediate deletion - + tags = { Project = "alex" Part = "5" From 2d3a34e9dcb3fd405de1d26247acf0e11f0aa59e Mon Sep 17 00:00:00 2001 From: "kapitmax07@gmail.com" Date: Wed, 12 Aug 2026 16:31:13 -0400 Subject: [PATCH 2/5] feat: migrate observability to Langfuse v4 SDK across all agents Replace the old logfire-based tracing shim with the official Langfuse v4 Python SDK and OpenInference OpenAI Agents instrumentation, applied consistently to planner, tagger, reporter, charter, retirement, and the researcher App Runner service. - observability.py: idempotent setup_instrumentation(), observe()/ observation() context managers, PII masking (email/phone/card) via Langfuse's mask and mask_otel_spans hooks, and environment tagging (production/development). - Each lambda_handler.py now loads the job owner up front and tags traces with user_id/session_id/tags so agent runs group under one Langfuse session per job. - Reporter's judge/evaluator step now uses start_evaluator() with a proper NUMERIC score instead of a raw span. - Replace the old time.sleep(15) post-flush workaround with a real blocking client.flush() call before the Lambda execution environment freezes. - Add researcher/observability.py and a smoke-test script for App Runner, and wire LANGFUSE_* env vars into the 4_researcher and 6_agents Terraform modules. - Bump langfuse to >=4 and add openinference-instrumentation-openai-agents across all agent uv projects. --- .env.example | 8 +- backend/charter/lambda_handler.py | 48 ++- backend/charter/observability.py | 299 +++++++++++++----- backend/charter/pyproject.toml | 3 +- backend/charter/uv.lock | 53 +++- backend/package_docker.py | 2 + backend/planner/agent.py | 2 +- backend/planner/lambda_handler.py | 80 +++-- backend/planner/observability.py | 299 +++++++++++++----- backend/planner/pyproject.toml | 3 +- backend/planner/uv.lock | 53 +++- backend/pyproject.toml | 3 +- backend/reporter/lambda_handler.py | 69 ++-- backend/reporter/observability.py | 299 +++++++++++++----- backend/reporter/pyproject.toml | 3 +- backend/reporter/uv.lock | 53 +++- backend/researcher/observability.py | 251 +++++++++++++++ backend/researcher/pyproject.toml | 2 + backend/researcher/server.py | 22 +- backend/researcher/test_langfuse_smoke.py | 26 ++ backend/researcher/uv.lock | 267 ++++++++++++++++ backend/retirement/lambda_handler.py | 49 +-- backend/retirement/observability.py | 299 +++++++++++++----- backend/retirement/pyproject.toml | 3 +- backend/retirement/uv.lock | 53 +++- backend/tagger/lambda_handler.py | 33 +- backend/tagger/observability.py | 299 +++++++++++++----- backend/tagger/pyproject.toml | 3 +- backend/tagger/uv.lock | 53 +++- backend/uv.lock | 53 +++- guides/5_database.md | 19 +- terraform/2_sagemaker/.terraform.lock.hcl | 2 + terraform/3_ingestion/.terraform.lock.hcl | 1 + terraform/4_researcher/main.tf | 17 +- .../4_researcher/terraform.tfvars.example | 7 +- terraform/4_researcher/variables.tf | 19 ++ terraform/5_database/.terraform.lock.hcl | 2 + terraform/6_agents/.terraform.lock.hcl | 1 + terraform/6_agents/main.tf | 50 +-- terraform/6_agents/terraform.tfvars.example | 4 +- 40 files changed, 2234 insertions(+), 578 deletions(-) create mode 100644 backend/researcher/observability.py create mode 100644 backend/researcher/test_langfuse_smoke.py diff --git a/.env.example b/.env.example index a6915e11..1c3931de 100644 --- a/.env.example +++ b/.env.example @@ -70,6 +70,8 @@ CLOUDFRONT_URL= # ============================================================ # PART 8: Observability (to be added) # ============================================================ -# LANGFUSE_PUBLIC_KEY=... -# LANGFUSE_SECRET_KEY=... -# LANGFUSE_HOST=https://cloud.langfuse.com \ No newline at end of file +# LANGFUSE_PUBLIC_KEY=pk-lf-... +# LANGFUSE_SECRET_KEY=sk-lf-... +# LANGFUSE_BASE_URL=https://us.cloud.langfuse.com +# LANGFUSE_HOST=https://us.cloud.langfuse.com +# LANGFUSE_TRACING_ENVIRONMENT=development \ No newline at end of file diff --git a/backend/charter/lambda_handler.py b/backend/charter/lambda_handler.py index 1db93b70..947c947e 100644 --- a/backend/charter/lambda_handler.py +++ b/backend/charter/lambda_handler.py @@ -134,24 +134,36 @@ def lambda_handler(event, context): "portfolio_data": {...} } """ - # Wrap entire handler with observability context - with observe(): - try: - logger.info(f"Charter Lambda invoked with event keys: {list(event.keys()) if isinstance(event, dict) else 'not a dict'}") + if isinstance(event, str): + event = json.loads(event) - # Parse event - if isinstance(event, str): - event = json.loads(event) + logger.info(f"Charter Lambda invoked with event keys: {list(event.keys()) if isinstance(event, dict) else 'not a dict'}") - job_id = event.get('job_id') - if not job_id: - return { - 'statusCode': 400, - 'body': json.dumps({'error': 'job_id is required'}) - } + job_id = event.get('job_id') + if not job_id: + return { + 'statusCode': 400, + 'body': json.dumps({'error': 'job_id is required'}) + } - # Initialize database first - db = Database() + db = Database() + user_id = None + try: + job = db.jobs.find_by_id(job_id) + if job: + user_id = job.get('clerk_user_id') + except Exception as e: + logger.warning(f"Charter: Could not load job owner for tracing: {e}") + + with observe( + name="chart-portfolio", + user_id=user_id, + session_id=job_id, + tags=["charter", "portfolio-analysis"], + metadata={"agent": "charter"}, + input={"job_id": job_id}, + ) as obs: + try: portfolio_data = event.get('portfolio_data') if not portfolio_data: @@ -212,13 +224,17 @@ def lambda_handler(event, context): result = asyncio.run(run_charter_agent(job_id, portfolio_data, db)) logger.info(f"Charter completed for job {job_id}: {result}") - + obs.update(output={ + "status": "completed" if result.get("success") else "failed", + "charts_generated": result.get("charts_generated", 0), + }) return { 'statusCode': 200, 'body': json.dumps(result) } except Exception as e: + obs.update(output={"status": "failed", "error": str(e)}) logger.error(f"Error in charter: {e}", exc_info=True) return { 'statusCode': 500, diff --git a/backend/charter/observability.py b/backend/charter/observability.py index c694860b..aa62258a 100644 --- a/backend/charter/observability.py +++ b/backend/charter/observability.py @@ -1,112 +1,251 @@ """ -Observability module for LangFuse integration. -Provides a simple context manager for setting up and flushing traces. +Langfuse observability for Alex agents. + +Uses the official OpenInference instrumentation for the OpenAI Agents SDK +and Langfuse Python SDK v4 APIs (propagate_attributes, observation types). +Agents work normally when Langfuse credentials are not configured. """ -import os +from __future__ import annotations + import logging -from contextlib import contextmanager +import os +import re +from contextlib import contextmanager, nullcontext +from dataclasses import dataclass +from typing import Any, Iterator, Optional -# Use root logger for Lambda compatibility logger = logging.getLogger() logger.setLevel(logging.INFO) +_EMAIL_RE = re.compile(r"\b[\w.-]+@[\w.-]+\.\w+\b") +_PHONE_RE = re.compile(r"\b\d{3}[-. ]?\d{3}[-. ]?\d{4}\b") +_CARD_RE = re.compile(r"\b(?:\d[ -]*?){13,19}\b") -@contextmanager -def observe(): - """ - Context manager for observability with LangFuse. +_instrumented = False +_client: Any = None - Sets up LangFuse observability if environment variables are configured, - and ensures traces are flushed on exit. - Usage: - from observability import observe +def _is_deployed() -> bool: + return bool( + os.getenv("AWS_LAMBDA_FUNCTION_NAME") + or os.getenv("AWS_EXECUTION_ENV") + or os.getenv("AWS_APP_RUNNER_SERVICE_ID") + ) - with observe(): - # Your code that uses OpenAI Agents SDK - result = await agent.run(...) - """ - logger.info("šŸ” Observability: Checking configuration...") - # Check if required environment variables exist - has_langfuse = bool(os.getenv("LANGFUSE_SECRET_KEY")) - has_openai = bool(os.getenv("OPENAI_API_KEY")) +def _sync_host_env() -> Optional[str]: + """Langfuse accepts LANGFUSE_BASE_URL (current) or LANGFUSE_HOST (legacy).""" + host = os.getenv("LANGFUSE_BASE_URL") or os.getenv("LANGFUSE_HOST") + if host: + os.environ["LANGFUSE_BASE_URL"] = host + os.environ["LANGFUSE_HOST"] = host + return host - logger.info(f"šŸ” Observability: LANGFUSE_SECRET_KEY exists: {has_langfuse}") - logger.info(f"šŸ” Observability: OPENAI_API_KEY exists: {has_openai}") - if not has_langfuse: - logger.info("šŸ” Observability: LangFuse not configured, skipping setup") - yield - return +def _default_environment() -> str: + return os.getenv("LANGFUSE_TRACING_ENVIRONMENT") or ( + "production" if _is_deployed() else "development" + ) - if not has_openai: - logger.warning("āš ļø Observability: OPENAI_API_KEY not set, traces may not export") - # Local variable for the client (no global needed) - langfuse_client = None +def _mask_text(value: str) -> str: + masked = _EMAIL_RE.sub("[REDACTED EMAIL]", value) + masked = _PHONE_RE.sub("[REDACTED PHONE]", masked) + masked = _CARD_RE.sub("[REDACTED CARD]", masked) + return masked - # Try to set up LangFuse - try: - logger.info("šŸ” Observability: Setting up LangFuse...") - import logfire - from langfuse import get_client +def _mask_data(*, data: Any, **kwargs: Any) -> Any: + if isinstance(data, str): + return _mask_text(data) + if isinstance(data, dict): + return {key: _mask_data(data=value) for key, value in data.items()} + if isinstance(data, list): + return [_mask_data(data=item) for item in data] + return data - # Configure logfire to instrument OpenAI Agents SDK - logfire.configure( - service_name="alex_charter_agent", - send_to_logfire=False, # Don't send to Logfire cloud + +def _stringify_metadata(metadata: Optional[dict[str, Any]]) -> dict[str, str]: + if not metadata: + return {} + result: dict[str, str] = {} + for key, value in metadata.items(): + text = str(value) + if len(text) > 200: + text = text[:197] + "..." + result[str(key)] = text + return result + + +def _mask_otel_spans(*, params: Any) -> Any: + try: + from langfuse.types import MaskOtelSpansResult, OtelSpanPatch + except ImportError: + return None + + patches = {} + for identifier, span in params.spans.items(): + replacements = {} + for key, value in span.attributes.items(): + if isinstance(value, str): + masked = _mask_text(value) + if masked != value: + replacements[key] = masked + if replacements: + patches[identifier] = OtelSpanPatch(set_attributes=replacements) + return MaskOtelSpansResult(span_patches=patches) if patches else None + + +@dataclass +class Observability: + """Handle yielded by observe(). Falsy when Langfuse is not configured.""" + + client: Any = None + observation: Any = None + + def __bool__(self) -> bool: + return self.client is not None + + def update(self, **kwargs: Any) -> None: + if self.observation is not None: + self.observation.update(**kwargs) + + def start_evaluator(self, name: str, **kwargs: Any): + if self.client is None: + return nullcontext() + return self.client.start_as_current_observation( + as_type="evaluator", name=name, **kwargs ) - logger.info("āœ… Observability: Logfire configured") - # Instrument OpenAI Agents SDK - logfire.instrument_openai_agents() - logger.info("āœ… Observability: OpenAI Agents SDK instrumented") - # Initialize LangFuse client - langfuse_client = get_client() - logger.info("āœ… Observability: LangFuse client initialized") +def setup_instrumentation() -> Any: + """Idempotent Langfuse + OpenAI Agents SDK instrumentation.""" + global _instrumented, _client + + if _instrumented: + return _client + + logger.info("Observability: Checking Langfuse configuration...") + if not os.getenv("LANGFUSE_SECRET_KEY") or not os.getenv("LANGFUSE_PUBLIC_KEY"): + logger.info("Observability: Langfuse not configured, skipping setup") + _instrumented = True + _client = None + return None + + _sync_host_env() + os.environ.setdefault("LANGFUSE_TRACING_ENVIRONMENT", _default_environment()) + + try: + from langfuse import Langfuse + from openinference.instrumentation.openai_agents import OpenAIAgentsInstrumentor - # Optional: Check authentication (blocking call, use sparingly) try: - auth_result = langfuse_client.auth_check() - logger.info( - f"āœ… Observability: LangFuse authentication check passed (result: {auth_result})" - ) - except Exception as auth_error: - logger.warning(f"āš ļø Observability: Auth check failed but continuing: {auth_error}") + _client = Langfuse(mask=_mask_data, mask_otel_spans=_mask_otel_spans) + except TypeError: + _client = Langfuse(mask=_mask_data) + OpenAIAgentsInstrumentor().instrument() + logger.info( + "Observability: Langfuse client ready (environment=%s)", + os.environ.get("LANGFUSE_TRACING_ENVIRONMENT"), + ) - logger.info("šŸŽÆ Observability: Setup complete - traces will be sent to LangFuse") + try: + if _client.auth_check(): + logger.info("Observability: Langfuse authentication succeeded") + else: + logger.warning("Observability: Langfuse auth check returned false") + except Exception as auth_error: + logger.warning("Observability: Auth check failed but continuing: %s", auth_error) except ImportError as e: - logger.error(f"āŒ Observability: Missing required package: {e}") - langfuse_client = None + logger.error("Observability: Missing required package: %s", e) + _client = None except Exception as e: - logger.error(f"āŒ Observability: Setup failed: {e}") - langfuse_client = None + logger.error("Observability: Setup failed: %s", e) + _client = None + + _instrumented = True + return _client + + +@contextmanager +def observe( + *, + name: str = "run-agent", + user_id: Optional[str] = None, + session_id: Optional[str] = None, + tags: Optional[list[str]] = None, + metadata: Optional[dict[str, Any]] = None, + input: Any = None, +) -> Iterator[Observability]: + """ + Trace one agent run. Groups related agents with session_id=job_id. + + Usage: + with observe(name="plan-portfolio", session_id=job_id, user_id=user_id) as obs: + result = asyncio.run(run_orchestrator(job_id)) + obs.update(output={"status": "completed"}) + """ + from langfuse import propagate_attributes + + client = setup_instrumentation() + if client is None: + yield Observability() + return + handle = Observability(client=client) try: - # Yield control back to the calling code - yield + with client.start_as_current_observation( + as_type="span", + name=name, + input=input, + ) as root: + handle.observation = root + attr_kwargs: dict[str, Any] = { + "trace_name": name, + "tags": tags or [], + "metadata": _stringify_metadata(metadata), + "version": os.getenv("LANGFUSE_RELEASE", "1.0.0"), + } + if user_id: + attr_kwargs["user_id"] = user_id + if session_id: + attr_kwargs["session_id"] = session_id + with propagate_attributes(**attr_kwargs): + yield handle finally: - # Flush traces on exit - if langfuse_client: - try: - logger.info("šŸ” Observability: Flushing traces to LangFuse...") - langfuse_client.flush() - langfuse_client.shutdown() - - # Add a 10 second delay to ensure network requests complete - # This is a workaround for Lambda's immediate termination - import time - - logger.info("šŸ” Observability: Waiting 10 seconds for flush to complete...") - time.sleep(10) - - logger.info("āœ… Observability: Traces flushed successfully") - except Exception as e: - logger.error(f"āŒ Observability: Failed to flush traces: {e}") - else: - logger.debug("šŸ” Observability: No client to flush") + try: + logger.info("Observability: Flushing traces to Langfuse...") + client.flush() + logger.info("Observability: Traces flushed successfully") + except Exception as e: + logger.error("Observability: Failed to flush traces: %s", e) + + +@contextmanager +def observation( + name: str, + *, + as_type: str = "span", + input: Any = None, + output: Any = None, +) -> Iterator[Any]: + """Nested observation that no-ops when Langfuse is not configured.""" + client = _client or setup_instrumentation() + if client is None: + yield None + return + + with client.start_as_current_observation( + as_type=as_type, + name=name, + input=input, + ) as obs: + try: + yield obs + if output is not None: + obs.update(output=output) + except Exception as e: + obs.update(output={"error": str(e)}) + raise diff --git a/backend/charter/pyproject.toml b/backend/charter/pyproject.toml index 36ccc423..c5a1df0b 100644 --- a/backend/charter/pyproject.toml +++ b/backend/charter/pyproject.toml @@ -5,8 +5,9 @@ requires-python = ">=3.12" dependencies = [ "alex-database", "boto3>=1.40.9", - "langfuse>=3.3.4", + "langfuse>=4", "openai-agents[litellm]>=0.2.6", + "openinference-instrumentation-openai-agents<2.0.0", "pydantic>=2.11.7", "pydantic-ai>=1.0.6", "python-dotenv>=1.1.1", diff --git a/backend/charter/uv.lock b/backend/charter/uv.lock index fe77ef84..1667fc8b 100644 --- a/backend/charter/uv.lock +++ b/backend/charter/uv.lock @@ -269,6 +269,7 @@ dependencies = [ { name = "boto3" }, { name = "langfuse" }, { name = "openai-agents", extra = ["litellm"] }, + { name = "openinference-instrumentation-openai-agents" }, { name = "pydantic" }, { name = "pydantic-ai" }, { name = "python-dotenv" }, @@ -279,8 +280,9 @@ dependencies = [ requires-dist = [ { name = "alex-database", editable = "../database" }, { name = "boto3", specifier = ">=1.40.9" }, - { name = "langfuse", specifier = ">=3.3.4" }, + { name = "langfuse", specifier = ">=4" }, { name = "openai-agents", extras = ["litellm"], specifier = ">=0.2.6" }, + { name = "openinference-instrumentation-openai-agents", specifier = "<2.0.0" }, { name = "pydantic", specifier = ">=2.11.7" }, { name = "pydantic-ai", specifier = ">=1.0.6" }, { name = "python-dotenv", specifier = ">=1.1.1" }, @@ -774,7 +776,7 @@ wheels = [ [[package]] name = "langfuse" -version = "3.3.4" +version = "4.14.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff" }, @@ -784,12 +786,11 @@ dependencies = [ { name = "opentelemetry-sdk" }, { name = "packaging" }, { name = "pydantic" }, - { name = "requests" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/27/9d/c9c422c660459687293727c9146185f92443b6c72fda564ab10e9e16ab41/langfuse-3.3.4.tar.gz", hash = "sha256:e5df4e7284298990b522e02a1dc6c3c72ebc4a7a411dc7d39255fb8c2e5a7c3a", size = 164745, upload-time = "2025-09-02T15:02:39.16Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/3f/d3387db1f472d2fd4b5de40eb6abedc2f59c9da2224e9d0a79d054a90b81/langfuse-4.14.4.tar.gz", hash = "sha256:48c4bdefc290f61a42881b8048a904ab6b81d37e02496473166836e71ee0ab3a", size = 389680, upload-time = "2026-08-11T17:03:20.472Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/46/edd370d47ca72ed4ca36b3c3b8f3a4ce71a310629d0bcb6ed760b04b08b1/langfuse-3.3.4-py3-none-any.whl", hash = "sha256:15b9d20878cf39a48ca9cfa7e52acdfeb043603d3a9cef8cf451687a4d838c6b", size = 318389, upload-time = "2025-09-02T15:02:37.171Z" }, + { url = "https://files.pythonhosted.org/packages/d9/1b/a198adc52aa4ffd0886ca0d4e9bb97d45f4f326db206d2395bac67677f5d/langfuse-4.14.4-py3-none-any.whl", hash = "sha256:78692a1d4785a8c255bf6ea967e673e1ee30ce078d646e7b5f7ff44549d45cce", size = 688351, upload-time = "2026-08-11T17:03:21.881Z" }, ] [[package]] @@ -1063,6 +1064,48 @@ litellm = [ { name = "litellm" }, ] +[[package]] +name = "openinference-instrumentation" +version = "0.1.57" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openinference-semantic-conventions" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/d1/08bbd17cb66c1d7749802a30a551c132fa0ecdb1169bd13cf47fb8427132/openinference_instrumentation-0.1.57.tar.gz", hash = "sha256:2bd00a1f95ebb8a47d11615c1039128101e4abc0e9722cc4331849afdeb248fc", size = 41183, upload-time = "2026-08-07T14:29:23.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/50/05db6ba8da9fcf1872597f52b66b3894a1e5b78c177a50239e30de008317/openinference_instrumentation-0.1.57-py3-none-any.whl", hash = "sha256:e06f436e93156f5e0cfedf3f34bde1a386f8e00dbff528155a506ce74c72af92", size = 48872, upload-time = "2026-08-07T14:29:21.632Z" }, +] + +[[package]] +name = "openinference-instrumentation-openai-agents" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openinference-instrumentation" }, + { name = "openinference-semantic-conventions" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/f1/8d97d6c859854f6d5546af805185a03376604d73d744e90b3308eaddd9bb/openinference_instrumentation_openai_agents-1.6.2.tar.gz", hash = "sha256:efffff2606e552b6cb8481c6e5738b8c104b6e6e72ead13773ee5b0bdc86aabd", size = 26586, upload-time = "2026-07-30T16:38:28.049Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/15/2f6f3890675c626eeac81ce7994f7762bb25c3a3a4a5b00e41e712c3fd47/openinference_instrumentation_openai_agents-1.6.2-py3-none-any.whl", hash = "sha256:0d9fcf4cb24dd22c57ed035f31c807135fbccc67347e11cfdc01b39e835d35d9", size = 28995, upload-time = "2026-07-30T16:38:26.879Z" }, +] + +[[package]] +name = "openinference-semantic-conventions" +version = "0.1.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/5e/13ffdb2ba436312cda9ed842ce3416fa5961e36a205df9a18a67eef7f9b3/openinference_semantic_conventions-0.1.32.tar.gz", hash = "sha256:2a3e6723ddce37abc5c43d38c6c7ac05b0f2efcfbe75cc0f135ab14ceb0730f7", size = 13980, upload-time = "2026-08-07T14:29:21.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/19/2d096333313d45e5f87d08f903cedc3f7298c5690d2e6c4ef236dc22870e/openinference_semantic_conventions-0.1.32-py3-none-any.whl", hash = "sha256:d06c4716faf7537fbabbb6d34e758ec6b3650a18c037661782fc8c3e9fa90f81", size = 11252, upload-time = "2026-08-07T14:29:19.78Z" }, +] + [[package]] name = "opentelemetry-api" version = "1.37.0" diff --git a/backend/package_docker.py b/backend/package_docker.py index 311bc013..c2caf2be 100644 --- a/backend/package_docker.py +++ b/backend/package_docker.py @@ -9,6 +9,8 @@ import subprocess from pathlib import Path +sys.stdout.reconfigure(encoding="utf-8") + def run_packaging(agent_name): """Run packaging for a specific agent.""" diff --git a/backend/planner/agent.py b/backend/planner/agent.py index 32241ff0..c83ff433 100644 --- a/backend/planner/agent.py +++ b/backend/planner/agent.py @@ -114,7 +114,7 @@ def handle_missing_instruments(job_id: str, db) -> None: response = lambda_client.invoke( FunctionName=TAGGER_FUNCTION, InvocationType="RequestResponse", - Payload=json.dumps({"instruments": missing}), + Payload=json.dumps({"instruments": missing, "job_id": job_id}), ) result = json.loads(response["Payload"].read()) diff --git a/backend/planner/lambda_handler.py b/backend/planner/lambda_handler.py index 0bd69ed0..dc74f665 100644 --- a/backend/planner/lambda_handler.py +++ b/backend/planner/lambda_handler.py @@ -24,7 +24,7 @@ from templates import ORCHESTRATOR_INSTRUCTIONS from agent import create_agent, handle_missing_instruments, load_portfolio_summary from market import update_instrument_prices -from observability import observe +from observability import observe, observation logger = logging.getLogger() logger.setLevel(logging.INFO) @@ -45,11 +45,13 @@ async def run_orchestrator(job_id: str) -> None: db.jobs.update_status(job_id, 'running') # Handle missing instruments first (non-agent pre-processing) - await asyncio.to_thread(handle_missing_instruments, job_id, db) + with observation("tag-missing-instruments", as_type="span", input={"job_id": job_id}): + await asyncio.to_thread(handle_missing_instruments, job_id, db) # Update instrument prices after tagging logger.info("Planner: Updating instrument prices from market data") - await asyncio.to_thread(update_instrument_prices, job_id, db) + with observation("update-instrument-prices", as_type="span", input={"job_id": job_id}): + await asyncio.to_thread(update_instrument_prices, job_id, db) # Load portfolio summary (just statistics, not full data) portfolio_summary = await asyncio.to_thread(load_portfolio_summary, job_id, db) @@ -83,6 +85,22 @@ async def run_orchestrator(job_id: str) -> None: db.jobs.update_status(job_id, 'failed', error_message=str(e)) raise +def _extract_job_id(event: Dict[str, Any]) -> str | None: + """Extract job_id from an SQS event or a direct Lambda invocation.""" + if 'Records' in event and len(event['Records']) > 0: + job_id = event['Records'][0]['body'] + if isinstance(job_id, str) and job_id.startswith('{'): + try: + body = json.loads(job_id) + job_id = body.get('job_id', job_id) + except json.JSONDecodeError: + pass + return job_id + if 'job_id' in event: + return event['job_id'] + return None + + def lambda_handler(event, context): """ Lambda handler for SQS-triggered orchestration. @@ -96,37 +114,36 @@ def lambda_handler(event, context): ] } """ - # Wrap entire handler with observability context - with observe(): - try: - logger.info(f"Planner Lambda invoked with event: {json.dumps(event)[:500]}") - - # Extract job_id from SQS message - if 'Records' in event and len(event['Records']) > 0: - # SQS message - job_id = event['Records'][0]['body'] - if isinstance(job_id, str) and job_id.startswith('{'): - # Body might be JSON - try: - body = json.loads(job_id) - job_id = body.get('job_id', job_id) - except json.JSONDecodeError: - pass - elif 'job_id' in event: - # Direct invocation - job_id = event['job_id'] - else: - logger.error("No job_id found in event") - return { - 'statusCode': 400, - 'body': json.dumps({'error': 'No job_id provided'}) - } + logger.info(f"Planner Lambda invoked with event: {json.dumps(event)[:500]}") + + job_id = _extract_job_id(event) + if not job_id: + logger.error("No job_id found in event") + return { + 'statusCode': 400, + 'body': json.dumps({'error': 'No job_id provided'}) + } + user_id = None + try: + job = db.jobs.find_by_id(job_id) + if job: + user_id = job.get("clerk_user_id") + except Exception as e: + logger.warning(f"Planner: Could not load job owner for tracing: {e}") + + with observe( + name="plan-portfolio", + user_id=user_id, + session_id=job_id, + tags=["planner", "portfolio-analysis"], + metadata={"agent": "planner"}, + input={"job_id": job_id}, + ) as obs: + try: logger.info(f"Planner: Starting orchestration for job {job_id}") - - # Run the orchestrator asyncio.run(run_orchestrator(job_id)) - + obs.update(output={"status": "completed", "job_id": job_id}) return { 'statusCode': 200, 'body': json.dumps({ @@ -136,6 +153,7 @@ def lambda_handler(event, context): } except Exception as e: + obs.update(output={"status": "failed", "error": str(e)}) logger.error(f"Planner: Error in lambda handler: {e}", exc_info=True) return { 'statusCode': 500, diff --git a/backend/planner/observability.py b/backend/planner/observability.py index bd2c0c7e..aa62258a 100644 --- a/backend/planner/observability.py +++ b/backend/planner/observability.py @@ -1,112 +1,251 @@ """ -Observability module for LangFuse integration. -Provides a simple context manager for setting up and flushing traces. +Langfuse observability for Alex agents. + +Uses the official OpenInference instrumentation for the OpenAI Agents SDK +and Langfuse Python SDK v4 APIs (propagate_attributes, observation types). +Agents work normally when Langfuse credentials are not configured. """ -import os +from __future__ import annotations + import logging -from contextlib import contextmanager +import os +import re +from contextlib import contextmanager, nullcontext +from dataclasses import dataclass +from typing import Any, Iterator, Optional -# Use root logger for Lambda compatibility logger = logging.getLogger() logger.setLevel(logging.INFO) +_EMAIL_RE = re.compile(r"\b[\w.-]+@[\w.-]+\.\w+\b") +_PHONE_RE = re.compile(r"\b\d{3}[-. ]?\d{3}[-. ]?\d{4}\b") +_CARD_RE = re.compile(r"\b(?:\d[ -]*?){13,19}\b") -@contextmanager -def observe(): - """ - Context manager for observability with LangFuse. +_instrumented = False +_client: Any = None - Sets up LangFuse observability if environment variables are configured, - and ensures traces are flushed on exit. - Usage: - from observability import observe +def _is_deployed() -> bool: + return bool( + os.getenv("AWS_LAMBDA_FUNCTION_NAME") + or os.getenv("AWS_EXECUTION_ENV") + or os.getenv("AWS_APP_RUNNER_SERVICE_ID") + ) - with observe(): - # Your code that uses OpenAI Agents SDK - result = await agent.run(...) - """ - logger.info("šŸ” Observability: Checking configuration...") - # Check if required environment variables exist - has_langfuse = bool(os.getenv("LANGFUSE_SECRET_KEY")) - has_openai = bool(os.getenv("OPENAI_API_KEY")) +def _sync_host_env() -> Optional[str]: + """Langfuse accepts LANGFUSE_BASE_URL (current) or LANGFUSE_HOST (legacy).""" + host = os.getenv("LANGFUSE_BASE_URL") or os.getenv("LANGFUSE_HOST") + if host: + os.environ["LANGFUSE_BASE_URL"] = host + os.environ["LANGFUSE_HOST"] = host + return host - logger.info(f"šŸ” Observability: LANGFUSE_SECRET_KEY exists: {has_langfuse}") - logger.info(f"šŸ” Observability: OPENAI_API_KEY exists: {has_openai}") - if not has_langfuse: - logger.info("šŸ” Observability: LangFuse not configured, skipping setup") - yield - return +def _default_environment() -> str: + return os.getenv("LANGFUSE_TRACING_ENVIRONMENT") or ( + "production" if _is_deployed() else "development" + ) - if not has_openai: - logger.warning("āš ļø Observability: OPENAI_API_KEY not set, traces may not export") - # Local variable for the client (no global needed) - langfuse_client = None +def _mask_text(value: str) -> str: + masked = _EMAIL_RE.sub("[REDACTED EMAIL]", value) + masked = _PHONE_RE.sub("[REDACTED PHONE]", masked) + masked = _CARD_RE.sub("[REDACTED CARD]", masked) + return masked - # Try to set up LangFuse - try: - logger.info("šŸ” Observability: Setting up LangFuse...") - import logfire - from langfuse import get_client +def _mask_data(*, data: Any, **kwargs: Any) -> Any: + if isinstance(data, str): + return _mask_text(data) + if isinstance(data, dict): + return {key: _mask_data(data=value) for key, value in data.items()} + if isinstance(data, list): + return [_mask_data(data=item) for item in data] + return data - # Configure logfire to instrument OpenAI Agents SDK - logfire.configure( - service_name="alex_planner_agent", - send_to_logfire=False, # Don't send to Logfire cloud + +def _stringify_metadata(metadata: Optional[dict[str, Any]]) -> dict[str, str]: + if not metadata: + return {} + result: dict[str, str] = {} + for key, value in metadata.items(): + text = str(value) + if len(text) > 200: + text = text[:197] + "..." + result[str(key)] = text + return result + + +def _mask_otel_spans(*, params: Any) -> Any: + try: + from langfuse.types import MaskOtelSpansResult, OtelSpanPatch + except ImportError: + return None + + patches = {} + for identifier, span in params.spans.items(): + replacements = {} + for key, value in span.attributes.items(): + if isinstance(value, str): + masked = _mask_text(value) + if masked != value: + replacements[key] = masked + if replacements: + patches[identifier] = OtelSpanPatch(set_attributes=replacements) + return MaskOtelSpansResult(span_patches=patches) if patches else None + + +@dataclass +class Observability: + """Handle yielded by observe(). Falsy when Langfuse is not configured.""" + + client: Any = None + observation: Any = None + + def __bool__(self) -> bool: + return self.client is not None + + def update(self, **kwargs: Any) -> None: + if self.observation is not None: + self.observation.update(**kwargs) + + def start_evaluator(self, name: str, **kwargs: Any): + if self.client is None: + return nullcontext() + return self.client.start_as_current_observation( + as_type="evaluator", name=name, **kwargs ) - logger.info("āœ… Observability: Logfire configured") - # Instrument OpenAI Agents SDK - logfire.instrument_openai_agents() - logger.info("āœ… Observability: OpenAI Agents SDK instrumented") - # Initialize LangFuse client - langfuse_client = get_client() - logger.info("āœ… Observability: LangFuse client initialized") +def setup_instrumentation() -> Any: + """Idempotent Langfuse + OpenAI Agents SDK instrumentation.""" + global _instrumented, _client + + if _instrumented: + return _client + + logger.info("Observability: Checking Langfuse configuration...") + if not os.getenv("LANGFUSE_SECRET_KEY") or not os.getenv("LANGFUSE_PUBLIC_KEY"): + logger.info("Observability: Langfuse not configured, skipping setup") + _instrumented = True + _client = None + return None + + _sync_host_env() + os.environ.setdefault("LANGFUSE_TRACING_ENVIRONMENT", _default_environment()) + + try: + from langfuse import Langfuse + from openinference.instrumentation.openai_agents import OpenAIAgentsInstrumentor - # Optional: Check authentication (blocking call, use sparingly) try: - auth_result = langfuse_client.auth_check() - logger.info( - f"āœ… Observability: LangFuse authentication check passed (result: {auth_result})" - ) - except Exception as auth_error: - logger.warning(f"āš ļø Observability: Auth check failed but continuing: {auth_error}") + _client = Langfuse(mask=_mask_data, mask_otel_spans=_mask_otel_spans) + except TypeError: + _client = Langfuse(mask=_mask_data) + OpenAIAgentsInstrumentor().instrument() + logger.info( + "Observability: Langfuse client ready (environment=%s)", + os.environ.get("LANGFUSE_TRACING_ENVIRONMENT"), + ) - logger.info("šŸŽÆ Observability: Setup complete - traces will be sent to LangFuse") + try: + if _client.auth_check(): + logger.info("Observability: Langfuse authentication succeeded") + else: + logger.warning("Observability: Langfuse auth check returned false") + except Exception as auth_error: + logger.warning("Observability: Auth check failed but continuing: %s", auth_error) except ImportError as e: - logger.error(f"āŒ Observability: Missing required package: {e}") - langfuse_client = None + logger.error("Observability: Missing required package: %s", e) + _client = None except Exception as e: - logger.error(f"āŒ Observability: Setup failed: {e}") - langfuse_client = None + logger.error("Observability: Setup failed: %s", e) + _client = None + + _instrumented = True + return _client + + +@contextmanager +def observe( + *, + name: str = "run-agent", + user_id: Optional[str] = None, + session_id: Optional[str] = None, + tags: Optional[list[str]] = None, + metadata: Optional[dict[str, Any]] = None, + input: Any = None, +) -> Iterator[Observability]: + """ + Trace one agent run. Groups related agents with session_id=job_id. + + Usage: + with observe(name="plan-portfolio", session_id=job_id, user_id=user_id) as obs: + result = asyncio.run(run_orchestrator(job_id)) + obs.update(output={"status": "completed"}) + """ + from langfuse import propagate_attributes + + client = setup_instrumentation() + if client is None: + yield Observability() + return + handle = Observability(client=client) try: - # Yield control back to the calling code - yield + with client.start_as_current_observation( + as_type="span", + name=name, + input=input, + ) as root: + handle.observation = root + attr_kwargs: dict[str, Any] = { + "trace_name": name, + "tags": tags or [], + "metadata": _stringify_metadata(metadata), + "version": os.getenv("LANGFUSE_RELEASE", "1.0.0"), + } + if user_id: + attr_kwargs["user_id"] = user_id + if session_id: + attr_kwargs["session_id"] = session_id + with propagate_attributes(**attr_kwargs): + yield handle finally: - # Flush traces on exit - if langfuse_client: - try: - logger.info("šŸ” Observability: Flushing traces to LangFuse...") - langfuse_client.flush() - langfuse_client.shutdown() - - # Add a 10 second delay to ensure network requests complete - # This is a workaround for Lambda's immediate termination - import time - - logger.info("šŸ” Observability: Waiting 15 seconds for flush to complete...") - time.sleep(15) - - logger.info("āœ… Observability: Traces flushed successfully") - except Exception as e: - logger.error(f"āŒ Observability: Failed to flush traces: {e}") - else: - logger.debug("šŸ” Observability: No client to flush") + try: + logger.info("Observability: Flushing traces to Langfuse...") + client.flush() + logger.info("Observability: Traces flushed successfully") + except Exception as e: + logger.error("Observability: Failed to flush traces: %s", e) + + +@contextmanager +def observation( + name: str, + *, + as_type: str = "span", + input: Any = None, + output: Any = None, +) -> Iterator[Any]: + """Nested observation that no-ops when Langfuse is not configured.""" + client = _client or setup_instrumentation() + if client is None: + yield None + return + + with client.start_as_current_observation( + as_type=as_type, + name=name, + input=input, + ) as obs: + try: + yield obs + if output is not None: + obs.update(output=output) + except Exception as e: + obs.update(output={"error": str(e)}) + raise diff --git a/backend/planner/pyproject.toml b/backend/planner/pyproject.toml index 369c8ce1..f2c2dbb2 100644 --- a/backend/planner/pyproject.toml +++ b/backend/planner/pyproject.toml @@ -5,8 +5,9 @@ requires-python = ">=3.12" dependencies = [ "alex-database", "boto3>=1.40.8", - "langfuse>=3.3.4", + "langfuse>=4", "openai-agents[litellm]>=0.3.0", + "openinference-instrumentation-openai-agents<2.0.0", "polygon-api-client>=1.15.3", "pydantic>=2.11.7", "pydantic-ai>=1.0.6", diff --git a/backend/planner/uv.lock b/backend/planner/uv.lock index 10491be9..32d5a569 100644 --- a/backend/planner/uv.lock +++ b/backend/planner/uv.lock @@ -747,7 +747,7 @@ wheels = [ [[package]] name = "langfuse" -version = "3.3.4" +version = "4.14.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff" }, @@ -757,12 +757,11 @@ dependencies = [ { name = "opentelemetry-sdk" }, { name = "packaging" }, { name = "pydantic" }, - { name = "requests" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/27/9d/c9c422c660459687293727c9146185f92443b6c72fda564ab10e9e16ab41/langfuse-3.3.4.tar.gz", hash = "sha256:e5df4e7284298990b522e02a1dc6c3c72ebc4a7a411dc7d39255fb8c2e5a7c3a", size = 164745, upload-time = "2025-09-02T15:02:39.16Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/3f/d3387db1f472d2fd4b5de40eb6abedc2f59c9da2224e9d0a79d054a90b81/langfuse-4.14.4.tar.gz", hash = "sha256:48c4bdefc290f61a42881b8048a904ab6b81d37e02496473166836e71ee0ab3a", size = 389680, upload-time = "2026-08-11T17:03:20.472Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/46/edd370d47ca72ed4ca36b3c3b8f3a4ce71a310629d0bcb6ed760b04b08b1/langfuse-3.3.4-py3-none-any.whl", hash = "sha256:15b9d20878cf39a48ca9cfa7e52acdfeb043603d3a9cef8cf451687a4d838c6b", size = 318389, upload-time = "2025-09-02T15:02:37.171Z" }, + { url = "https://files.pythonhosted.org/packages/d9/1b/a198adc52aa4ffd0886ca0d4e9bb97d45f4f326db206d2395bac67677f5d/langfuse-4.14.4-py3-none-any.whl", hash = "sha256:78692a1d4785a8c255bf6ea967e673e1ee30ce078d646e7b5f7ff44549d45cce", size = 688351, upload-time = "2026-08-11T17:03:21.881Z" }, ] [[package]] @@ -1036,6 +1035,48 @@ litellm = [ { name = "litellm" }, ] +[[package]] +name = "openinference-instrumentation" +version = "0.1.57" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openinference-semantic-conventions" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/d1/08bbd17cb66c1d7749802a30a551c132fa0ecdb1169bd13cf47fb8427132/openinference_instrumentation-0.1.57.tar.gz", hash = "sha256:2bd00a1f95ebb8a47d11615c1039128101e4abc0e9722cc4331849afdeb248fc", size = 41183, upload-time = "2026-08-07T14:29:23.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/50/05db6ba8da9fcf1872597f52b66b3894a1e5b78c177a50239e30de008317/openinference_instrumentation-0.1.57-py3-none-any.whl", hash = "sha256:e06f436e93156f5e0cfedf3f34bde1a386f8e00dbff528155a506ce74c72af92", size = 48872, upload-time = "2026-08-07T14:29:21.632Z" }, +] + +[[package]] +name = "openinference-instrumentation-openai-agents" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openinference-instrumentation" }, + { name = "openinference-semantic-conventions" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/f1/8d97d6c859854f6d5546af805185a03376604d73d744e90b3308eaddd9bb/openinference_instrumentation_openai_agents-1.6.2.tar.gz", hash = "sha256:efffff2606e552b6cb8481c6e5738b8c104b6e6e72ead13773ee5b0bdc86aabd", size = 26586, upload-time = "2026-07-30T16:38:28.049Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/15/2f6f3890675c626eeac81ce7994f7762bb25c3a3a4a5b00e41e712c3fd47/openinference_instrumentation_openai_agents-1.6.2-py3-none-any.whl", hash = "sha256:0d9fcf4cb24dd22c57ed035f31c807135fbccc67347e11cfdc01b39e835d35d9", size = 28995, upload-time = "2026-07-30T16:38:26.879Z" }, +] + +[[package]] +name = "openinference-semantic-conventions" +version = "0.1.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/5e/13ffdb2ba436312cda9ed842ce3416fa5961e36a205df9a18a67eef7f9b3/openinference_semantic_conventions-0.1.32.tar.gz", hash = "sha256:2a3e6723ddce37abc5c43d38c6c7ac05b0f2efcfbe75cc0f135ab14ceb0730f7", size = 13980, upload-time = "2026-08-07T14:29:21.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/19/2d096333313d45e5f87d08f903cedc3f7298c5690d2e6c4ef236dc22870e/openinference_semantic_conventions-0.1.32-py3-none-any.whl", hash = "sha256:d06c4716faf7537fbabbb6d34e758ec6b3650a18c037661782fc8c3e9fa90f81", size = 11252, upload-time = "2026-08-07T14:29:19.78Z" }, +] + [[package]] name = "opentelemetry-api" version = "1.37.0" @@ -1176,6 +1217,7 @@ dependencies = [ { name = "boto3" }, { name = "langfuse" }, { name = "openai-agents", extra = ["litellm"] }, + { name = "openinference-instrumentation-openai-agents" }, { name = "polygon-api-client" }, { name = "pydantic" }, { name = "pydantic-ai" }, @@ -1187,8 +1229,9 @@ dependencies = [ requires-dist = [ { name = "alex-database", editable = "../database" }, { name = "boto3", specifier = ">=1.40.8" }, - { name = "langfuse", specifier = ">=3.3.4" }, + { name = "langfuse", specifier = ">=4" }, { name = "openai-agents", extras = ["litellm"], specifier = ">=0.3.0" }, + { name = "openinference-instrumentation-openai-agents", specifier = "<2.0.0" }, { name = "polygon-api-client", specifier = ">=1.15.3" }, { name = "pydantic", specifier = ">=2.11.7" }, { name = "pydantic-ai", specifier = ">=1.0.6" }, diff --git a/backend/pyproject.toml b/backend/pyproject.toml index db25a81d..fb89689e 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -5,8 +5,9 @@ requires-python = ">=3.12" dependencies = [ "alex-database", "boto3>=1.40.29", - "langfuse>=3.3.4", + "langfuse>=4", "openai-agents>=0.3.0", + "openinference-instrumentation-openai-agents<2.0.0", "pydantic-ai>=1.0.6", "python-dotenv>=1.1.1", ] diff --git a/backend/reporter/lambda_handler.py b/backend/reporter/lambda_handler.py index 2232d56f..73931e04 100644 --- a/backend/reporter/lambda_handler.py +++ b/backend/reporter/lambda_handler.py @@ -70,13 +70,21 @@ async def run_reporter_agent( response = result.final_output if observability: - with observability.start_as_current_span(name="judge") as span: + with observability.start_evaluator( + "evaluate-report", + input={"job_id": job_id}, + ) as evaluator: evaluation = await evaluate(REPORTER_INSTRUCTIONS, task, response) score = evaluation.score / 100 comment = evaluation.feedback - span.score(name="Judge", value=score, data_type="NUMERIC", comment=comment) - observation = f"Score: {score} - Feedback: {comment}" - observability.create_event(name="Judge Event", status_message=observation) + if evaluator is not None: + evaluator.update(output={"score": score, "feedback": comment}) + evaluator.score( + name="judge", + value=score, + data_type="NUMERIC", + comment=comment, + ) if score < GUARD_AGAINST_SCORE: logger.error(f"Reporter score is too low: {score}") response = "I'm sorry, I'm not able to generate a report for you. Please try again later." @@ -113,21 +121,33 @@ def lambda_handler(event, context): "user_data": {...} } """ - # Wrap entire handler with observability context - with observe() as observability: + if isinstance(event, str): + event = json.loads(event) + + logger.info(f"Reporter Lambda invoked with event: {json.dumps(event)[:500]}") + + job_id = event.get("job_id") + if not job_id: + return {"statusCode": 400, "body": json.dumps({"error": "job_id is required"})} + + db = Database() + user_id = None + try: + job = db.jobs.find_by_id(job_id) + if job: + user_id = job.get("clerk_user_id") + except Exception as e: + logger.warning(f"Reporter: Could not load job owner for tracing: {e}") + + with observe( + name="report-portfolio", + user_id=user_id, + session_id=job_id, + tags=["reporter", "portfolio-analysis"], + metadata={"agent": "reporter"}, + input={"job_id": job_id}, + ) as observability: try: - logger.info(f"Reporter Lambda invoked with event: {json.dumps(event)[:500]}") - - # Parse event - if isinstance(event, str): - event = json.loads(event) - - job_id = event.get("job_id") - if not job_id: - return {"statusCode": 400, "body": json.dumps({"error": "job_id is required"})} - - # Initialize database - db = Database() portfolio_data = event.get("portfolio_data") if not portfolio_data: @@ -136,11 +156,6 @@ def lambda_handler(event, context): job = db.jobs.find_by_id(job_id) if job: user_id = job["clerk_user_id"] - - if observability: - observability.create_event( - name="Reporter Started!", status_message="OK" - ) user = db.users.find_by_clerk_id(user_id) accounts = db.accounts.find_by_user(user_id) @@ -186,11 +201,6 @@ def lambda_handler(event, context): try: job = db.jobs.find_by_id(job_id) if job and job.get("clerk_user_id"): - status = f"Job ID: {job_id} Clerk User ID: {job['clerk_user_id']}" - if observability: - observability.create_event( - name="Reporter about to run", status_message=status - ) user = db.users.find_by_clerk_id(job["clerk_user_id"]) if user: user_data = { @@ -214,10 +224,11 @@ def lambda_handler(event, context): ) logger.info(f"Reporter completed for job {job_id}") - + observability.update(output={"status": "completed", "job_id": job_id}) return {"statusCode": 200, "body": json.dumps(result)} except Exception as e: + observability.update(output={"status": "failed", "error": str(e)}) logger.error(f"Error in reporter: {e}", exc_info=True) return {"statusCode": 500, "body": json.dumps({"success": False, "error": str(e)})} diff --git a/backend/reporter/observability.py b/backend/reporter/observability.py index 5709320c..aa62258a 100644 --- a/backend/reporter/observability.py +++ b/backend/reporter/observability.py @@ -1,112 +1,251 @@ """ -Observability module for LangFuse integration. -Provides a simple context manager for setting up and flushing traces. +Langfuse observability for Alex agents. + +Uses the official OpenInference instrumentation for the OpenAI Agents SDK +and Langfuse Python SDK v4 APIs (propagate_attributes, observation types). +Agents work normally when Langfuse credentials are not configured. """ -import os +from __future__ import annotations + import logging -from contextlib import contextmanager +import os +import re +from contextlib import contextmanager, nullcontext +from dataclasses import dataclass +from typing import Any, Iterator, Optional -# Use root logger for Lambda compatibility logger = logging.getLogger() logger.setLevel(logging.INFO) +_EMAIL_RE = re.compile(r"\b[\w.-]+@[\w.-]+\.\w+\b") +_PHONE_RE = re.compile(r"\b\d{3}[-. ]?\d{3}[-. ]?\d{4}\b") +_CARD_RE = re.compile(r"\b(?:\d[ -]*?){13,19}\b") -@contextmanager -def observe(): - """ - Context manager for observability with LangFuse. +_instrumented = False +_client: Any = None - Sets up LangFuse observability if environment variables are configured, - and ensures traces are flushed on exit. - Usage: - from observability import observe +def _is_deployed() -> bool: + return bool( + os.getenv("AWS_LAMBDA_FUNCTION_NAME") + or os.getenv("AWS_EXECUTION_ENV") + or os.getenv("AWS_APP_RUNNER_SERVICE_ID") + ) - with observe(): - # Your code that uses OpenAI Agents SDK - result = await agent.run(...) - """ - logger.info("šŸ” Observability: Checking configuration...") - # Check if required environment variables exist - has_langfuse = bool(os.getenv("LANGFUSE_SECRET_KEY")) - has_openai = bool(os.getenv("OPENAI_API_KEY")) +def _sync_host_env() -> Optional[str]: + """Langfuse accepts LANGFUSE_BASE_URL (current) or LANGFUSE_HOST (legacy).""" + host = os.getenv("LANGFUSE_BASE_URL") or os.getenv("LANGFUSE_HOST") + if host: + os.environ["LANGFUSE_BASE_URL"] = host + os.environ["LANGFUSE_HOST"] = host + return host - logger.info(f"šŸ” Observability: LANGFUSE_SECRET_KEY exists: {has_langfuse}") - logger.info(f"šŸ” Observability: OPENAI_API_KEY exists: {has_openai}") - if not has_langfuse: - logger.info("šŸ” Observability: LangFuse not configured, skipping setup") - yield None - return +def _default_environment() -> str: + return os.getenv("LANGFUSE_TRACING_ENVIRONMENT") or ( + "production" if _is_deployed() else "development" + ) - if not has_openai: - logger.warning("āš ļø Observability: OPENAI_API_KEY not set, traces may not export") - # Local variable for the client (no global needed) - langfuse_client = None +def _mask_text(value: str) -> str: + masked = _EMAIL_RE.sub("[REDACTED EMAIL]", value) + masked = _PHONE_RE.sub("[REDACTED PHONE]", masked) + masked = _CARD_RE.sub("[REDACTED CARD]", masked) + return masked - # Try to set up LangFuse - try: - logger.info("šŸ” Observability: Setting up LangFuse...") - import logfire - from langfuse import get_client +def _mask_data(*, data: Any, **kwargs: Any) -> Any: + if isinstance(data, str): + return _mask_text(data) + if isinstance(data, dict): + return {key: _mask_data(data=value) for key, value in data.items()} + if isinstance(data, list): + return [_mask_data(data=item) for item in data] + return data - # Configure logfire to instrument OpenAI Agents SDK - logfire.configure( - service_name="alex_reporter_agent", - send_to_logfire=False, # Don't send to Logfire cloud + +def _stringify_metadata(metadata: Optional[dict[str, Any]]) -> dict[str, str]: + if not metadata: + return {} + result: dict[str, str] = {} + for key, value in metadata.items(): + text = str(value) + if len(text) > 200: + text = text[:197] + "..." + result[str(key)] = text + return result + + +def _mask_otel_spans(*, params: Any) -> Any: + try: + from langfuse.types import MaskOtelSpansResult, OtelSpanPatch + except ImportError: + return None + + patches = {} + for identifier, span in params.spans.items(): + replacements = {} + for key, value in span.attributes.items(): + if isinstance(value, str): + masked = _mask_text(value) + if masked != value: + replacements[key] = masked + if replacements: + patches[identifier] = OtelSpanPatch(set_attributes=replacements) + return MaskOtelSpansResult(span_patches=patches) if patches else None + + +@dataclass +class Observability: + """Handle yielded by observe(). Falsy when Langfuse is not configured.""" + + client: Any = None + observation: Any = None + + def __bool__(self) -> bool: + return self.client is not None + + def update(self, **kwargs: Any) -> None: + if self.observation is not None: + self.observation.update(**kwargs) + + def start_evaluator(self, name: str, **kwargs: Any): + if self.client is None: + return nullcontext() + return self.client.start_as_current_observation( + as_type="evaluator", name=name, **kwargs ) - logger.info("āœ… Observability: Logfire configured") - # Instrument OpenAI Agents SDK - logfire.instrument_openai_agents() - logger.info("āœ… Observability: OpenAI Agents SDK instrumented") - # Initialize LangFuse client - langfuse_client = get_client() - logger.info("āœ… Observability: LangFuse client initialized") +def setup_instrumentation() -> Any: + """Idempotent Langfuse + OpenAI Agents SDK instrumentation.""" + global _instrumented, _client + + if _instrumented: + return _client + + logger.info("Observability: Checking Langfuse configuration...") + if not os.getenv("LANGFUSE_SECRET_KEY") or not os.getenv("LANGFUSE_PUBLIC_KEY"): + logger.info("Observability: Langfuse not configured, skipping setup") + _instrumented = True + _client = None + return None + + _sync_host_env() + os.environ.setdefault("LANGFUSE_TRACING_ENVIRONMENT", _default_environment()) + + try: + from langfuse import Langfuse + from openinference.instrumentation.openai_agents import OpenAIAgentsInstrumentor - # Optional: Check authentication (blocking call, use sparingly) try: - auth_result = langfuse_client.auth_check() - logger.info( - f"āœ… Observability: LangFuse authentication check passed (result: {auth_result})" - ) - except Exception as auth_error: - logger.warning(f"āš ļø Observability: Auth check failed but continuing: {auth_error}") + _client = Langfuse(mask=_mask_data, mask_otel_spans=_mask_otel_spans) + except TypeError: + _client = Langfuse(mask=_mask_data) + OpenAIAgentsInstrumentor().instrument() + logger.info( + "Observability: Langfuse client ready (environment=%s)", + os.environ.get("LANGFUSE_TRACING_ENVIRONMENT"), + ) - logger.info("šŸŽÆ Observability: Setup complete - traces will be sent to LangFuse") + try: + if _client.auth_check(): + logger.info("Observability: Langfuse authentication succeeded") + else: + logger.warning("Observability: Langfuse auth check returned false") + except Exception as auth_error: + logger.warning("Observability: Auth check failed but continuing: %s", auth_error) except ImportError as e: - logger.error(f"āŒ Observability: Missing required package: {e}") - langfuse_client = None + logger.error("Observability: Missing required package: %s", e) + _client = None except Exception as e: - logger.error(f"āŒ Observability: Setup failed: {e}") - langfuse_client = None + logger.error("Observability: Setup failed: %s", e) + _client = None + + _instrumented = True + return _client + + +@contextmanager +def observe( + *, + name: str = "run-agent", + user_id: Optional[str] = None, + session_id: Optional[str] = None, + tags: Optional[list[str]] = None, + metadata: Optional[dict[str, Any]] = None, + input: Any = None, +) -> Iterator[Observability]: + """ + Trace one agent run. Groups related agents with session_id=job_id. + + Usage: + with observe(name="plan-portfolio", session_id=job_id, user_id=user_id) as obs: + result = asyncio.run(run_orchestrator(job_id)) + obs.update(output={"status": "completed"}) + """ + from langfuse import propagate_attributes + client = setup_instrumentation() + if client is None: + yield Observability() + return + + handle = Observability(client=client) try: - # Yield control back to the calling code - yield langfuse_client + with client.start_as_current_observation( + as_type="span", + name=name, + input=input, + ) as root: + handle.observation = root + attr_kwargs: dict[str, Any] = { + "trace_name": name, + "tags": tags or [], + "metadata": _stringify_metadata(metadata), + "version": os.getenv("LANGFUSE_RELEASE", "1.0.0"), + } + if user_id: + attr_kwargs["user_id"] = user_id + if session_id: + attr_kwargs["session_id"] = session_id + with propagate_attributes(**attr_kwargs): + yield handle finally: - # Flush traces on exit - if langfuse_client: - try: - logger.info("šŸ” Observability: Flushing traces to LangFuse...") - langfuse_client.flush() - langfuse_client.shutdown() - - # Add a 10 second delay to ensure network requests complete - # This is a workaround for Lambda's immediate termination - import time - - logger.info("šŸ” Observability: Waiting 10 seconds for flush to complete...") - time.sleep(10) - - logger.info("āœ… Observability: Traces flushed successfully") - except Exception as e: - logger.error(f"āŒ Observability: Failed to flush traces: {e}") - else: - logger.debug("šŸ” Observability: No client to flush") + try: + logger.info("Observability: Flushing traces to Langfuse...") + client.flush() + logger.info("Observability: Traces flushed successfully") + except Exception as e: + logger.error("Observability: Failed to flush traces: %s", e) + + +@contextmanager +def observation( + name: str, + *, + as_type: str = "span", + input: Any = None, + output: Any = None, +) -> Iterator[Any]: + """Nested observation that no-ops when Langfuse is not configured.""" + client = _client or setup_instrumentation() + if client is None: + yield None + return + + with client.start_as_current_observation( + as_type=as_type, + name=name, + input=input, + ) as obs: + try: + yield obs + if output is not None: + obs.update(output=output) + except Exception as e: + obs.update(output={"error": str(e)}) + raise diff --git a/backend/reporter/pyproject.toml b/backend/reporter/pyproject.toml index 8ebea5ca..7430063a 100644 --- a/backend/reporter/pyproject.toml +++ b/backend/reporter/pyproject.toml @@ -5,8 +5,9 @@ requires-python = ">=3.12" dependencies = [ "alex-database", "boto3>=1.40.9", - "langfuse>=3.3.4", + "langfuse>=4", "openai-agents[litellm]>=0.2.6", + "openinference-instrumentation-openai-agents<2.0.0", "pydantic>=2.11.7", "pydantic-ai>=1.0.6", "python-dotenv>=1.1.1", diff --git a/backend/reporter/uv.lock b/backend/reporter/uv.lock index 7becf733..c0821671 100644 --- a/backend/reporter/uv.lock +++ b/backend/reporter/uv.lock @@ -731,7 +731,7 @@ wheels = [ [[package]] name = "langfuse" -version = "3.3.4" +version = "4.14.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff" }, @@ -741,12 +741,11 @@ dependencies = [ { name = "opentelemetry-sdk" }, { name = "packaging" }, { name = "pydantic" }, - { name = "requests" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/27/9d/c9c422c660459687293727c9146185f92443b6c72fda564ab10e9e16ab41/langfuse-3.3.4.tar.gz", hash = "sha256:e5df4e7284298990b522e02a1dc6c3c72ebc4a7a411dc7d39255fb8c2e5a7c3a", size = 164745, upload-time = "2025-09-02T15:02:39.16Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/3f/d3387db1f472d2fd4b5de40eb6abedc2f59c9da2224e9d0a79d054a90b81/langfuse-4.14.4.tar.gz", hash = "sha256:48c4bdefc290f61a42881b8048a904ab6b81d37e02496473166836e71ee0ab3a", size = 389680, upload-time = "2026-08-11T17:03:20.472Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/46/edd370d47ca72ed4ca36b3c3b8f3a4ce71a310629d0bcb6ed760b04b08b1/langfuse-3.3.4-py3-none-any.whl", hash = "sha256:15b9d20878cf39a48ca9cfa7e52acdfeb043603d3a9cef8cf451687a4d838c6b", size = 318389, upload-time = "2025-09-02T15:02:37.171Z" }, + { url = "https://files.pythonhosted.org/packages/d9/1b/a198adc52aa4ffd0886ca0d4e9bb97d45f4f326db206d2395bac67677f5d/langfuse-4.14.4-py3-none-any.whl", hash = "sha256:78692a1d4785a8c255bf6ea967e673e1ee30ce078d646e7b5f7ff44549d45cce", size = 688351, upload-time = "2026-08-11T17:03:21.881Z" }, ] [[package]] @@ -1019,6 +1018,48 @@ litellm = [ { name = "litellm" }, ] +[[package]] +name = "openinference-instrumentation" +version = "0.1.57" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openinference-semantic-conventions" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/d1/08bbd17cb66c1d7749802a30a551c132fa0ecdb1169bd13cf47fb8427132/openinference_instrumentation-0.1.57.tar.gz", hash = "sha256:2bd00a1f95ebb8a47d11615c1039128101e4abc0e9722cc4331849afdeb248fc", size = 41183, upload-time = "2026-08-07T14:29:23.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/50/05db6ba8da9fcf1872597f52b66b3894a1e5b78c177a50239e30de008317/openinference_instrumentation-0.1.57-py3-none-any.whl", hash = "sha256:e06f436e93156f5e0cfedf3f34bde1a386f8e00dbff528155a506ce74c72af92", size = 48872, upload-time = "2026-08-07T14:29:21.632Z" }, +] + +[[package]] +name = "openinference-instrumentation-openai-agents" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openinference-instrumentation" }, + { name = "openinference-semantic-conventions" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/f1/8d97d6c859854f6d5546af805185a03376604d73d744e90b3308eaddd9bb/openinference_instrumentation_openai_agents-1.6.2.tar.gz", hash = "sha256:efffff2606e552b6cb8481c6e5738b8c104b6e6e72ead13773ee5b0bdc86aabd", size = 26586, upload-time = "2026-07-30T16:38:28.049Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/15/2f6f3890675c626eeac81ce7994f7762bb25c3a3a4a5b00e41e712c3fd47/openinference_instrumentation_openai_agents-1.6.2-py3-none-any.whl", hash = "sha256:0d9fcf4cb24dd22c57ed035f31c807135fbccc67347e11cfdc01b39e835d35d9", size = 28995, upload-time = "2026-07-30T16:38:26.879Z" }, +] + +[[package]] +name = "openinference-semantic-conventions" +version = "0.1.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/5e/13ffdb2ba436312cda9ed842ce3416fa5961e36a205df9a18a67eef7f9b3/openinference_semantic_conventions-0.1.32.tar.gz", hash = "sha256:2a3e6723ddce37abc5c43d38c6c7ac05b0f2efcfbe75cc0f135ab14ceb0730f7", size = 13980, upload-time = "2026-08-07T14:29:21.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/19/2d096333313d45e5f87d08f903cedc3f7298c5690d2e6c4ef236dc22870e/openinference_semantic_conventions-0.1.32-py3-none-any.whl", hash = "sha256:d06c4716faf7537fbabbb6d34e758ec6b3650a18c037661782fc8c3e9fa90f81", size = 11252, upload-time = "2026-08-07T14:29:19.78Z" }, +] + [[package]] name = "opentelemetry-api" version = "1.37.0" @@ -1602,6 +1643,7 @@ dependencies = [ { name = "boto3" }, { name = "langfuse" }, { name = "openai-agents", extra = ["litellm"] }, + { name = "openinference-instrumentation-openai-agents" }, { name = "pydantic" }, { name = "pydantic-ai" }, { name = "python-dotenv" }, @@ -1612,8 +1654,9 @@ dependencies = [ requires-dist = [ { name = "alex-database", editable = "../database" }, { name = "boto3", specifier = ">=1.40.9" }, - { name = "langfuse", specifier = ">=3.3.4" }, + { name = "langfuse", specifier = ">=4" }, { name = "openai-agents", extras = ["litellm"], specifier = ">=0.2.6" }, + { name = "openinference-instrumentation-openai-agents", specifier = "<2.0.0" }, { name = "pydantic", specifier = ">=2.11.7" }, { name = "pydantic-ai", specifier = ">=1.0.6" }, { name = "python-dotenv", specifier = ">=1.1.1" }, diff --git a/backend/researcher/observability.py b/backend/researcher/observability.py new file mode 100644 index 00000000..aa62258a --- /dev/null +++ b/backend/researcher/observability.py @@ -0,0 +1,251 @@ +""" +Langfuse observability for Alex agents. + +Uses the official OpenInference instrumentation for the OpenAI Agents SDK +and Langfuse Python SDK v4 APIs (propagate_attributes, observation types). +Agents work normally when Langfuse credentials are not configured. +""" + +from __future__ import annotations + +import logging +import os +import re +from contextlib import contextmanager, nullcontext +from dataclasses import dataclass +from typing import Any, Iterator, Optional + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +_EMAIL_RE = re.compile(r"\b[\w.-]+@[\w.-]+\.\w+\b") +_PHONE_RE = re.compile(r"\b\d{3}[-. ]?\d{3}[-. ]?\d{4}\b") +_CARD_RE = re.compile(r"\b(?:\d[ -]*?){13,19}\b") + +_instrumented = False +_client: Any = None + + +def _is_deployed() -> bool: + return bool( + os.getenv("AWS_LAMBDA_FUNCTION_NAME") + or os.getenv("AWS_EXECUTION_ENV") + or os.getenv("AWS_APP_RUNNER_SERVICE_ID") + ) + + +def _sync_host_env() -> Optional[str]: + """Langfuse accepts LANGFUSE_BASE_URL (current) or LANGFUSE_HOST (legacy).""" + host = os.getenv("LANGFUSE_BASE_URL") or os.getenv("LANGFUSE_HOST") + if host: + os.environ["LANGFUSE_BASE_URL"] = host + os.environ["LANGFUSE_HOST"] = host + return host + + +def _default_environment() -> str: + return os.getenv("LANGFUSE_TRACING_ENVIRONMENT") or ( + "production" if _is_deployed() else "development" + ) + + +def _mask_text(value: str) -> str: + masked = _EMAIL_RE.sub("[REDACTED EMAIL]", value) + masked = _PHONE_RE.sub("[REDACTED PHONE]", masked) + masked = _CARD_RE.sub("[REDACTED CARD]", masked) + return masked + + +def _mask_data(*, data: Any, **kwargs: Any) -> Any: + if isinstance(data, str): + return _mask_text(data) + if isinstance(data, dict): + return {key: _mask_data(data=value) for key, value in data.items()} + if isinstance(data, list): + return [_mask_data(data=item) for item in data] + return data + + +def _stringify_metadata(metadata: Optional[dict[str, Any]]) -> dict[str, str]: + if not metadata: + return {} + result: dict[str, str] = {} + for key, value in metadata.items(): + text = str(value) + if len(text) > 200: + text = text[:197] + "..." + result[str(key)] = text + return result + + +def _mask_otel_spans(*, params: Any) -> Any: + try: + from langfuse.types import MaskOtelSpansResult, OtelSpanPatch + except ImportError: + return None + + patches = {} + for identifier, span in params.spans.items(): + replacements = {} + for key, value in span.attributes.items(): + if isinstance(value, str): + masked = _mask_text(value) + if masked != value: + replacements[key] = masked + if replacements: + patches[identifier] = OtelSpanPatch(set_attributes=replacements) + return MaskOtelSpansResult(span_patches=patches) if patches else None + + +@dataclass +class Observability: + """Handle yielded by observe(). Falsy when Langfuse is not configured.""" + + client: Any = None + observation: Any = None + + def __bool__(self) -> bool: + return self.client is not None + + def update(self, **kwargs: Any) -> None: + if self.observation is not None: + self.observation.update(**kwargs) + + def start_evaluator(self, name: str, **kwargs: Any): + if self.client is None: + return nullcontext() + return self.client.start_as_current_observation( + as_type="evaluator", name=name, **kwargs + ) + + +def setup_instrumentation() -> Any: + """Idempotent Langfuse + OpenAI Agents SDK instrumentation.""" + global _instrumented, _client + + if _instrumented: + return _client + + logger.info("Observability: Checking Langfuse configuration...") + if not os.getenv("LANGFUSE_SECRET_KEY") or not os.getenv("LANGFUSE_PUBLIC_KEY"): + logger.info("Observability: Langfuse not configured, skipping setup") + _instrumented = True + _client = None + return None + + _sync_host_env() + os.environ.setdefault("LANGFUSE_TRACING_ENVIRONMENT", _default_environment()) + + try: + from langfuse import Langfuse + from openinference.instrumentation.openai_agents import OpenAIAgentsInstrumentor + + try: + _client = Langfuse(mask=_mask_data, mask_otel_spans=_mask_otel_spans) + except TypeError: + _client = Langfuse(mask=_mask_data) + OpenAIAgentsInstrumentor().instrument() + logger.info( + "Observability: Langfuse client ready (environment=%s)", + os.environ.get("LANGFUSE_TRACING_ENVIRONMENT"), + ) + + try: + if _client.auth_check(): + logger.info("Observability: Langfuse authentication succeeded") + else: + logger.warning("Observability: Langfuse auth check returned false") + except Exception as auth_error: + logger.warning("Observability: Auth check failed but continuing: %s", auth_error) + + except ImportError as e: + logger.error("Observability: Missing required package: %s", e) + _client = None + except Exception as e: + logger.error("Observability: Setup failed: %s", e) + _client = None + + _instrumented = True + return _client + + +@contextmanager +def observe( + *, + name: str = "run-agent", + user_id: Optional[str] = None, + session_id: Optional[str] = None, + tags: Optional[list[str]] = None, + metadata: Optional[dict[str, Any]] = None, + input: Any = None, +) -> Iterator[Observability]: + """ + Trace one agent run. Groups related agents with session_id=job_id. + + Usage: + with observe(name="plan-portfolio", session_id=job_id, user_id=user_id) as obs: + result = asyncio.run(run_orchestrator(job_id)) + obs.update(output={"status": "completed"}) + """ + from langfuse import propagate_attributes + + client = setup_instrumentation() + if client is None: + yield Observability() + return + + handle = Observability(client=client) + try: + with client.start_as_current_observation( + as_type="span", + name=name, + input=input, + ) as root: + handle.observation = root + attr_kwargs: dict[str, Any] = { + "trace_name": name, + "tags": tags or [], + "metadata": _stringify_metadata(metadata), + "version": os.getenv("LANGFUSE_RELEASE", "1.0.0"), + } + if user_id: + attr_kwargs["user_id"] = user_id + if session_id: + attr_kwargs["session_id"] = session_id + with propagate_attributes(**attr_kwargs): + yield handle + finally: + try: + logger.info("Observability: Flushing traces to Langfuse...") + client.flush() + logger.info("Observability: Traces flushed successfully") + except Exception as e: + logger.error("Observability: Failed to flush traces: %s", e) + + +@contextmanager +def observation( + name: str, + *, + as_type: str = "span", + input: Any = None, + output: Any = None, +) -> Iterator[Any]: + """Nested observation that no-ops when Langfuse is not configured.""" + client = _client or setup_instrumentation() + if client is None: + yield None + return + + with client.start_as_current_observation( + as_type=as_type, + name=name, + input=input, + ) as obs: + try: + yield obs + if output is not None: + obs.update(output=output) + except Exception as e: + obs.update(output={"error": str(e)}) + raise diff --git a/backend/researcher/pyproject.toml b/backend/researcher/pyproject.toml index 066adf63..0320b8b1 100644 --- a/backend/researcher/pyproject.toml +++ b/backend/researcher/pyproject.toml @@ -8,7 +8,9 @@ dependencies = [ "boto3>=1.40.6", "fastapi>=0.116.1", "httpx>=0.28.1", + "langfuse>=4", "openai-agents[litellm]>=0.2.4", + "openinference-instrumentation-openai-agents<2.0.0", "playwright>=1.54.0", "pydantic>=2.11.7", "python-dotenv>=1.1.1", diff --git a/backend/researcher/server.py b/backend/researcher/server.py index d87d62bd..4e018af9 100644 --- a/backend/researcher/server.py +++ b/backend/researcher/server.py @@ -12,6 +12,7 @@ from dotenv import load_dotenv from agents import Agent, RunHooks, Runner, trace from agents.extensions.models.litellm_model import LitellmModel +from observability import observe, setup_instrumentation # Suppress LiteLLM warnings about optional dependencies logging.basicConfig(level=logging.INFO) @@ -23,8 +24,9 @@ from mcp_servers import create_playwright_mcp_server from tools import ingest_financial_document -# Load environment +# Load environment before Langfuse initializes load_dotenv(override=True) +setup_instrumentation() app = FastAPI(title="Alex Researcher Service") @@ -72,6 +74,24 @@ async def run_research_agent(topic: str = None) -> str: else: query = DEFAULT_RESEARCH_PROMPT + with observe( + name="research-market", + tags=["researcher", "market-research"], + metadata={"agent": "researcher"}, + input={"topic": topic or "agent-selected"}, + ) as obs: + try: + result = await _run_research_agent(topic, query) + obs.update(output={"status": "completed", "topic": topic or "agent-selected"}) + return result + except Exception as e: + obs.update(output={"status": "failed", "error": str(e)}) + raise + + +async def _run_research_agent(topic: Optional[str], query: str) -> str: + """Inner research run, traced by run_research_agent().""" + if MCP_LOGGING_ENABLED: logger.info( "Starting research agent topic_provided=%s query_preview=%s", diff --git a/backend/researcher/test_langfuse_smoke.py b/backend/researcher/test_langfuse_smoke.py new file mode 100644 index 00000000..74fd7579 --- /dev/null +++ b/backend/researcher/test_langfuse_smoke.py @@ -0,0 +1,26 @@ +"""Send a smoke-test trace so we can audit Langfuse instrumentation.""" + +from pathlib import Path + +from dotenv import load_dotenv + +load_dotenv(Path(__file__).resolve().parents[2] / ".env", override=True) + +from observability import observe # noqa: E402 + + +def main() -> None: + with observe( + name="plan-portfolio", + user_id="smoke-test-user", + session_id="smoke-test-session", + tags=["planner", "portfolio-analysis", "smoke-test"], + metadata={"agent": "planner", "source": "smoke-test"}, + input={"job_id": "smoke-test-job"}, + ) as obs: + obs.update(output={"status": "completed", "job_id": "smoke-test-job"}) + print("Smoke trace flushed. Check Langfuse for name=plan-portfolio") + + +if __name__ == "__main__": + main() diff --git a/backend/researcher/uv.lock b/backend/researcher/uv.lock index 12b97eb2..316d7652 100644 --- a/backend/researcher/uv.lock +++ b/backend/researcher/uv.lock @@ -107,6 +107,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, ] +[[package]] +name = "backoff" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, +] + [[package]] name = "boto3" version = "1.40.25" @@ -324,6 +333,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/47/71/70db47e4f6ce3e5c37a607355f80da8860a33226be640226ac52cb05ef2e/fsspec-2025.9.0-py3-none-any.whl", hash = "sha256:530dc2a2af60a414a832059574df4a6e10cce927f6f4a78209390fe38955cfb7", size = 199289, upload-time = "2025-09-02T19:10:47.708Z" }, ] +[[package]] +name = "googleapis-common-protos" +version = "1.75.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/73/74bcab964c9a7a61f2bb71e8179b0f13e6fa98f7ce00fd168aab291e4a2e/googleapis_common_protos-1.75.1.tar.gz", hash = "sha256:d3042c6c5a2d4e67113104d6b6818b59b6bd92a197f2a91508e801fe815cf071", size = 150967, upload-time = "2026-08-06T06:24:51.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/51/186c02b8549b69ccda44429cf6ff5081e4b61a602ddfe6a8020d1be31d1b/googleapis_common_protos-1.75.1-py3-none-any.whl", hash = "sha256:28a1934bcd33b9c9da66ac301a0a4227e3367f095a17d0375cb98f0a09d93b79", size = 300626, upload-time = "2026-08-06T06:23:46.696Z" }, +] + [[package]] name = "greenlet" version = "3.2.4" @@ -338,6 +359,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, { url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" }, + { url = "https://files.pythonhosted.org/packages/27/45/80935968b53cfd3f33cf99ea5f08227f2646e044568c9b1555b58ffd61c2/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0", size = 1564846, upload-time = "2025-11-04T12:42:15.191Z" }, + { url = "https://files.pythonhosted.org/packages/69/02/b7c30e5e04752cb4db6202a3858b149c0710e5453b71a3b2aec5d78a1aab/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d", size = 1633814, upload-time = "2025-11-04T12:42:17.175Z" }, { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" }, { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, @@ -347,6 +370,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" }, + { url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" }, { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" }, { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, @@ -354,6 +379,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" }, + { url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" }, { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, ] @@ -566,6 +593,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/0e/b27cdbaccf30b890c40ed1da9fd4a3593a5cf94dae54fb34f8a4b74fcd3f/jsonschema_specifications-2025.4.1-py3-none-any.whl", hash = "sha256:4653bffbd6584f7de83a67e0d620ef16900b390ddc7939d56684d6c81e33f1af", size = 18437, upload-time = "2025-04-23T12:34:05.422Z" }, ] +[[package]] +name = "langfuse" +version = "4.14.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backoff" }, + { name = "httpx" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/3f/d3387db1f472d2fd4b5de40eb6abedc2f59c9da2224e9d0a79d054a90b81/langfuse-4.14.4.tar.gz", hash = "sha256:48c4bdefc290f61a42881b8048a904ab6b81d37e02496473166836e71ee0ab3a", size = 389680, upload-time = "2026-08-11T17:03:20.472Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/1b/a198adc52aa4ffd0886ca0d4e9bb97d45f4f326db206d2395bac67677f5d/langfuse-4.14.4-py3-none-any.whl", hash = "sha256:78692a1d4785a8c255bf6ea967e673e1ee30ce078d646e7b5f7ff44549d45cce", size = 688351, upload-time = "2026-08-11T17:03:21.881Z" }, +] + [[package]] name = "litellm" version = "1.76.2" @@ -754,6 +800,144 @@ litellm = [ { name = "litellm" }, ] +[[package]] +name = "openinference-instrumentation" +version = "0.1.57" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openinference-semantic-conventions" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/d1/08bbd17cb66c1d7749802a30a551c132fa0ecdb1169bd13cf47fb8427132/openinference_instrumentation-0.1.57.tar.gz", hash = "sha256:2bd00a1f95ebb8a47d11615c1039128101e4abc0e9722cc4331849afdeb248fc", size = 41183, upload-time = "2026-08-07T14:29:23.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/50/05db6ba8da9fcf1872597f52b66b3894a1e5b78c177a50239e30de008317/openinference_instrumentation-0.1.57-py3-none-any.whl", hash = "sha256:e06f436e93156f5e0cfedf3f34bde1a386f8e00dbff528155a506ce74c72af92", size = 48872, upload-time = "2026-08-07T14:29:21.632Z" }, +] + +[[package]] +name = "openinference-instrumentation-openai-agents" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openinference-instrumentation" }, + { name = "openinference-semantic-conventions" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/f1/8d97d6c859854f6d5546af805185a03376604d73d744e90b3308eaddd9bb/openinference_instrumentation_openai_agents-1.6.2.tar.gz", hash = "sha256:efffff2606e552b6cb8481c6e5738b8c104b6e6e72ead13773ee5b0bdc86aabd", size = 26586, upload-time = "2026-07-30T16:38:28.049Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/15/2f6f3890675c626eeac81ce7994f7762bb25c3a3a4a5b00e41e712c3fd47/openinference_instrumentation_openai_agents-1.6.2-py3-none-any.whl", hash = "sha256:0d9fcf4cb24dd22c57ed035f31c807135fbccc67347e11cfdc01b39e835d35d9", size = 28995, upload-time = "2026-07-30T16:38:26.879Z" }, +] + +[[package]] +name = "openinference-semantic-conventions" +version = "0.1.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/5e/13ffdb2ba436312cda9ed842ce3416fa5961e36a205df9a18a67eef7f9b3/openinference_semantic_conventions-0.1.32.tar.gz", hash = "sha256:2a3e6723ddce37abc5c43d38c6c7ac05b0f2efcfbe75cc0f135ab14ceb0730f7", size = 13980, upload-time = "2026-08-07T14:29:21.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/19/2d096333313d45e5f87d08f903cedc3f7298c5690d2e6c4ef236dc22870e/openinference_semantic_conventions-0.1.32-py3-none-any.whl", hash = "sha256:d06c4716faf7537fbabbb6d34e758ec6b3650a18c037661782fc8c3e9fa90f81", size = 11252, upload-time = "2026-08-07T14:29:19.78Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/09/4d717852c1cf3f854b76c7110a5d00883bc3c99288b9b0dbcbeb9e306eb6/opentelemetry_exporter_otlp_proto_common-1.44.0.tar.gz", hash = "sha256:dc87a5a5bc58f149a56d1547e4691588fa12994cdc3bc039a694ccb3375862ac", size = 20202, upload-time = "2026-07-16T15:25:37.658Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/71/65fd9d54c10b860f87c045ccee1264cab7011268895d3528818a29c1172a/opentelemetry_exporter_otlp_proto_common-1.44.0-py3-none-any.whl", hash = "sha256:9a9fe61bba73d802904bc989f1d6b4a7b1ee40f06c40e98d6f85af65aaebb694", size = 17045, upload-time = "2026-07-16T15:25:18.201Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/87/95e2a5aaa795b4e2260d74e16df2d5541deb2ea9de010bcd615f4dee2654/opentelemetry_exporter_otlp_proto_http-1.44.0.tar.gz", hash = "sha256:c633d7270ad6b57cd4cfbe8b0007a9e2e7c0cb50bd6c50fe2a7b245f721a09d8", size = 25806, upload-time = "2026-07-16T15:25:39.162Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/d0/fdeb1a98d8d3a6205f5f297c51b4a9bfe65126ab60339669bbe3dd54c2e2/opentelemetry_exporter_otlp_proto_http-1.44.0-py3-none-any.whl", hash = "sha256:838592fce774c1c8bb7b9a0a7facbfa82e17be5a8a4e94cef10cb84ae026bae3", size = 21850, upload-time = "2026-07-16T15:25:20.006Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.65b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/91/3c58961cb0360cd60509064734f0be4275383c8681d73c580a40ca83ddce/opentelemetry_instrumentation-0.65b0.tar.gz", hash = "sha256:071d9d9eced9bd6460444ec3b0c77229870ed05a881c22c84fdede58e4eed09b", size = 42689, upload-time = "2026-07-16T15:25:50.275Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/7b/85eab1215f72adf0e68d3dc4a679b9bff993fa679ff34cd8dd378e2659fd/opentelemetry_instrumentation-0.65b0-py3-none-any.whl", hash = "sha256:ea967a72b9939b5fcfdad572753b4306c59dcb99e3f382d95dae04286805e137", size = 36717, upload-time = "2026-07-16T15:24:51.424Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/01/40ac4ae9a149263cc52c2cee200ddd80cb6d8db1a4610abf8eabce0fe771/opentelemetry_proto-1.44.0.tar.gz", hash = "sha256:c547a79c2f8c0c515d31509154682e5921c7cfd5ca67b70e1f9266e2c3e103f3", size = 46488, upload-time = "2026-07-16T15:25:45.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/7c/8be563d68e93bbefa5c8affb82ddcff91b3ad858ce49957ba7b16fd3e0ab/opentelemetry_proto-1.44.0-py3-none-any.whl", hash = "sha256:898b155a0e1557afd867478fb6158e8122a46329ca0bb8dc53cc55e98f017f56", size = 72483, upload-time = "2026-07-16T15:25:28.429Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/77/a6592cbc7c8d9bcc9d6757a9df45e04a7c585e3e6e7a13456da522b21109/opentelemetry_sdk-1.44.0.tar.gz", hash = "sha256:cebe7f65dc12f26ead75c6064de12fd2a9052e5060c0272d402cfa203aae123b", size = 208624, upload-time = "2026-07-16T15:25:46.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/23/ff077e61886ee020a17ce9c8b6fa11c601c8d8345b09ea24f605445df62a/opentelemetry_sdk-1.44.0-py3-none-any.whl", hash = "sha256:df081c4c6bcfdb1211e3e86140376792643128a25f8d72d1d27675936e7e96ad", size = 137221, upload-time = "2026-07-16T15:25:29.534Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.65b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/73/0cbdebcb4cf545fdd328da14f5137e37d0770c3f26185e478b0d15d94f50/opentelemetry_semantic_conventions-0.65b0.tar.gz", hash = "sha256:f9b2b81e9d5b64f11bc952075e7e9c7fb0aab075c7fd1c46d597f1b919852d60", size = 148774, upload-time = "2026-07-16T15:25:46.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/0e/49df70d9b81fb5cbae4bbf2a49d865b09bcbcbc4eb53f5851b1027738d78/opentelemetry_semantic_conventions-0.65b0-py3-none-any.whl", hash = "sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb", size = 204645, upload-time = "2026-07-16T15:25:30.688Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -839,6 +1023,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, ] +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + [[package]] name = "pydantic" version = "2.11.7" @@ -1081,7 +1280,9 @@ dependencies = [ { name = "boto3" }, { name = "fastapi" }, { name = "httpx" }, + { name = "langfuse" }, { name = "openai-agents", extra = ["litellm"] }, + { name = "openinference-instrumentation-openai-agents" }, { name = "playwright" }, { name = "pydantic" }, { name = "python-dotenv" }, @@ -1095,7 +1296,9 @@ requires-dist = [ { name = "boto3", specifier = ">=1.40.6" }, { name = "fastapi", specifier = ">=0.116.1" }, { name = "httpx", specifier = ">=0.28.1" }, + { name = "langfuse", specifier = ">=4" }, { name = "openai-agents", extras = ["litellm"], specifier = ">=0.2.4" }, + { name = "openinference-instrumentation-openai-agents", specifier = "<2.0.0" }, { name = "playwright", specifier = ">=1.54.0" }, { name = "pydantic", specifier = ">=2.11.7" }, { name = "python-dotenv", specifier = ">=1.1.1" }, @@ -1365,6 +1568,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" }, ] +[[package]] +name = "wrapt" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/b0/c1f5a970721f06b85c0cd5142e0ff8fe067708abd779b0c4f4be7d61d09f/wrapt-2.3.0.tar.gz", hash = "sha256:681a2d0eefd721998f90642762b8e75c2159ec531b20ad5e437245ea7b06a107", size = 131509, upload-time = "2026-07-28T06:06:14.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/4a/d17a0fad1bf1c5f2c887ff71fef75654141b0880bff71d157d955b5bec3a/wrapt-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0a45ffae742ce91a16e11cb6c7cd71e7f9994f3cbd283b962ab093f5c6dcf525", size = 82139, upload-time = "2026-07-28T06:04:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/6e/55/51b92daaf6defb57f4dc56bdcce985400f75c6984a03ca5e78ccac717028/wrapt-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:69e477046f2237ef0bc6547544ee73008dc764ca26eff44f09e976d221b34d5d", size = 82723, upload-time = "2026-07-28T06:04:36.502Z" }, + { url = "https://files.pythonhosted.org/packages/28/7f/cfd9bc4b1f5e424eeea83d0493e43f3b1b02707ce8e50c47945873982bd5/wrapt-2.3.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d221a6e6ddd302b8397433184e96b59f259f50024b854db1c411a881586b6b8", size = 172381, upload-time = "2026-07-28T06:04:37.674Z" }, + { url = "https://files.pythonhosted.org/packages/cb/89/ff7814f6eb6856b479946117d1138a2fbb46cdb6b1f379db359056c69743/wrapt-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:392158c9a7f2ab1b8699418bfc0fe6f83548788c418b27d7bf2019ad3405cebb", size = 174120, upload-time = "2026-07-28T06:04:38.987Z" }, + { url = "https://files.pythonhosted.org/packages/12/1e/8eded8615d39e3ce81f626937a3a87b280a2a86239a2bf14a4b4bb345034/wrapt-2.3.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e5301c35cf75655eb33498f2bd6ae8703ca19940e3167dc9cdf740c712a39c60", size = 163035, upload-time = "2026-07-28T06:04:40.361Z" }, + { url = "https://files.pythonhosted.org/packages/35/ea/a0af2d9da62897af2a055484920de05dade30d2ba2c0d65cbdea875d3d8b/wrapt-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:418f54bb09d1762db02c7009b4051149893af3153a87f92d70356703c11eea02", size = 171887, upload-time = "2026-07-28T06:04:41.614Z" }, + { url = "https://files.pythonhosted.org/packages/7e/dd/63cd4c864c65ef4906df64bd2d378f4a62b54f28063f282dfb3bf93caead/wrapt-2.3.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1598becd30f8f2777d18564064eb4f4dbe1ab0e05a8f09786d0ef505ac782bf3", size = 161113, upload-time = "2026-07-28T06:04:42.864Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ee/82f1fc9e431b5c2c5a6d201aa865dbeae3984c311c6d11a185f0c8367cf6/wrapt-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3da470536bf9645143323dd41b32db55c6f4304ad382094c1a1da8a92061e10d", size = 170530, upload-time = "2026-07-28T06:04:44.212Z" }, + { url = "https://files.pythonhosted.org/packages/37/a5/5dc590e863a419930d988f8b7ca3e75a6befcfb10b6003b3a152f3d5f732/wrapt-2.3.0-cp312-cp312-win32.whl", hash = "sha256:fb8e2e6704a1e0b1b989546c69e2688371ef4a07fa5f61bde3eb6211186f5ac1", size = 78323, upload-time = "2026-07-28T06:04:45.484Z" }, + { url = "https://files.pythonhosted.org/packages/51/f9/4a6925a07951df56394f7e6ebe14f69f1c5ef9d87aa63e0839acf15aa63a/wrapt-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:cdc021cb0b62471d6aac7f2bd92f3b4658073775f9ee7fcd325c511129e7bcc8", size = 81180, upload-time = "2026-07-28T06:04:47.021Z" }, + { url = "https://files.pythonhosted.org/packages/a8/4f/8b5de0395b2a72216751d41c9861df6facaeb611b619d8810ed2b3b23eb2/wrapt-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:67bfe2485f50368c3fcd2275fc1fd100e350d601e0058921a7c82678a465aeab", size = 80155, upload-time = "2026-07-28T06:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6e/0f88a072483e76b881e3fdcd6b6ffb4a5791002514fe541e72b1b73c859a/wrapt-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d3fb71e65b001adfc42684522eeccd9c21d8ba679945abc993439567b66e59f", size = 81960, upload-time = "2026-07-28T06:04:49.622Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ff/b7e2776e7c294075eb712cc9ef573d1b818f393006d09787262b8fc871c4/wrapt-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51a7a4181c1295774812271fbcd7c909df372bc25579d4ed9eb875caaf0ae86f", size = 82435, upload-time = "2026-07-28T06:04:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/d8/90/343bb5d0f1f9669bc252a6073f085b4abf862511bd5c9c9eaec754341f1d/wrapt-2.3.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9045917809c63fdf7abe3a2ceaed3d670b8ee4500ddd9291192d30aeb34467c5", size = 170350, upload-time = "2026-07-28T06:04:52.187Z" }, + { url = "https://files.pythonhosted.org/packages/59/f8/13b79a392930bd0dd6b86cbfbfe1c40944110456e1dc6d809e5c46ece904/wrapt-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54ca1d5573f69b5fe1d74f1f65799c68015e82f685efec9fd8cfa40a094c44d0", size = 170022, upload-time = "2026-07-28T06:04:53.599Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fc/4f1b6918f5290db959d6e0c07f77385d87cede29c39c9cf8f145e9c82954/wrapt-2.3.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:242b60c21e30866e6a2fa606c612b47c553fa60c0eaeeeb7797fb842ac0ce609", size = 161043, upload-time = "2026-07-28T06:04:54.936Z" }, + { url = "https://files.pythonhosted.org/packages/01/e1/45d3cf74414780bdff6d0380467e003f6eb0f028b6c9403db868dbc7209c/wrapt-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3f3d7ec0a51fbfe00d3aef047641ff2c58b25565b4717fc1f90e050be01cba8", size = 168576, upload-time = "2026-07-28T06:04:56.261Z" }, + { url = "https://files.pythonhosted.org/packages/f3/73/2fa58dd97f191c997755e2c6d569a68f0c433db4e4b36099bdd7227b6cac/wrapt-2.3.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:261f53870cd4fb2bf38f9f972c56c728fd224cb7c65721307de59d9e7e6741ae", size = 159140, upload-time = "2026-07-28T06:04:57.754Z" }, + { url = "https://files.pythonhosted.org/packages/29/a8/08a56e2000a8816d449dcbad8c8b081697acbbd490821ceca0f9d8e8d20c/wrapt-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8159ec0b0cb7608175eb150de94c19e34f4d47ac655f5ca9baf45df6b688ffd3", size = 169263, upload-time = "2026-07-28T06:04:59.161Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d4/354e1725e35a73b2af4fa70a3e024c7a5d1bf1802dfb862dcb668aae0253/wrapt-2.3.0-cp313-cp313-win32.whl", hash = "sha256:10461884b3014fbfc8eb7d09a93c5f246363e6711d9d881f95eb8c27fdef049f", size = 78241, upload-time = "2026-07-28T06:05:00.507Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7e/34c87fa2174848dfee820322aaa318bab08913998ccecc8d2f57b4ad4639/wrapt-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:ac870cc97b73bb00ac353329e9559a4bebc47c4c86792ed9b23b58c15b6ad838", size = 81113, upload-time = "2026-07-28T06:05:01.839Z" }, + { url = "https://files.pythonhosted.org/packages/11/86/fcc9a530579e008c9478bb565a6cdfbfd33536660f069c8b91a6607c5050/wrapt-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:a65e8db2b4e90c2e7ade931086351c98ef420bf7a94ee08c95ac8a3cbbc43579", size = 80182, upload-time = "2026-07-28T06:05:03.152Z" }, + { url = "https://files.pythonhosted.org/packages/96/50/3864848b95b28ef73e17551fc8dccbff2628a834f52cf26a57f9c419fb83/wrapt-2.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:fd1f2f557dd3491fe75905e578f4db967393d40d1a8f468edc4d40ac7f2d5944", size = 83921, upload-time = "2026-07-28T06:05:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4c/3d1921a60c3e8c71c540ff136e6a47a1fbccf7f671e818394889f7871d9c/wrapt-2.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9f5d2aec29dfc76c37e23897dee92766a3fd4f3bff3ae7fc9c6b4bf37d8c1360", size = 84412, upload-time = "2026-07-28T06:05:05.921Z" }, + { url = "https://files.pythonhosted.org/packages/fa/1a/4a796ff7adb26ada6d4b758c94d47a38320b085e7099afc088efbbcdb006/wrapt-2.3.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:646d20d413ffcd1b0a2f700076e2d0252d872dcb7754860a73e45a59ea883614", size = 207168, upload-time = "2026-07-28T06:05:07.256Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3e/d7777776806c579b761bac2f91721dda9f04c7a1b380213c5935cc750ae6/wrapt-2.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:379f670f45b7bb8993edd9f6fc36c6cc65edb81cffa0b504be34acb0303fff0a", size = 214351, upload-time = "2026-07-28T06:05:08.945Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/2d64d394df7bf181955b3bb562bf33c4492fb4be113f53071106d43ad8b5/wrapt-2.3.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6208f302f110295d64b22a7ac96500c791bf492dce4366e622e4912b077c9687", size = 199020, upload-time = "2026-07-28T06:05:10.418Z" }, + { url = "https://files.pythonhosted.org/packages/3e/3d/fb31d3db7d9834d265fb1a27a2adf0ddf51557c67458c97b22439ad6ae3d/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ed635a9ca4f3a5a2b900c10c69e823373bc00ebc114b459383596d3487da3570", size = 209969, upload-time = "2026-07-28T06:05:11.983Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d1/8724b5da582e62070dc9bf4d8bf1972f317297eefd7ba1f2b5c6393ccf6c/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:e3b9eaa742ae7a0aaaaad4ca4b69469d757af2d6e6663ef1dadc47adec0aeb41", size = 196324, upload-time = "2026-07-28T06:05:13.557Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/3d9ef411149543016ee6bcf3af707f787cebd946527452b94bf122e9b7b4/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d0f7284f88f4833705132d06d3b425a43095c2cbd07c58166aac3ab646ba12a4", size = 202610, upload-time = "2026-07-28T06:05:15.048Z" }, + { url = "https://files.pythonhosted.org/packages/13/9b/4fc042ceb757866dd4a5fc057b3b736f2b360d3703ce9f830d83dc9226e0/wrapt-2.3.0-cp313-cp313t-win32.whl", hash = "sha256:7ebb274aba688b043429eb1500ff8a76ce0cb8ac0812ca3e301f06247b8722b3", size = 79178, upload-time = "2026-07-28T06:05:16.469Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ff/b94878f8eed809ca042685276bcea9f24e8c2ca7c9653bb80bbb920a68a5/wrapt-2.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c4bded758ad6f03b965830944a2f0bc5b2eb3767fe5a7310134315d1a6610e98", size = 82634, upload-time = "2026-07-28T06:05:18.026Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/663e1de5332a71685a729754312d327d4cada767c36e1c5a2db4c8de49e6/wrapt-2.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:d2cc64539da63e39ffb9c7ede849b6e8ddaaf7b3876b5cfb04efd85a5f3f4eb6", size = 81387, upload-time = "2026-07-28T06:05:19.417Z" }, + { url = "https://files.pythonhosted.org/packages/58/10/b073beaea89bc0d3670a75ff51139430a54b6af7ba7796507730634536dd/wrapt-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea52a0d0f08c584943d5764be0e84efa912c8da23c23e1e285ff2f5641c18fcc", size = 81978, upload-time = "2026-07-28T06:05:21.133Z" }, + { url = "https://files.pythonhosted.org/packages/b3/31/0916d9cebf848ed3f1a0c1888faee421747df77331e4db2bc527a9a85988/wrapt-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd85b0aa88efdb189d6ae2f35f4526943a8f091c38599c9c31478241c819e6a1", size = 82518, upload-time = "2026-07-28T06:05:22.562Z" }, + { url = "https://files.pythonhosted.org/packages/f5/73/31c1bf0f3384062751c2094dadb314916d70aa9b6bfd26d994b4a7b393fa/wrapt-2.3.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:141ed6211286a9660d8d6702de598b43f0934b4f0eda16393f100a80f501d945", size = 170187, upload-time = "2026-07-28T06:05:23.904Z" }, + { url = "https://files.pythonhosted.org/packages/ed/25/fce087d54b79b8905f3c3c9dd5f454bbd8d8acb80b960c4a6aee5b4659b3/wrapt-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e49885a62ec4ee854d1b9e6371fda6afd219917225752abf729a3f36d4df9a5", size = 169288, upload-time = "2026-07-28T06:05:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/c7/30/0d09e6dddc6b7a7230ac77f50254b5980ab4fcd22976f72f8cc8a0404458/wrapt-2.3.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d6159c9b2fefec02314e1332dbbbfaf960e369dfd26bcf7f8b258b5732065b3", size = 160932, upload-time = "2026-07-28T06:05:27.022Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ca/0913af0d2ec0c43865d32d615f518fea66c13c5c930e489e9b0de248e9a8/wrapt-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24da48596326ef8e448cfa837b454f638713d3531262375f00e5a9681682fc07", size = 169017, upload-time = "2026-07-28T06:05:28.501Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f2/3d1e47ea81b822210f5df1bf942fd90780a75c055243d569b664529dea88/wrapt-2.3.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cd3a2edf0427013736b8127955cec62608c56e53ea47e82812ea32059cda407f", size = 159065, upload-time = "2026-07-28T06:05:30.01Z" }, + { url = "https://files.pythonhosted.org/packages/43/a5/ef2066ced8e5fca204e2b361e9708e36555b40949c583d997ea3b590817d/wrapt-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fa0df3bff4e7ce45759f33fd39335fe2f60477bb9ecf7b8aa41e7d07ee36a23", size = 168821, upload-time = "2026-07-28T06:05:31.649Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e1/016104650d4e572fa91506eb396b3dd8efbccc9284fdc1c9479c3d21db28/wrapt-2.3.0-cp314-cp314-win32.whl", hash = "sha256:2935d5454b3f179a29b12cf390ee47246740ba2c3a7545b1b46ba31a5f2a4a0b", size = 78700, upload-time = "2026-07-28T06:05:33.391Z" }, + { url = "https://files.pythonhosted.org/packages/3d/97/6fdc20a9f2ca304748b3f0819cbf377d55260562777bf0b615431bc3c181/wrapt-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:cc2cea812e5cb179a796b766747e7d3b21088760d8deb95676d482b8c8e6fa7d", size = 81422, upload-time = "2026-07-28T06:05:34.774Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a4/9cbd53bf05746bea2c392af39cb052427a8ec95cbd494d930733d8f44681/wrapt-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:22cc5c0a717bd4da87018ae0bffd4c19c6fb679d3ff357216ba566ab26c76cab", size = 80639, upload-time = "2026-07-28T06:05:36.228Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/6c5e4a0f66ea0d2b2dd267e8dd05a0014eea56840b3c8595d40b0a5d1f91/wrapt-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a6b5984cd65dd639546f0eb4b8eacf1c31cb2fe9fb5c27bffe240987cdb2cf84", size = 84030, upload-time = "2026-07-28T06:05:37.714Z" }, + { url = "https://files.pythonhosted.org/packages/6a/eb/a1aedf03283bc9cbf8a1783995ddc54e3c5a86878f19002d2c428494f4c5/wrapt-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c88abcf53daef80e01a75c7530e727fa6e2c1888fe83e3dcdba4c96216a1f5c7", size = 84419, upload-time = "2026-07-28T06:05:39.131Z" }, + { url = "https://files.pythonhosted.org/packages/63/61/50d511c0dc5105563849e86daa3e16ac7feef699f79fb05af45ea70107d5/wrapt-2.3.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:85de890ff968196e92dd1ae73a9fb8970495e7650a457b1c9ef0ac3dd550bce2", size = 207171, upload-time = "2026-07-28T06:05:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/3f/59/9b538cf7795217e810699d16bc88b96a830d9b5c403eb2ec2db6b5f2ae81/wrapt-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50f416b74d092bb9f41b424e90dd457f365f7ba4b11de62a23679769a21bd85c", size = 214329, upload-time = "2026-07-28T06:05:42.287Z" }, + { url = "https://files.pythonhosted.org/packages/b3/28/9935d62b1499e5c8b3d191e99ba4eb31ca237a0b699142011a837e9dc7ea/wrapt-2.3.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39febbee6d77301d31da6996b152ce52452da7c7ef72aba10c2fa976dff9c295", size = 199079, upload-time = "2026-07-28T06:05:43.958Z" }, + { url = "https://files.pythonhosted.org/packages/2b/01/4446b80fa2ffa47a3449b250d004ba1c1937f07f64a179608fec735df866/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:93513bec052c6cd987f9f580c3df068c8bc4ebae6543736be3ca7ec5959cafcd", size = 209992, upload-time = "2026-07-28T06:05:45.677Z" }, + { url = "https://files.pythonhosted.org/packages/d4/07/56f26c9f9979586a021e8148747004aba4498f49458c90b0502969b904e1/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:729126e667da34d251b8ebf8a45ef0c5ddadc21542b3d6e1abf4259ece6508df", size = 196334, upload-time = "2026-07-28T06:05:47.608Z" }, + { url = "https://files.pythonhosted.org/packages/8b/41/6d7bcc895b0f28b2250e10908f060687b9165429dcd7f22ddb3d4c031b74/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:626b69db2021aa01671ec7bbc9740e558522bd44c18cf2ce69bf3d666a014109", size = 202644, upload-time = "2026-07-28T06:05:49.183Z" }, + { url = "https://files.pythonhosted.org/packages/cd/25/7860927edba06b758b8852a6f02e832be715563c67a6795d94350bc81099/wrapt-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:629d73378082c00a8173031f9fb30a3ac6abbc894a5bfdfae71fabc60642d501", size = 79685, upload-time = "2026-07-28T06:05:50.976Z" }, + { url = "https://files.pythonhosted.org/packages/c4/0f/270bafe92fde3b069a39bc01e39ee79340895b335640df861d43d2a51885/wrapt-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:42869085687f0aefd57c0f636c3f9354f8ffb321a8ba9cb52d19beb796e561c5", size = 83104, upload-time = "2026-07-28T06:05:52.405Z" }, + { url = "https://files.pythonhosted.org/packages/55/b3/af176d79a8515a8a720eccdad9a96f6e31a30abf2865430c8c42adf2fd13/wrapt-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b1e5aa486e269b00ed35e64771c7d0ab8096cfd2643405ca8cd60ebedc099a51", size = 81774, upload-time = "2026-07-28T06:05:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/00/39/3daf9f47be208606586de4568ba6713db53ebc8fd7a575aea1fe57983b69/wrapt-2.3.0-py3-none-any.whl", hash = "sha256:d8c7ed08477429752b8c44991f40ad7838b18332a160698740a6bfbc10d998a2", size = 61866, upload-time = "2026-07-28T06:06:12.9Z" }, +] + [[package]] name = "yarl" version = "1.20.1" diff --git a/backend/retirement/lambda_handler.py b/backend/retirement/lambda_handler.py index 1902f44d..ffbd5c81 100644 --- a/backend/retirement/lambda_handler.py +++ b/backend/retirement/lambda_handler.py @@ -130,21 +130,36 @@ def lambda_handler(event, context): "portfolio_data": {...} # Optional, will load from DB if not provided } """ - # Wrap entire handler with observability context - with observe() as observability: - try: - logger.info(f"Retirement Lambda invoked with event: {json.dumps(event)[:500]}") + if isinstance(event, str): + event = json.loads(event) - # Parse event - if isinstance(event, str): - event = json.loads(event) + logger.info(f"Retirement Lambda invoked with event: {json.dumps(event)[:500]}") - job_id = event.get('job_id') - if not job_id: - return { - 'statusCode': 400, - 'body': json.dumps({'error': 'job_id is required'}) - } + job_id = event.get('job_id') + if not job_id: + return { + 'statusCode': 400, + 'body': json.dumps({'error': 'job_id is required'}) + } + + user_id = None + try: + db_for_trace = Database() + job = db_for_trace.jobs.find_by_id(job_id) + if job: + user_id = job.get('clerk_user_id') + except Exception as e: + logger.warning(f"Retirement: Could not load job owner for tracing: {e}") + + with observe( + name="project-retirement", + user_id=user_id, + session_id=job_id, + tags=["retirement", "portfolio-analysis"], + metadata={"agent": "retirement"}, + input={"job_id": job_id}, + ) as observability: + try: portfolio_data = event.get('portfolio_data') if not portfolio_data: @@ -158,11 +173,6 @@ def lambda_handler(event, context): db = Database() job = db.jobs.find_by_id(job_id) if job: - if observability: - observability.create_event( - name="Retirement Started!", status_message="OK" - ) - # portfolio_data = job.get('request_payload', {}).get('portfolio_data', {}) user_id = job['clerk_user_id'] user = db.users.find_by_clerk_id(user_id) @@ -216,13 +226,14 @@ def lambda_handler(event, context): result = asyncio.run(run_retirement_agent(job_id, portfolio_data)) logger.info(f"Retirement completed for job {job_id}") - + observability.update(output={"status": "completed", "job_id": job_id}) return { 'statusCode': 200, 'body': json.dumps(result) } except Exception as e: + observability.update(output={"status": "failed", "error": str(e)}) logger.error(f"Error in retirement: {e}", exc_info=True) return { 'statusCode': 500, diff --git a/backend/retirement/observability.py b/backend/retirement/observability.py index 6ae50b97..aa62258a 100644 --- a/backend/retirement/observability.py +++ b/backend/retirement/observability.py @@ -1,112 +1,251 @@ """ -Observability module for LangFuse integration. -Provides a simple context manager for setting up and flushing traces. +Langfuse observability for Alex agents. + +Uses the official OpenInference instrumentation for the OpenAI Agents SDK +and Langfuse Python SDK v4 APIs (propagate_attributes, observation types). +Agents work normally when Langfuse credentials are not configured. """ -import os +from __future__ import annotations + import logging -from contextlib import contextmanager +import os +import re +from contextlib import contextmanager, nullcontext +from dataclasses import dataclass +from typing import Any, Iterator, Optional -# Use root logger for Lambda compatibility logger = logging.getLogger() logger.setLevel(logging.INFO) +_EMAIL_RE = re.compile(r"\b[\w.-]+@[\w.-]+\.\w+\b") +_PHONE_RE = re.compile(r"\b\d{3}[-. ]?\d{3}[-. ]?\d{4}\b") +_CARD_RE = re.compile(r"\b(?:\d[ -]*?){13,19}\b") -@contextmanager -def observe(): - """ - Context manager for observability with LangFuse. +_instrumented = False +_client: Any = None - Sets up LangFuse observability if environment variables are configured, - and ensures traces are flushed on exit. - Usage: - from observability import observe +def _is_deployed() -> bool: + return bool( + os.getenv("AWS_LAMBDA_FUNCTION_NAME") + or os.getenv("AWS_EXECUTION_ENV") + or os.getenv("AWS_APP_RUNNER_SERVICE_ID") + ) - with observe(): - # Your code that uses OpenAI Agents SDK - result = await agent.run(...) - """ - logger.info("šŸ” Observability: Checking configuration...") - # Check if required environment variables exist - has_langfuse = bool(os.getenv("LANGFUSE_SECRET_KEY")) - has_openai = bool(os.getenv("OPENAI_API_KEY")) +def _sync_host_env() -> Optional[str]: + """Langfuse accepts LANGFUSE_BASE_URL (current) or LANGFUSE_HOST (legacy).""" + host = os.getenv("LANGFUSE_BASE_URL") or os.getenv("LANGFUSE_HOST") + if host: + os.environ["LANGFUSE_BASE_URL"] = host + os.environ["LANGFUSE_HOST"] = host + return host - logger.info(f"šŸ” Observability: LANGFUSE_SECRET_KEY exists: {has_langfuse}") - logger.info(f"šŸ” Observability: OPENAI_API_KEY exists: {has_openai}") - if not has_langfuse: - logger.info("šŸ” Observability: LangFuse not configured, skipping setup") - yield - return +def _default_environment() -> str: + return os.getenv("LANGFUSE_TRACING_ENVIRONMENT") or ( + "production" if _is_deployed() else "development" + ) - if not has_openai: - logger.warning("āš ļø Observability: OPENAI_API_KEY not set, traces may not export") - # Local variable for the client (no global needed) - langfuse_client = None +def _mask_text(value: str) -> str: + masked = _EMAIL_RE.sub("[REDACTED EMAIL]", value) + masked = _PHONE_RE.sub("[REDACTED PHONE]", masked) + masked = _CARD_RE.sub("[REDACTED CARD]", masked) + return masked - # Try to set up LangFuse - try: - logger.info("šŸ” Observability: Setting up LangFuse...") - import logfire - from langfuse import get_client +def _mask_data(*, data: Any, **kwargs: Any) -> Any: + if isinstance(data, str): + return _mask_text(data) + if isinstance(data, dict): + return {key: _mask_data(data=value) for key, value in data.items()} + if isinstance(data, list): + return [_mask_data(data=item) for item in data] + return data - # Configure logfire to instrument OpenAI Agents SDK - logfire.configure( - service_name="alex_retirement_agent", - send_to_logfire=False, # Don't send to Logfire cloud + +def _stringify_metadata(metadata: Optional[dict[str, Any]]) -> dict[str, str]: + if not metadata: + return {} + result: dict[str, str] = {} + for key, value in metadata.items(): + text = str(value) + if len(text) > 200: + text = text[:197] + "..." + result[str(key)] = text + return result + + +def _mask_otel_spans(*, params: Any) -> Any: + try: + from langfuse.types import MaskOtelSpansResult, OtelSpanPatch + except ImportError: + return None + + patches = {} + for identifier, span in params.spans.items(): + replacements = {} + for key, value in span.attributes.items(): + if isinstance(value, str): + masked = _mask_text(value) + if masked != value: + replacements[key] = masked + if replacements: + patches[identifier] = OtelSpanPatch(set_attributes=replacements) + return MaskOtelSpansResult(span_patches=patches) if patches else None + + +@dataclass +class Observability: + """Handle yielded by observe(). Falsy when Langfuse is not configured.""" + + client: Any = None + observation: Any = None + + def __bool__(self) -> bool: + return self.client is not None + + def update(self, **kwargs: Any) -> None: + if self.observation is not None: + self.observation.update(**kwargs) + + def start_evaluator(self, name: str, **kwargs: Any): + if self.client is None: + return nullcontext() + return self.client.start_as_current_observation( + as_type="evaluator", name=name, **kwargs ) - logger.info("āœ… Observability: Logfire configured") - # Instrument OpenAI Agents SDK - logfire.instrument_openai_agents() - logger.info("āœ… Observability: OpenAI Agents SDK instrumented") - # Initialize LangFuse client - langfuse_client = get_client() - logger.info("āœ… Observability: LangFuse client initialized") +def setup_instrumentation() -> Any: + """Idempotent Langfuse + OpenAI Agents SDK instrumentation.""" + global _instrumented, _client + + if _instrumented: + return _client + + logger.info("Observability: Checking Langfuse configuration...") + if not os.getenv("LANGFUSE_SECRET_KEY") or not os.getenv("LANGFUSE_PUBLIC_KEY"): + logger.info("Observability: Langfuse not configured, skipping setup") + _instrumented = True + _client = None + return None + + _sync_host_env() + os.environ.setdefault("LANGFUSE_TRACING_ENVIRONMENT", _default_environment()) + + try: + from langfuse import Langfuse + from openinference.instrumentation.openai_agents import OpenAIAgentsInstrumentor - # Optional: Check authentication (blocking call, use sparingly) try: - auth_result = langfuse_client.auth_check() - logger.info( - f"āœ… Observability: LangFuse authentication check passed (result: {auth_result})" - ) - except Exception as auth_error: - logger.warning(f"āš ļø Observability: Auth check failed but continuing: {auth_error}") + _client = Langfuse(mask=_mask_data, mask_otel_spans=_mask_otel_spans) + except TypeError: + _client = Langfuse(mask=_mask_data) + OpenAIAgentsInstrumentor().instrument() + logger.info( + "Observability: Langfuse client ready (environment=%s)", + os.environ.get("LANGFUSE_TRACING_ENVIRONMENT"), + ) - logger.info("šŸŽÆ Observability: Setup complete - traces will be sent to LangFuse") + try: + if _client.auth_check(): + logger.info("Observability: Langfuse authentication succeeded") + else: + logger.warning("Observability: Langfuse auth check returned false") + except Exception as auth_error: + logger.warning("Observability: Auth check failed but continuing: %s", auth_error) except ImportError as e: - logger.error(f"āŒ Observability: Missing required package: {e}") - langfuse_client = None + logger.error("Observability: Missing required package: %s", e) + _client = None except Exception as e: - logger.error(f"āŒ Observability: Setup failed: {e}") - langfuse_client = None + logger.error("Observability: Setup failed: %s", e) + _client = None + + _instrumented = True + return _client + + +@contextmanager +def observe( + *, + name: str = "run-agent", + user_id: Optional[str] = None, + session_id: Optional[str] = None, + tags: Optional[list[str]] = None, + metadata: Optional[dict[str, Any]] = None, + input: Any = None, +) -> Iterator[Observability]: + """ + Trace one agent run. Groups related agents with session_id=job_id. + + Usage: + with observe(name="plan-portfolio", session_id=job_id, user_id=user_id) as obs: + result = asyncio.run(run_orchestrator(job_id)) + obs.update(output={"status": "completed"}) + """ + from langfuse import propagate_attributes + + client = setup_instrumentation() + if client is None: + yield Observability() + return + handle = Observability(client=client) try: - # Yield control back to the calling code - yield + with client.start_as_current_observation( + as_type="span", + name=name, + input=input, + ) as root: + handle.observation = root + attr_kwargs: dict[str, Any] = { + "trace_name": name, + "tags": tags or [], + "metadata": _stringify_metadata(metadata), + "version": os.getenv("LANGFUSE_RELEASE", "1.0.0"), + } + if user_id: + attr_kwargs["user_id"] = user_id + if session_id: + attr_kwargs["session_id"] = session_id + with propagate_attributes(**attr_kwargs): + yield handle finally: - # Flush traces on exit - if langfuse_client: - try: - logger.info("šŸ” Observability: Flushing traces to LangFuse...") - langfuse_client.flush() - langfuse_client.shutdown() - - # Add a 10 second delay to ensure network requests complete - # This is a workaround for Lambda's immediate termination - import time - - logger.info("šŸ” Observability: Waiting 10 seconds for flush to complete...") - time.sleep(10) - - logger.info("āœ… Observability: Traces flushed successfully") - except Exception as e: - logger.error(f"āŒ Observability: Failed to flush traces: {e}") - else: - logger.debug("šŸ” Observability: No client to flush") + try: + logger.info("Observability: Flushing traces to Langfuse...") + client.flush() + logger.info("Observability: Traces flushed successfully") + except Exception as e: + logger.error("Observability: Failed to flush traces: %s", e) + + +@contextmanager +def observation( + name: str, + *, + as_type: str = "span", + input: Any = None, + output: Any = None, +) -> Iterator[Any]: + """Nested observation that no-ops when Langfuse is not configured.""" + client = _client or setup_instrumentation() + if client is None: + yield None + return + + with client.start_as_current_observation( + as_type=as_type, + name=name, + input=input, + ) as obs: + try: + yield obs + if output is not None: + obs.update(output=output) + except Exception as e: + obs.update(output={"error": str(e)}) + raise diff --git a/backend/retirement/pyproject.toml b/backend/retirement/pyproject.toml index ee27b5ed..6a76810e 100644 --- a/backend/retirement/pyproject.toml +++ b/backend/retirement/pyproject.toml @@ -5,8 +5,9 @@ requires-python = ">=3.12" dependencies = [ "alex-database", "boto3>=1.40.9", - "langfuse>=3.3.4", + "langfuse>=4", "openai-agents[litellm]>=0.2.6", + "openinference-instrumentation-openai-agents<2.0.0", "pydantic>=2.11.7", "pydantic-ai>=1.0.6", "python-dotenv>=1.1.1", diff --git a/backend/retirement/uv.lock b/backend/retirement/uv.lock index 6b9b4bd3..ab4cbbaa 100644 --- a/backend/retirement/uv.lock +++ b/backend/retirement/uv.lock @@ -731,7 +731,7 @@ wheels = [ [[package]] name = "langfuse" -version = "3.3.4" +version = "4.14.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff" }, @@ -741,12 +741,11 @@ dependencies = [ { name = "opentelemetry-sdk" }, { name = "packaging" }, { name = "pydantic" }, - { name = "requests" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/27/9d/c9c422c660459687293727c9146185f92443b6c72fda564ab10e9e16ab41/langfuse-3.3.4.tar.gz", hash = "sha256:e5df4e7284298990b522e02a1dc6c3c72ebc4a7a411dc7d39255fb8c2e5a7c3a", size = 164745, upload-time = "2025-09-02T15:02:39.16Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/3f/d3387db1f472d2fd4b5de40eb6abedc2f59c9da2224e9d0a79d054a90b81/langfuse-4.14.4.tar.gz", hash = "sha256:48c4bdefc290f61a42881b8048a904ab6b81d37e02496473166836e71ee0ab3a", size = 389680, upload-time = "2026-08-11T17:03:20.472Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/46/edd370d47ca72ed4ca36b3c3b8f3a4ce71a310629d0bcb6ed760b04b08b1/langfuse-3.3.4-py3-none-any.whl", hash = "sha256:15b9d20878cf39a48ca9cfa7e52acdfeb043603d3a9cef8cf451687a4d838c6b", size = 318389, upload-time = "2025-09-02T15:02:37.171Z" }, + { url = "https://files.pythonhosted.org/packages/d9/1b/a198adc52aa4ffd0886ca0d4e9bb97d45f4f326db206d2395bac67677f5d/langfuse-4.14.4-py3-none-any.whl", hash = "sha256:78692a1d4785a8c255bf6ea967e673e1ee30ce078d646e7b5f7ff44549d45cce", size = 688351, upload-time = "2026-08-11T17:03:21.881Z" }, ] [[package]] @@ -1019,6 +1018,48 @@ litellm = [ { name = "litellm" }, ] +[[package]] +name = "openinference-instrumentation" +version = "0.1.57" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openinference-semantic-conventions" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/d1/08bbd17cb66c1d7749802a30a551c132fa0ecdb1169bd13cf47fb8427132/openinference_instrumentation-0.1.57.tar.gz", hash = "sha256:2bd00a1f95ebb8a47d11615c1039128101e4abc0e9722cc4331849afdeb248fc", size = 41183, upload-time = "2026-08-07T14:29:23.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/50/05db6ba8da9fcf1872597f52b66b3894a1e5b78c177a50239e30de008317/openinference_instrumentation-0.1.57-py3-none-any.whl", hash = "sha256:e06f436e93156f5e0cfedf3f34bde1a386f8e00dbff528155a506ce74c72af92", size = 48872, upload-time = "2026-08-07T14:29:21.632Z" }, +] + +[[package]] +name = "openinference-instrumentation-openai-agents" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openinference-instrumentation" }, + { name = "openinference-semantic-conventions" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/f1/8d97d6c859854f6d5546af805185a03376604d73d744e90b3308eaddd9bb/openinference_instrumentation_openai_agents-1.6.2.tar.gz", hash = "sha256:efffff2606e552b6cb8481c6e5738b8c104b6e6e72ead13773ee5b0bdc86aabd", size = 26586, upload-time = "2026-07-30T16:38:28.049Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/15/2f6f3890675c626eeac81ce7994f7762bb25c3a3a4a5b00e41e712c3fd47/openinference_instrumentation_openai_agents-1.6.2-py3-none-any.whl", hash = "sha256:0d9fcf4cb24dd22c57ed035f31c807135fbccc67347e11cfdc01b39e835d35d9", size = 28995, upload-time = "2026-07-30T16:38:26.879Z" }, +] + +[[package]] +name = "openinference-semantic-conventions" +version = "0.1.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/5e/13ffdb2ba436312cda9ed842ce3416fa5961e36a205df9a18a67eef7f9b3/openinference_semantic_conventions-0.1.32.tar.gz", hash = "sha256:2a3e6723ddce37abc5c43d38c6c7ac05b0f2efcfbe75cc0f135ab14ceb0730f7", size = 13980, upload-time = "2026-08-07T14:29:21.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/19/2d096333313d45e5f87d08f903cedc3f7298c5690d2e6c4ef236dc22870e/openinference_semantic_conventions-0.1.32-py3-none-any.whl", hash = "sha256:d06c4716faf7537fbabbb6d34e758ec6b3650a18c037661782fc8c3e9fa90f81", size = 11252, upload-time = "2026-08-07T14:29:19.78Z" }, +] + [[package]] name = "opentelemetry-api" version = "1.37.0" @@ -1617,6 +1658,7 @@ dependencies = [ { name = "boto3" }, { name = "langfuse" }, { name = "openai-agents", extra = ["litellm"] }, + { name = "openinference-instrumentation-openai-agents" }, { name = "pydantic" }, { name = "pydantic-ai" }, { name = "python-dotenv" }, @@ -1627,8 +1669,9 @@ dependencies = [ requires-dist = [ { name = "alex-database", editable = "../database" }, { name = "boto3", specifier = ">=1.40.9" }, - { name = "langfuse", specifier = ">=3.3.4" }, + { name = "langfuse", specifier = ">=4" }, { name = "openai-agents", extras = ["litellm"], specifier = ">=0.2.6" }, + { name = "openinference-instrumentation-openai-agents", specifier = "<2.0.0" }, { name = "pydantic", specifier = ">=2.11.7" }, { name = "pydantic-ai", specifier = ">=1.0.6" }, { name = "python-dotenv", specifier = ">=1.1.1" }, diff --git a/backend/tagger/lambda_handler.py b/backend/tagger/lambda_handler.py index b6136a1f..b103c2a5 100644 --- a/backend/tagger/lambda_handler.py +++ b/backend/tagger/lambda_handler.py @@ -105,27 +105,36 @@ def lambda_handler(event, context): ] } """ - # Wrap entire handler with observability context - with observe(): - try: - # Parse the event - instruments = event.get('instruments', []) + instruments = event.get('instruments', []) + job_id = event.get('job_id') + symbols = [item.get('symbol', '') for item in instruments if isinstance(item, dict)] - if not instruments: - return { - 'statusCode': 400, - 'body': json.dumps({'error': 'No instruments provided'}) - } + if not instruments: + return { + 'statusCode': 400, + 'body': json.dumps({'error': 'No instruments provided'}) + } - # Process all instruments in a single async context + with observe( + name="tag-instruments", + session_id=job_id, + tags=["tagger", "instrument-classification"], + metadata={"agent": "tagger"}, + input={"job_id": job_id, "symbols": symbols}, + ) as obs: + try: result = asyncio.run(process_instruments(instruments)) - + obs.update(output={ + "tagged": result.get("tagged", 0), + "updated": result.get("updated", []), + }) return { 'statusCode': 200, 'body': json.dumps(result) } except Exception as e: + obs.update(output={"error": str(e)}) logger.error(f"Lambda handler error: {e}") return { 'statusCode': 500, diff --git a/backend/tagger/observability.py b/backend/tagger/observability.py index 14fe322e..aa62258a 100644 --- a/backend/tagger/observability.py +++ b/backend/tagger/observability.py @@ -1,112 +1,251 @@ """ -Observability module for LangFuse integration. -Provides a simple context manager for setting up and flushing traces. +Langfuse observability for Alex agents. + +Uses the official OpenInference instrumentation for the OpenAI Agents SDK +and Langfuse Python SDK v4 APIs (propagate_attributes, observation types). +Agents work normally when Langfuse credentials are not configured. """ -import os +from __future__ import annotations + import logging -from contextlib import contextmanager +import os +import re +from contextlib import contextmanager, nullcontext +from dataclasses import dataclass +from typing import Any, Iterator, Optional -# Use root logger for Lambda compatibility logger = logging.getLogger() logger.setLevel(logging.INFO) +_EMAIL_RE = re.compile(r"\b[\w.-]+@[\w.-]+\.\w+\b") +_PHONE_RE = re.compile(r"\b\d{3}[-. ]?\d{3}[-. ]?\d{4}\b") +_CARD_RE = re.compile(r"\b(?:\d[ -]*?){13,19}\b") -@contextmanager -def observe(): - """ - Context manager for observability with LangFuse. +_instrumented = False +_client: Any = None - Sets up LangFuse observability if environment variables are configured, - and ensures traces are flushed on exit. - Usage: - from observability import observe +def _is_deployed() -> bool: + return bool( + os.getenv("AWS_LAMBDA_FUNCTION_NAME") + or os.getenv("AWS_EXECUTION_ENV") + or os.getenv("AWS_APP_RUNNER_SERVICE_ID") + ) - with observe(): - # Your code that uses OpenAI Agents SDK - result = await agent.run(...) - """ - logger.info("šŸ” Observability: Checking configuration...") - # Check if required environment variables exist - has_langfuse = bool(os.getenv("LANGFUSE_SECRET_KEY")) - has_openai = bool(os.getenv("OPENAI_API_KEY")) +def _sync_host_env() -> Optional[str]: + """Langfuse accepts LANGFUSE_BASE_URL (current) or LANGFUSE_HOST (legacy).""" + host = os.getenv("LANGFUSE_BASE_URL") or os.getenv("LANGFUSE_HOST") + if host: + os.environ["LANGFUSE_BASE_URL"] = host + os.environ["LANGFUSE_HOST"] = host + return host - logger.info(f"šŸ” Observability: LANGFUSE_SECRET_KEY exists: {has_langfuse}") - logger.info(f"šŸ” Observability: OPENAI_API_KEY exists: {has_openai}") - if not has_langfuse: - logger.info("šŸ” Observability: LangFuse not configured, skipping setup") - yield - return +def _default_environment() -> str: + return os.getenv("LANGFUSE_TRACING_ENVIRONMENT") or ( + "production" if _is_deployed() else "development" + ) - if not has_openai: - logger.warning("āš ļø Observability: OPENAI_API_KEY not set, traces may not export") - # Local variable for the client (no global needed) - langfuse_client = None +def _mask_text(value: str) -> str: + masked = _EMAIL_RE.sub("[REDACTED EMAIL]", value) + masked = _PHONE_RE.sub("[REDACTED PHONE]", masked) + masked = _CARD_RE.sub("[REDACTED CARD]", masked) + return masked - # Try to set up LangFuse - try: - logger.info("šŸ” Observability: Setting up LangFuse...") - import logfire - from langfuse import get_client +def _mask_data(*, data: Any, **kwargs: Any) -> Any: + if isinstance(data, str): + return _mask_text(data) + if isinstance(data, dict): + return {key: _mask_data(data=value) for key, value in data.items()} + if isinstance(data, list): + return [_mask_data(data=item) for item in data] + return data - # Configure logfire to instrument OpenAI Agents SDK - logfire.configure( - service_name="alex_tagger_agent", - send_to_logfire=False, # Don't send to Logfire cloud + +def _stringify_metadata(metadata: Optional[dict[str, Any]]) -> dict[str, str]: + if not metadata: + return {} + result: dict[str, str] = {} + for key, value in metadata.items(): + text = str(value) + if len(text) > 200: + text = text[:197] + "..." + result[str(key)] = text + return result + + +def _mask_otel_spans(*, params: Any) -> Any: + try: + from langfuse.types import MaskOtelSpansResult, OtelSpanPatch + except ImportError: + return None + + patches = {} + for identifier, span in params.spans.items(): + replacements = {} + for key, value in span.attributes.items(): + if isinstance(value, str): + masked = _mask_text(value) + if masked != value: + replacements[key] = masked + if replacements: + patches[identifier] = OtelSpanPatch(set_attributes=replacements) + return MaskOtelSpansResult(span_patches=patches) if patches else None + + +@dataclass +class Observability: + """Handle yielded by observe(). Falsy when Langfuse is not configured.""" + + client: Any = None + observation: Any = None + + def __bool__(self) -> bool: + return self.client is not None + + def update(self, **kwargs: Any) -> None: + if self.observation is not None: + self.observation.update(**kwargs) + + def start_evaluator(self, name: str, **kwargs: Any): + if self.client is None: + return nullcontext() + return self.client.start_as_current_observation( + as_type="evaluator", name=name, **kwargs ) - logger.info("āœ… Observability: Logfire configured") - # Instrument OpenAI Agents SDK - logfire.instrument_openai_agents() - logger.info("āœ… Observability: OpenAI Agents SDK instrumented") - # Initialize LangFuse client - langfuse_client = get_client() - logger.info("āœ… Observability: LangFuse client initialized") +def setup_instrumentation() -> Any: + """Idempotent Langfuse + OpenAI Agents SDK instrumentation.""" + global _instrumented, _client + + if _instrumented: + return _client + + logger.info("Observability: Checking Langfuse configuration...") + if not os.getenv("LANGFUSE_SECRET_KEY") or not os.getenv("LANGFUSE_PUBLIC_KEY"): + logger.info("Observability: Langfuse not configured, skipping setup") + _instrumented = True + _client = None + return None + + _sync_host_env() + os.environ.setdefault("LANGFUSE_TRACING_ENVIRONMENT", _default_environment()) + + try: + from langfuse import Langfuse + from openinference.instrumentation.openai_agents import OpenAIAgentsInstrumentor - # Optional: Check authentication (blocking call, use sparingly) try: - auth_result = langfuse_client.auth_check() - logger.info( - f"āœ… Observability: LangFuse authentication check passed (result: {auth_result})" - ) - except Exception as auth_error: - logger.warning(f"āš ļø Observability: Auth check failed but continuing: {auth_error}") + _client = Langfuse(mask=_mask_data, mask_otel_spans=_mask_otel_spans) + except TypeError: + _client = Langfuse(mask=_mask_data) + OpenAIAgentsInstrumentor().instrument() + logger.info( + "Observability: Langfuse client ready (environment=%s)", + os.environ.get("LANGFUSE_TRACING_ENVIRONMENT"), + ) - logger.info("šŸŽÆ Observability: Setup complete - traces will be sent to LangFuse") + try: + if _client.auth_check(): + logger.info("Observability: Langfuse authentication succeeded") + else: + logger.warning("Observability: Langfuse auth check returned false") + except Exception as auth_error: + logger.warning("Observability: Auth check failed but continuing: %s", auth_error) except ImportError as e: - logger.error(f"āŒ Observability: Missing required package: {e}") - langfuse_client = None + logger.error("Observability: Missing required package: %s", e) + _client = None except Exception as e: - logger.error(f"āŒ Observability: Setup failed: {e}") - langfuse_client = None + logger.error("Observability: Setup failed: %s", e) + _client = None + + _instrumented = True + return _client + + +@contextmanager +def observe( + *, + name: str = "run-agent", + user_id: Optional[str] = None, + session_id: Optional[str] = None, + tags: Optional[list[str]] = None, + metadata: Optional[dict[str, Any]] = None, + input: Any = None, +) -> Iterator[Observability]: + """ + Trace one agent run. Groups related agents with session_id=job_id. + + Usage: + with observe(name="plan-portfolio", session_id=job_id, user_id=user_id) as obs: + result = asyncio.run(run_orchestrator(job_id)) + obs.update(output={"status": "completed"}) + """ + from langfuse import propagate_attributes + + client = setup_instrumentation() + if client is None: + yield Observability() + return + handle = Observability(client=client) try: - # Yield control back to the calling code - yield + with client.start_as_current_observation( + as_type="span", + name=name, + input=input, + ) as root: + handle.observation = root + attr_kwargs: dict[str, Any] = { + "trace_name": name, + "tags": tags or [], + "metadata": _stringify_metadata(metadata), + "version": os.getenv("LANGFUSE_RELEASE", "1.0.0"), + } + if user_id: + attr_kwargs["user_id"] = user_id + if session_id: + attr_kwargs["session_id"] = session_id + with propagate_attributes(**attr_kwargs): + yield handle finally: - # Flush traces on exit - if langfuse_client: - try: - logger.info("šŸ” Observability: Flushing traces to LangFuse...") - langfuse_client.flush() - langfuse_client.shutdown() - - # Add a 10 second delay to ensure network requests complete - # This is a workaround for Lambda's immediate termination - import time - - logger.info("šŸ” Observability: Waiting 10 seconds for flush to complete...") - time.sleep(10) - - logger.info("āœ… Observability: Traces flushed successfully") - except Exception as e: - logger.error(f"āŒ Observability: Failed to flush traces: {e}") - else: - logger.debug("šŸ” Observability: No client to flush") + try: + logger.info("Observability: Flushing traces to Langfuse...") + client.flush() + logger.info("Observability: Traces flushed successfully") + except Exception as e: + logger.error("Observability: Failed to flush traces: %s", e) + + +@contextmanager +def observation( + name: str, + *, + as_type: str = "span", + input: Any = None, + output: Any = None, +) -> Iterator[Any]: + """Nested observation that no-ops when Langfuse is not configured.""" + client = _client or setup_instrumentation() + if client is None: + yield None + return + + with client.start_as_current_observation( + as_type=as_type, + name=name, + input=input, + ) as obs: + try: + yield obs + if output is not None: + obs.update(output=output) + except Exception as e: + obs.update(output={"error": str(e)}) + raise diff --git a/backend/tagger/pyproject.toml b/backend/tagger/pyproject.toml index b72a1e98..294b6f14 100644 --- a/backend/tagger/pyproject.toml +++ b/backend/tagger/pyproject.toml @@ -5,8 +5,9 @@ requires-python = ">=3.12" dependencies = [ "alex-database", "boto3>=1.40.9", - "langfuse>=3.3.4", + "langfuse>=4", "openai-agents[litellm]>=0.2.6", + "openinference-instrumentation-openai-agents<2.0.0", "pydantic>=2.11.7", "pydantic-ai>=1.0.6", "python-dotenv>=1.1.1", diff --git a/backend/tagger/uv.lock b/backend/tagger/uv.lock index 89327de1..9fd102ec 100644 --- a/backend/tagger/uv.lock +++ b/backend/tagger/uv.lock @@ -731,7 +731,7 @@ wheels = [ [[package]] name = "langfuse" -version = "3.3.4" +version = "4.14.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff" }, @@ -741,12 +741,11 @@ dependencies = [ { name = "opentelemetry-sdk" }, { name = "packaging" }, { name = "pydantic" }, - { name = "requests" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/27/9d/c9c422c660459687293727c9146185f92443b6c72fda564ab10e9e16ab41/langfuse-3.3.4.tar.gz", hash = "sha256:e5df4e7284298990b522e02a1dc6c3c72ebc4a7a411dc7d39255fb8c2e5a7c3a", size = 164745, upload-time = "2025-09-02T15:02:39.16Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/3f/d3387db1f472d2fd4b5de40eb6abedc2f59c9da2224e9d0a79d054a90b81/langfuse-4.14.4.tar.gz", hash = "sha256:48c4bdefc290f61a42881b8048a904ab6b81d37e02496473166836e71ee0ab3a", size = 389680, upload-time = "2026-08-11T17:03:20.472Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/46/edd370d47ca72ed4ca36b3c3b8f3a4ce71a310629d0bcb6ed760b04b08b1/langfuse-3.3.4-py3-none-any.whl", hash = "sha256:15b9d20878cf39a48ca9cfa7e52acdfeb043603d3a9cef8cf451687a4d838c6b", size = 318389, upload-time = "2025-09-02T15:02:37.171Z" }, + { url = "https://files.pythonhosted.org/packages/d9/1b/a198adc52aa4ffd0886ca0d4e9bb97d45f4f326db206d2395bac67677f5d/langfuse-4.14.4-py3-none-any.whl", hash = "sha256:78692a1d4785a8c255bf6ea967e673e1ee30ce078d646e7b5f7ff44549d45cce", size = 688351, upload-time = "2026-08-11T17:03:21.881Z" }, ] [[package]] @@ -1019,6 +1018,48 @@ litellm = [ { name = "litellm" }, ] +[[package]] +name = "openinference-instrumentation" +version = "0.1.57" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openinference-semantic-conventions" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/d1/08bbd17cb66c1d7749802a30a551c132fa0ecdb1169bd13cf47fb8427132/openinference_instrumentation-0.1.57.tar.gz", hash = "sha256:2bd00a1f95ebb8a47d11615c1039128101e4abc0e9722cc4331849afdeb248fc", size = 41183, upload-time = "2026-08-07T14:29:23.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/50/05db6ba8da9fcf1872597f52b66b3894a1e5b78c177a50239e30de008317/openinference_instrumentation-0.1.57-py3-none-any.whl", hash = "sha256:e06f436e93156f5e0cfedf3f34bde1a386f8e00dbff528155a506ce74c72af92", size = 48872, upload-time = "2026-08-07T14:29:21.632Z" }, +] + +[[package]] +name = "openinference-instrumentation-openai-agents" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openinference-instrumentation" }, + { name = "openinference-semantic-conventions" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/f1/8d97d6c859854f6d5546af805185a03376604d73d744e90b3308eaddd9bb/openinference_instrumentation_openai_agents-1.6.2.tar.gz", hash = "sha256:efffff2606e552b6cb8481c6e5738b8c104b6e6e72ead13773ee5b0bdc86aabd", size = 26586, upload-time = "2026-07-30T16:38:28.049Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/15/2f6f3890675c626eeac81ce7994f7762bb25c3a3a4a5b00e41e712c3fd47/openinference_instrumentation_openai_agents-1.6.2-py3-none-any.whl", hash = "sha256:0d9fcf4cb24dd22c57ed035f31c807135fbccc67347e11cfdc01b39e835d35d9", size = 28995, upload-time = "2026-07-30T16:38:26.879Z" }, +] + +[[package]] +name = "openinference-semantic-conventions" +version = "0.1.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/5e/13ffdb2ba436312cda9ed842ce3416fa5961e36a205df9a18a67eef7f9b3/openinference_semantic_conventions-0.1.32.tar.gz", hash = "sha256:2a3e6723ddce37abc5c43d38c6c7ac05b0f2efcfbe75cc0f135ab14ceb0730f7", size = 13980, upload-time = "2026-08-07T14:29:21.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/19/2d096333313d45e5f87d08f903cedc3f7298c5690d2e6c4ef236dc22870e/openinference_semantic_conventions-0.1.32-py3-none-any.whl", hash = "sha256:d06c4716faf7537fbabbb6d34e758ec6b3650a18c037661782fc8c3e9fa90f81", size = 11252, upload-time = "2026-08-07T14:29:19.78Z" }, +] + [[package]] name = "opentelemetry-api" version = "1.37.0" @@ -1778,6 +1819,7 @@ dependencies = [ { name = "boto3" }, { name = "langfuse" }, { name = "openai-agents", extra = ["litellm"] }, + { name = "openinference-instrumentation-openai-agents" }, { name = "pydantic" }, { name = "pydantic-ai" }, { name = "python-dotenv" }, @@ -1788,8 +1830,9 @@ dependencies = [ requires-dist = [ { name = "alex-database", editable = "../database" }, { name = "boto3", specifier = ">=1.40.9" }, - { name = "langfuse", specifier = ">=3.3.4" }, + { name = "langfuse", specifier = ">=4" }, { name = "openai-agents", extras = ["litellm"], specifier = ">=0.2.6" }, + { name = "openinference-instrumentation-openai-agents", specifier = "<2.0.0" }, { name = "pydantic", specifier = ">=2.11.7" }, { name = "pydantic-ai", specifier = ">=1.0.6" }, { name = "python-dotenv", specifier = ">=1.1.1" }, diff --git a/backend/uv.lock b/backend/uv.lock index 7d8f9384..3c80d4ec 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -211,6 +211,7 @@ dependencies = [ { name = "boto3" }, { name = "langfuse" }, { name = "openai-agents" }, + { name = "openinference-instrumentation-openai-agents" }, { name = "pydantic-ai" }, { name = "python-dotenv" }, ] @@ -219,8 +220,9 @@ dependencies = [ requires-dist = [ { name = "alex-database", editable = "database" }, { name = "boto3", specifier = ">=1.40.29" }, - { name = "langfuse", specifier = ">=3.3.4" }, + { name = "langfuse", specifier = ">=4" }, { name = "openai-agents", specifier = ">=0.3.0" }, + { name = "openinference-instrumentation-openai-agents", specifier = "<2.0.0" }, { name = "pydantic-ai", specifier = ">=1.0.6" }, { name = "python-dotenv", specifier = ">=1.1.1" }, ] @@ -913,7 +915,7 @@ wheels = [ [[package]] name = "langfuse" -version = "3.3.4" +version = "4.14.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff" }, @@ -923,12 +925,11 @@ dependencies = [ { name = "opentelemetry-sdk" }, { name = "packaging" }, { name = "pydantic" }, - { name = "requests" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/27/9d/c9c422c660459687293727c9146185f92443b6c72fda564ab10e9e16ab41/langfuse-3.3.4.tar.gz", hash = "sha256:e5df4e7284298990b522e02a1dc6c3c72ebc4a7a411dc7d39255fb8c2e5a7c3a", size = 164745, upload-time = "2025-09-02T15:02:39.16Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/3f/d3387db1f472d2fd4b5de40eb6abedc2f59c9da2224e9d0a79d054a90b81/langfuse-4.14.4.tar.gz", hash = "sha256:48c4bdefc290f61a42881b8048a904ab6b81d37e02496473166836e71ee0ab3a", size = 389680, upload-time = "2026-08-11T17:03:20.472Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/46/edd370d47ca72ed4ca36b3c3b8f3a4ce71a310629d0bcb6ed760b04b08b1/langfuse-3.3.4-py3-none-any.whl", hash = "sha256:15b9d20878cf39a48ca9cfa7e52acdfeb043603d3a9cef8cf451687a4d838c6b", size = 318389, upload-time = "2025-09-02T15:02:37.171Z" }, + { url = "https://files.pythonhosted.org/packages/d9/1b/a198adc52aa4ffd0886ca0d4e9bb97d45f4f326db206d2395bac67677f5d/langfuse-4.14.4-py3-none-any.whl", hash = "sha256:78692a1d4785a8c255bf6ea967e673e1ee30ce078d646e7b5f7ff44549d45cce", size = 688351, upload-time = "2026-08-11T17:03:21.881Z" }, ] [[package]] @@ -1148,6 +1149,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/3b/58ee42582716645aa1a5c30c1e337dc9a7433f7f0b5ed84ef02a35368abb/openai_agents-0.3.0-py3-none-any.whl", hash = "sha256:16de8a28729ae9e27faad7ce146a4b74acf05c9eeca3fe23299f6e621a3893ed", size = 185007, upload-time = "2025-09-11T19:20:08.304Z" }, ] +[[package]] +name = "openinference-instrumentation" +version = "0.1.57" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openinference-semantic-conventions" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/d1/08bbd17cb66c1d7749802a30a551c132fa0ecdb1169bd13cf47fb8427132/openinference_instrumentation-0.1.57.tar.gz", hash = "sha256:2bd00a1f95ebb8a47d11615c1039128101e4abc0e9722cc4331849afdeb248fc", size = 41183, upload-time = "2026-08-07T14:29:23.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/50/05db6ba8da9fcf1872597f52b66b3894a1e5b78c177a50239e30de008317/openinference_instrumentation-0.1.57-py3-none-any.whl", hash = "sha256:e06f436e93156f5e0cfedf3f34bde1a386f8e00dbff528155a506ce74c72af92", size = 48872, upload-time = "2026-08-07T14:29:21.632Z" }, +] + +[[package]] +name = "openinference-instrumentation-openai-agents" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openinference-instrumentation" }, + { name = "openinference-semantic-conventions" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/f1/8d97d6c859854f6d5546af805185a03376604d73d744e90b3308eaddd9bb/openinference_instrumentation_openai_agents-1.6.2.tar.gz", hash = "sha256:efffff2606e552b6cb8481c6e5738b8c104b6e6e72ead13773ee5b0bdc86aabd", size = 26586, upload-time = "2026-07-30T16:38:28.049Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/15/2f6f3890675c626eeac81ce7994f7762bb25c3a3a4a5b00e41e712c3fd47/openinference_instrumentation_openai_agents-1.6.2-py3-none-any.whl", hash = "sha256:0d9fcf4cb24dd22c57ed035f31c807135fbccc67347e11cfdc01b39e835d35d9", size = 28995, upload-time = "2026-07-30T16:38:26.879Z" }, +] + +[[package]] +name = "openinference-semantic-conventions" +version = "0.1.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/5e/13ffdb2ba436312cda9ed842ce3416fa5961e36a205df9a18a67eef7f9b3/openinference_semantic_conventions-0.1.32.tar.gz", hash = "sha256:2a3e6723ddce37abc5c43d38c6c7ac05b0f2efcfbe75cc0f135ab14ceb0730f7", size = 13980, upload-time = "2026-08-07T14:29:21.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/19/2d096333313d45e5f87d08f903cedc3f7298c5690d2e6c4ef236dc22870e/openinference_semantic_conventions-0.1.32-py3-none-any.whl", hash = "sha256:d06c4716faf7537fbabbb6d34e758ec6b3650a18c037661782fc8c3e9fa90f81", size = 11252, upload-time = "2026-08-07T14:29:19.78Z" }, +] + [[package]] name = "opentelemetry-api" version = "1.37.0" diff --git a/guides/5_database.md b/guides/5_database.md index 3e1c79ea..e2085442 100644 --- a/guides/5_database.md +++ b/guides/5_database.md @@ -120,6 +120,20 @@ Since Guide 4, we need additional AWS permissions for Aurora and related service ], "Resource": "*" }, + { + "Sid": "RDSServiceLinkedRole", + "Effect": "Allow", + "Action": "iam:CreateServiceLinkedRole", + "Resource": "*", + "Condition": { + "StringLike": { + "iam:AWSServiceName": [ + "rds.amazonaws.com", + "rds.application-autoscaling.amazonaws.com" + ] + } + } + }, { "Sid": "EC2Permissions", "Effect": "Allow", @@ -148,8 +162,11 @@ Since Guide 4, we need additional AWS permissions for Aurora and related service "secretsmanager:DeleteSecret", "secretsmanager:DescribeSecret", "secretsmanager:GetSecretValue", + "secretsmanager:GetResourcePolicy", "secretsmanager:PutSecretValue", - "secretsmanager:UpdateSecret" + "secretsmanager:UpdateSecret", + "secretsmanager:TagResource", + "secretsmanager:UntagResource" ], "Resource": "*" }, diff --git a/terraform/2_sagemaker/.terraform.lock.hcl b/terraform/2_sagemaker/.terraform.lock.hcl index ac9a7691..9fff0e2c 100644 --- a/terraform/2_sagemaker/.terraform.lock.hcl +++ b/terraform/2_sagemaker/.terraform.lock.hcl @@ -5,6 +5,7 @@ provider "registry.terraform.io/hashicorp/aws" { version = "5.100.0" constraints = "~> 5.70" hashes = [ + "h1:H3mU/7URhP0uCRGK8jeQRKxx2XFzEqLiOq/L2Bbiaxs=", "h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=", "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644", "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2", @@ -27,6 +28,7 @@ provider "registry.terraform.io/hashicorp/aws" { provider "registry.terraform.io/hashicorp/time" { version = "0.13.1" hashes = [ + "h1:5l8PAnxPdoUPqNPuv1dAr3efcCCtSCnY+Vj2nSGkQmw=", "h1:ZT5ppCNIModqk3iOkVt5my8b8yBHmDpl663JtXAIRqM=", "zh:02cb9aab1002f0f2a94a4f85acec8893297dc75915f7404c165983f720a54b74", "zh:04429b2b31a492d19e5ecf999b116d396dac0b24bba0d0fb19ecaefe193fdb8f", diff --git a/terraform/3_ingestion/.terraform.lock.hcl b/terraform/3_ingestion/.terraform.lock.hcl index cdc1668d..9f8c6099 100644 --- a/terraform/3_ingestion/.terraform.lock.hcl +++ b/terraform/3_ingestion/.terraform.lock.hcl @@ -5,6 +5,7 @@ provider "registry.terraform.io/hashicorp/aws" { version = "5.100.0" constraints = "~> 5.0" hashes = [ + "h1:H3mU/7URhP0uCRGK8jeQRKxx2XFzEqLiOq/L2Bbiaxs=", "h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=", "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644", "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2", diff --git a/terraform/4_researcher/main.tf b/terraform/4_researcher/main.tf index 753503e8..65e24b87 100644 --- a/terraform/4_researcher/main.tf +++ b/terraform/4_researcher/main.tf @@ -131,12 +131,17 @@ resource "aws_lambda_function" "researcher" { environment { variables = { - OPENAI_API_KEY = var.openai_api_key - ALEX_API_ENDPOINT = var.alex_api_endpoint - ALEX_API_KEY = var.alex_api_key - BEDROCK_REGION = var.bedrock_region - RESEARCHER_MODEL = var.researcher_model - MCP_LOGGING = var.mcp_logging + OPENAI_API_KEY = var.openai_api_key + ALEX_API_ENDPOINT = var.alex_api_endpoint + ALEX_API_KEY = var.alex_api_key + BEDROCK_REGION = var.bedrock_region + RESEARCHER_MODEL = var.researcher_model + MCP_LOGGING = var.mcp_logging + LANGFUSE_PUBLIC_KEY = var.langfuse_public_key + LANGFUSE_SECRET_KEY = var.langfuse_secret_key + LANGFUSE_HOST = var.langfuse_host + LANGFUSE_BASE_URL = var.langfuse_host + LANGFUSE_TRACING_ENVIRONMENT = "production" } } diff --git a/terraform/4_researcher/terraform.tfvars.example b/terraform/4_researcher/terraform.tfvars.example index dcc80696..af893476 100644 --- a/terraform/4_researcher/terraform.tfvars.example +++ b/terraform/4_researcher/terraform.tfvars.example @@ -14,4 +14,9 @@ alex_api_endpoint = "https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/prod alex_api_key = "your-api-key-here" # Enable automated research scheduler (optional, default is false) -scheduler_enabled = false \ No newline at end of file +scheduler_enabled = false + +# Langfuse observability (optional) +# langfuse_public_key = "pk-lf-..." +# langfuse_secret_key = "sk-lf-..." +# langfuse_host = "https://us.cloud.langfuse.com" \ No newline at end of file diff --git a/terraform/4_researcher/variables.tf b/terraform/4_researcher/variables.tf index 51381d33..59ba0a39 100644 --- a/terraform/4_researcher/variables.tf +++ b/terraform/4_researcher/variables.tf @@ -49,3 +49,22 @@ variable "mcp_logging" { type = string default = "False" } + +variable "langfuse_public_key" { + description = "Langfuse public key for observability (optional)" + type = string + default = "" +} + +variable "langfuse_secret_key" { + description = "Langfuse secret key for observability (optional)" + type = string + default = "" + sensitive = true +} + +variable "langfuse_host" { + description = "Langfuse host URL (optional)" + type = string + default = "https://us.cloud.langfuse.com" +} diff --git a/terraform/5_database/.terraform.lock.hcl b/terraform/5_database/.terraform.lock.hcl index 64d2d1de..5b96e898 100644 --- a/terraform/5_database/.terraform.lock.hcl +++ b/terraform/5_database/.terraform.lock.hcl @@ -5,6 +5,7 @@ provider "registry.terraform.io/hashicorp/aws" { version = "5.100.0" constraints = "~> 5.0" hashes = [ + "h1:H3mU/7URhP0uCRGK8jeQRKxx2XFzEqLiOq/L2Bbiaxs=", "h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=", "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644", "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2", @@ -28,6 +29,7 @@ provider "registry.terraform.io/hashicorp/random" { version = "3.7.2" constraints = "~> 3.5" hashes = [ + "h1:0hcNr59VEJbhZYwuDE/ysmyTS0evkfcLarlni+zATPM=", "h1:KG4NuIBl1mRWU0KD/BGfCi1YN/j3F7H4YgeeM7iSdNs=", "zh:14829603a32e4bc4d05062f059e545a91e27ff033756b48afbae6b3c835f508f", "zh:1527fb07d9fea400d70e9e6eb4a2b918d5060d604749b6f1c361518e7da546dc", diff --git a/terraform/6_agents/.terraform.lock.hcl b/terraform/6_agents/.terraform.lock.hcl index cdc1668d..9f8c6099 100644 --- a/terraform/6_agents/.terraform.lock.hcl +++ b/terraform/6_agents/.terraform.lock.hcl @@ -5,6 +5,7 @@ provider "registry.terraform.io/hashicorp/aws" { version = "5.100.0" constraints = "~> 5.0" hashes = [ + "h1:H3mU/7URhP0uCRGK8jeQRKxx2XFzEqLiOq/L2Bbiaxs=", "h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=", "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644", "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2", diff --git a/terraform/6_agents/main.tf b/terraform/6_agents/main.tf index 805ad8ca..f1d18d7c 100644 --- a/terraform/6_agents/main.tf +++ b/terraform/6_agents/main.tf @@ -247,10 +247,12 @@ resource "aws_lambda_function" "planner" { POLYGON_API_KEY = var.polygon_api_key POLYGON_PLAN = var.polygon_plan # LangFuse observability (optional) - LANGFUSE_PUBLIC_KEY = var.langfuse_public_key - LANGFUSE_SECRET_KEY = var.langfuse_secret_key - LANGFUSE_HOST = var.langfuse_host - OPENAI_API_KEY = var.openai_api_key + LANGFUSE_PUBLIC_KEY = var.langfuse_public_key + LANGFUSE_SECRET_KEY = var.langfuse_secret_key + LANGFUSE_HOST = var.langfuse_host + LANGFUSE_BASE_URL = var.langfuse_host + LANGFUSE_TRACING_ENVIRONMENT = "production" + OPENAI_API_KEY = var.openai_api_key } } @@ -294,10 +296,12 @@ resource "aws_lambda_function" "tagger" { BEDROCK_REGION = var.bedrock_region DEFAULT_AWS_REGION = var.aws_region # LangFuse observability (optional) - LANGFUSE_PUBLIC_KEY = var.langfuse_public_key - LANGFUSE_SECRET_KEY = var.langfuse_secret_key - LANGFUSE_HOST = var.langfuse_host - OPENAI_API_KEY = var.openai_api_key + LANGFUSE_PUBLIC_KEY = var.langfuse_public_key + LANGFUSE_SECRET_KEY = var.langfuse_secret_key + LANGFUSE_HOST = var.langfuse_host + LANGFUSE_BASE_URL = var.langfuse_host + LANGFUSE_TRACING_ENVIRONMENT = "production" + OPENAI_API_KEY = var.openai_api_key } } @@ -335,10 +339,12 @@ resource "aws_lambda_function" "reporter" { DEFAULT_AWS_REGION = var.aws_region SAGEMAKER_ENDPOINT = var.sagemaker_endpoint # LangFuse observability (optional) - LANGFUSE_PUBLIC_KEY = var.langfuse_public_key - LANGFUSE_SECRET_KEY = var.langfuse_secret_key - LANGFUSE_HOST = var.langfuse_host - OPENAI_API_KEY = var.openai_api_key + LANGFUSE_PUBLIC_KEY = var.langfuse_public_key + LANGFUSE_SECRET_KEY = var.langfuse_secret_key + LANGFUSE_HOST = var.langfuse_host + LANGFUSE_BASE_URL = var.langfuse_host + LANGFUSE_TRACING_ENVIRONMENT = "production" + OPENAI_API_KEY = var.openai_api_key } } @@ -375,10 +381,12 @@ resource "aws_lambda_function" "charter" { BEDROCK_REGION = var.bedrock_region DEFAULT_AWS_REGION = var.aws_region # LangFuse observability (optional) - LANGFUSE_PUBLIC_KEY = var.langfuse_public_key - LANGFUSE_SECRET_KEY = var.langfuse_secret_key - LANGFUSE_HOST = var.langfuse_host - OPENAI_API_KEY = var.openai_api_key + LANGFUSE_PUBLIC_KEY = var.langfuse_public_key + LANGFUSE_SECRET_KEY = var.langfuse_secret_key + LANGFUSE_HOST = var.langfuse_host + LANGFUSE_BASE_URL = var.langfuse_host + LANGFUSE_TRACING_ENVIRONMENT = "production" + OPENAI_API_KEY = var.openai_api_key } } @@ -415,10 +423,12 @@ resource "aws_lambda_function" "retirement" { BEDROCK_REGION = var.bedrock_region DEFAULT_AWS_REGION = var.aws_region # LangFuse observability (optional) - LANGFUSE_PUBLIC_KEY = var.langfuse_public_key - LANGFUSE_SECRET_KEY = var.langfuse_secret_key - LANGFUSE_HOST = var.langfuse_host - OPENAI_API_KEY = var.openai_api_key + LANGFUSE_PUBLIC_KEY = var.langfuse_public_key + LANGFUSE_SECRET_KEY = var.langfuse_secret_key + LANGFUSE_HOST = var.langfuse_host + LANGFUSE_BASE_URL = var.langfuse_host + LANGFUSE_TRACING_ENVIRONMENT = "production" + OPENAI_API_KEY = var.openai_api_key } } diff --git a/terraform/6_agents/terraform.tfvars.example b/terraform/6_agents/terraform.tfvars.example index be53d3b9..6d2cf9d3 100644 --- a/terraform/6_agents/terraform.tfvars.example +++ b/terraform/6_agents/terraform.tfvars.example @@ -37,6 +37,6 @@ polygon_plan = "free" # langfuse_public_key = "pk-lf-..." # Add your pk-lf-xxx key here - be sure this is the one that starts pk # langfuse_secret_key = "sk-lf-..." # Add your sk-lf-xxx key here - be sure this is the one that starts sk -# langfuse_host = "https://us.cloud.langfuse.com" -# OpenAI API key (required for OpenAI Agents SDK tracing to work - no balance or spend needed) +# langfuse_host = "https://us.cloud.langfuse.com" # EU: https://cloud.langfuse.com +# openai_api_key is optional; Bedrock agents do not need it for Langfuse tracing # openai_api_key = "" \ No newline at end of file From 9fad87a0512bf6ca5bb5cc1863f60a78a371287b Mon Sep 17 00:00:00 2001 From: "kapitmax07@gmail.com" Date: Wed, 12 Aug 2026 17:05:07 -0400 Subject: [PATCH 3/5] fix: use the same job lookup for trace user_id and portfolio loading charter, reporter, and retirement each fetched the job once to set observe()'s user_id, then unconditionally re-fetched it again inside the handler and reassigned user_id from that second result. If the first lookup failed but the second succeeded, the Langfuse trace would be recorded with user_id=None while the agent actually ran for a known user. Reuse the already-fetched job instead of re-querying, which also drops a redundant Data API round-trip per invocation. --- backend/charter/lambda_handler.py | 3 ++- backend/reporter/lambda_handler.py | 3 ++- backend/retirement/lambda_handler.py | 14 ++++++-------- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/backend/charter/lambda_handler.py b/backend/charter/lambda_handler.py index 947c947e..148f3f78 100644 --- a/backend/charter/lambda_handler.py +++ b/backend/charter/lambda_handler.py @@ -148,6 +148,7 @@ def lambda_handler(event, context): db = Database() user_id = None + job = None try: job = db.jobs.find_by_id(job_id) if job: @@ -170,7 +171,7 @@ def lambda_handler(event, context): # Load portfolio data from database (like Reporter does) logger.info(f"Charter: Loading portfolio data for job {job_id}") try: - job = db.jobs.find_by_id(job_id) + job = job or db.jobs.find_by_id(job_id) if job: user_id = job['clerk_user_id'] user = db.users.find_by_clerk_id(user_id) diff --git a/backend/reporter/lambda_handler.py b/backend/reporter/lambda_handler.py index 73931e04..ea6696b4 100644 --- a/backend/reporter/lambda_handler.py +++ b/backend/reporter/lambda_handler.py @@ -132,6 +132,7 @@ def lambda_handler(event, context): db = Database() user_id = None + job = None try: job = db.jobs.find_by_id(job_id) if job: @@ -153,7 +154,7 @@ def lambda_handler(event, context): if not portfolio_data: # Try to load from database try: - job = db.jobs.find_by_id(job_id) + job = job or db.jobs.find_by_id(job_id) if job: user_id = job["clerk_user_id"] user = db.users.find_by_clerk_id(user_id) diff --git a/backend/retirement/lambda_handler.py b/backend/retirement/lambda_handler.py index ffbd5c81..2da27740 100644 --- a/backend/retirement/lambda_handler.py +++ b/backend/retirement/lambda_handler.py @@ -143,9 +143,11 @@ def lambda_handler(event, context): } user_id = None + job = None + db = None try: - db_for_trace = Database() - job = db_for_trace.jobs.find_by_id(job_id) + db = Database() + job = db.jobs.find_by_id(job_id) if job: user_id = job.get('clerk_user_id') except Exception as e: @@ -166,12 +168,8 @@ def lambda_handler(event, context): # Try to load from database logger.info(f"Retirement Loading portfolio data for job {job_id}") try: - import sys - sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) - from src import Database - - db = Database() - job = db.jobs.find_by_id(job_id) + db = db or Database() + job = job or db.jobs.find_by_id(job_id) if job: # portfolio_data = job.get('request_payload', {}).get('portfolio_data', {}) user_id = job['clerk_user_id'] From cb553597a012b4cf348c865936c2fea3387daab0 Mon Sep 17 00:00:00 2001 From: "kapitmax07@gmail.com" Date: Wed, 12 Aug 2026 20:18:39 -0400 Subject: [PATCH 4/5] fix: harden run_local.py against Windows startup and shutdown issues Fixes several issues found while debugging a failed local frontend start on Windows: a UTF-8 encoding crash when stdout isn't a real console, too-short a startup timeout for cold Next.js compiles, orphaned backend/frontend processes left behind because proc.terminate() only kills the immediate uv/cmd.exe wrapper (not its child server) on Windows, and false "process died" shutdowns triggered by that same wrapper exiting on its own while the real server keeps running fine. --- scripts/run_local.py | 81 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 66 insertions(+), 15 deletions(-) diff --git a/scripts/run_local.py b/scripts/run_local.py index 31ddefe7..128870c2 100644 --- a/scripts/run_local.py +++ b/scripts/run_local.py @@ -14,6 +14,12 @@ # On Windows, npm/node are .cmd files and need shell=True to be found IS_WINDOWS = sys.platform == "win32" +# On Windows, stdout may default to a non-UTF-8 codepage (e.g. cp1252) when not +# attached to a real console, which crashes on the emoji this script prints +if IS_WINDOWS: + sys.stdout.reconfigure(encoding="utf-8") + sys.stderr.reconfigure(encoding="utf-8") + # Track subprocesses for cleanup processes = [] @@ -22,7 +28,16 @@ def cleanup(signum=None, frame=None): print("\nšŸ›‘ Shutting down services...") for proc in processes: try: - proc.terminate() + if IS_WINDOWS: + # proc.terminate() only kills the immediate wrapper (uv/cmd.exe), + # leaving the real backend/frontend server as an orphan. Kill the + # whole process tree instead. + subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(proc.pid)], + capture_output=True + ) + else: + proc.terminate() proc.wait(timeout=5) except: proc.kill() @@ -172,7 +187,11 @@ def read_output(): reader = threading.Thread(target=read_output, daemon=True) reader.start() - for i in range(30): # 30 second timeout + for i in range(90): # 90 second timeout (cold Next.js starts can take ~30s) + if proc.poll() is not None: + print(" āŒ Frontend process exited unexpectedly (see output above)") + cleanup() + if started_flag["started"] or i > 5: # Start checking after 5 seconds try: response = httpx.get("http://localhost:3000", timeout=1) @@ -190,7 +209,7 @@ def read_output(): print(" āŒ Frontend failed to start") cleanup() -def monitor_processes(): +def monitor_processes(backend_proc, frontend_proc): """Monitor running processes and show their output""" print("\n" + "="*60) print("šŸŽÆ Alex Financial Advisor - Local Development") @@ -202,21 +221,53 @@ def monitor_processes(): print("\nšŸ“ Logs will appear below. Press Ctrl+C to stop.\n") print("="*60 + "\n") + import httpx + last_health_check = 0.0 + consecutive_backend_failures = 0 + consecutive_frontend_failures = 0 + FAILURE_THRESHOLD = 3 # require 3 consecutive misses before treating it as dead + # Monitor processes while True: - for proc in processes: - # Check if process is still running - if proc.poll() is not None: - print(f"\nāš ļø A process has stopped unexpectedly!") - cleanup() + # Backend stdout has no other reader. Frontend's stdout is already + # drained by the background thread started in start_frontend, so + # don't read it again here (that would race with that thread). + try: + line = backend_proc.stdout.readline() + if line: + print(f"[LOG] {line.strip()}") + except: + pass + + # On Windows, the intermediate wrapper process (uv/cmd.exe) can exit + # on its own while the real server underneath keeps running, so we + # can't rely on proc.poll() to detect a crash. Poll the actual + # services instead. The backend does blocking AWS calls per-request, + # so a single slow health check under load isn't necessarily a crash - + # require several consecutive misses before shutting down. + now = time.time() + if now - last_health_check > 5: + last_health_check = now - # Read any available output try: - line = proc.stdout.readline() - if line: - print(f"[LOG] {line.strip()}") - except: - pass + backend_ok = httpx.get("http://localhost:8000/health", timeout=5).status_code == 200 + except Exception: + backend_ok = False + consecutive_backend_failures = 0 if backend_ok else consecutive_backend_failures + 1 + + try: + httpx.get("http://localhost:3000", timeout=5) + frontend_ok = True + except httpx.ConnectError: + frontend_ok = False + except Exception: + frontend_ok = True # any response at all means it's up + consecutive_frontend_failures = 0 if frontend_ok else consecutive_frontend_failures + 1 + + if consecutive_backend_failures >= FAILURE_THRESHOLD or consecutive_frontend_failures >= FAILURE_THRESHOLD: + dead = "Backend" if consecutive_backend_failures >= FAILURE_THRESHOLD else "Frontend" + print(f"\nāš ļø {dead} stopped responding!") + cleanup() time.sleep(0.1) @@ -242,7 +293,7 @@ def main(): # Monitor processes try: - monitor_processes() + monitor_processes(backend_proc, frontend_proc) except KeyboardInterrupt: cleanup() From 9b50016d915c7f7ec4aa98529ba06c8c6fbc017b Mon Sep 17 00:00:00 2001 From: "kapitmax07@gmail.com" Date: Wed, 12 Aug 2026 20:41:47 -0400 Subject: [PATCH 5/5] fix: drain backend stderr in run_local.py to prevent pipe-buffer deadlock backend/api/main.py logs via logging.basicConfig(), which defaults to stderr. run_local.py captured that stderr with its own subprocess.PIPE but never read it, so once enough log lines accumulated the OS pipe buffer filled up and the backend's next logging call blocked forever - freezing its single-threaded event loop (and every request, including /health) with no error ever logged. Fix: merge backend stderr into stdout and continuously drain it via a background thread, matching the pattern already used for the frontend. Found and verified with py-spy: before the fix, a live hang showed the backend's only thread stuck inside logging.emit(); after the fix, 800 rapid requests left it idle in the normal asyncio event loop. --- scripts/run_local.py | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/scripts/run_local.py b/scripts/run_local.py index 128870c2..d855c08a 100644 --- a/scripts/run_local.py +++ b/scripts/run_local.py @@ -125,12 +125,24 @@ def start_backend(): ["uv", "run", "main.py"], cwd=backend_dir, stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + stderr=subprocess.STDOUT, # Combine stderr with stdout (main.py's logging goes to stderr) text=True, bufsize=1 ) processes.append(proc) + # Continuously drain the backend's output pipe. Without this, once the OS + # pipe buffer fills up (main.py's logging.basicConfig() defaults to + # stderr), the backend's next log call blocks forever, freezing its + # single-threaded event loop - and with it, every request including /health. + import threading + + def read_backend_output(): + for line in proc.stdout: + print(f" Backend: {line.strip()}") + + threading.Thread(target=read_backend_output, daemon=True).start() + # Wait for backend to start print(" Waiting for backend to start...") for _ in range(30): # 30 second timeout @@ -209,7 +221,7 @@ def read_output(): print(" āŒ Frontend failed to start") cleanup() -def monitor_processes(backend_proc, frontend_proc): +def monitor_processes(): """Monitor running processes and show their output""" print("\n" + "="*60) print("šŸŽÆ Alex Financial Advisor - Local Development") @@ -229,15 +241,11 @@ def monitor_processes(backend_proc, frontend_proc): # Monitor processes while True: - # Backend stdout has no other reader. Frontend's stdout is already - # drained by the background thread started in start_frontend, so - # don't read it again here (that would race with that thread). - try: - line = backend_proc.stdout.readline() - if line: - print(f"[LOG] {line.strip()}") - except: - pass + # Both backend and frontend stdout are drained by their own background + # reader threads (started in start_backend/start_frontend), so this + # loop doesn't read their output itself - doing so would race with + # those threads and reintroduce the pipe-buffer deadlock they exist to + # prevent. # On Windows, the intermediate wrapper process (uv/cmd.exe) can exit # on its own while the real server underneath keeps running, so we @@ -288,12 +296,12 @@ def main(): subprocess.run(["uv", "add", "httpx"], check=True) # Start services - backend_proc = start_backend() - frontend_proc = start_frontend() + start_backend() + start_frontend() # Monitor processes try: - monitor_processes(backend_proc, frontend_proc) + monitor_processes() except KeyboardInterrupt: cleanup()