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/.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/backend/charter/lambda_handler.py b/backend/charter/lambda_handler.py index 1db93b70..148f3f78 100644 --- a/backend/charter/lambda_handler.py +++ b/backend/charter/lambda_handler.py @@ -134,31 +134,44 @@ 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 + job = 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: # 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) @@ -212,13 +225,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..ea6696b4 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,34 +121,42 @@ 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 + job = 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: # 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"] - - 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 +202,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 +225,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..2da27740 100644 --- a/backend/retirement/lambda_handler.py +++ b/backend/retirement/lambda_handler.py @@ -130,39 +130,47 @@ 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 + job = None + db = None + try: + db = Database() + job = db.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: # 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: - 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 +224,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/scripts/run_local.py b/scripts/run_local.py index 31ddefe7..d855c08a 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() @@ -110,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 @@ -172,7 +199,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) @@ -202,21 +233,49 @@ 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() + # 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 + # 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) @@ -237,8 +296,8 @@ 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: 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/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" 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