diff --git a/QUICK_START_LOGGING.md b/QUICK_START_LOGGING.md new file mode 100644 index 000000000..077cc9cc9 --- /dev/null +++ b/QUICK_START_LOGGING.md @@ -0,0 +1,223 @@ +# ๐Ÿš€ Quick Start: Using OpenTelemetry Logging in Your CLI + +## โšก TL;DR - 3 Step Process + +1. **Import the logger**: `from codegen.shared.logging.get_logger import get_logger` +2. **Add `extra={}` to your log calls**: `logger.info("message", extra={"key": "value"})` +3. **Enable telemetry**: `codegen config telemetry enable` + +**That's it!** Your logs automatically go to Grafana Cloud when telemetry is enabled. + +## ๐ŸŽฏ Immediate Actions You Can Take + +### 1. Quick Enhancement of Existing Commands + +Pick **any existing CLI command** and add 2-3 lines: + +```python +# Add this import at the top +from codegen.shared.logging.get_logger import get_logger + +# Add this line after imports +logger = get_logger(__name__) + +# Find any existing console.print() or error handling and add: +logger.info("Operation completed", extra={ + "operation": "command_name", + "org_id": org_id, # if available + "success": True +}) +``` + +### 2. Test the Integration Right Now + +```bash +# 1. Enable telemetry +codegen config telemetry enable + +# 2. Run the demo +python example_enhanced_agent_command.py + +# 3. Run any CLI command +codegen agents # or any other command + +# 4. Check status +codegen config telemetry status +``` + +## ๐Ÿ“ Copy-Paste Patterns + +### Pattern 1: Operation Start/End +```python +logger = get_logger(__name__) + +# At start of function +logger.info("Operation started", extra={ + "operation": "command.subcommand", + "user_input": relevant_input +}) + +# At end of function +logger.info("Operation completed", extra={ + "operation": "command.subcommand", + "success": True +}) +``` + +### Pattern 2: Error Handling +```python +try: + # your existing code + pass +except SomeSpecificError as e: + logger.error("Specific error occurred", extra={ + "operation": "command.subcommand", + "error_type": "specific_error", + "error_details": str(e) + }, exc_info=True) + # your existing error handling +``` + +### Pattern 3: API Calls +```python +# Before API call +logger.info("Making API request", extra={ + "operation": "api.request", + "endpoint": "agent/run", + "org_id": org_id +}) + +# After successful API call +logger.info("API request successful", extra={ + "operation": "api.request", + "endpoint": "agent/run", + "response_id": response.get("id"), + "status_code": response.status_code +}) +``` + +## ๐ŸŽฏ What to Log (Priority Order) + +### ๐Ÿ”ฅ High Priority (Add These First) +- **Operation start/end**: When commands begin/complete +- **API calls**: Requests to your backend +- **Authentication events**: Login/logout/token issues +- **Errors**: Any exception or failure +- **User actions**: Commands run, options selected + +### โญ Medium Priority +- **Performance**: Duration of operations +- **State changes**: Status updates, configuration changes +- **External tools**: Claude CLI detection, git operations + +### ๐Ÿ’ก Low Priority (Nice to Have) +- **Debug info**: Internal state, validation steps +- **User behavior**: Which features are used most + +## ๐Ÿ”ง Minimal Changes to Existing Commands + +### Example: Enhance agent/main.py + +```python +# Just add these 3 lines to your existing create() function: + +from codegen.shared.logging.get_logger import get_logger +logger = get_logger(__name__) + +def create(prompt: str, org_id: int | None = None, ...): + """Create a new agent run with the given prompt.""" + + # ADD: Log start + logger.info("Agent creation started", extra={ + "operation": "agent.create", + "org_id": org_id, + "prompt_length": len(prompt) + }) + + # Your existing code... + try: + response = requests.post(url, headers=headers, json=payload) + agent_run_data = response.json() + + # ADD: Log success + logger.info("Agent created successfully", extra={ + "operation": "agent.create", + "agent_run_id": agent_run_data.get("id"), + "status": agent_run_data.get("status") + }) + + except requests.RequestException as e: + # ADD: Log error + logger.error("Agent creation failed", extra={ + "operation": "agent.create", + "error_type": "api_error", + "error": str(e) + }) + # Your existing error handling... +``` + +### Example: Enhance claude/main.py + +```python +# Add to your _run_claude_interactive function: + +logger = get_logger(__name__) + +def _run_claude_interactive(resolved_org_id: int, no_mcp: bool | None) -> None: + session_id = generate_session_id() + + # ADD: Log session start + logger.info("Claude session started", extra={ + "operation": "claude.session_start", + "session_id": session_id[:8], # Short version for privacy + "org_id": resolved_org_id + }) + + # Your existing code... + + try: + process = subprocess.Popen([claude_path, "--session-id", session_id]) + returncode = process.wait() + + # ADD: Log session end + logger.info("Claude session completed", extra={ + "operation": "claude.session_complete", + "session_id": session_id[:8], + "exit_code": returncode, + "status": "COMPLETE" if returncode == 0 else "ERROR" + }) + + except Exception as e: + # ADD: Log session error + logger.error("Claude session failed", extra={ + "operation": "claude.session_error", + "session_id": session_id[:8], + "error": str(e) + }) +``` + +## ๐Ÿงช Verification + +After making changes: + +1. **Run the command**: Execute your enhanced CLI command +2. **Check telemetry status**: `codegen config telemetry status` +3. **Look for logs in Grafana Cloud**: Search for your operation names +4. **Test with telemetry disabled**: `codegen config telemetry disable` - should still work normally + +## ๐Ÿš€ Progressive Enhancement + +**Week 1**: Add basic operation logging to 2-3 commands +**Week 2**: Add error logging to all commands +**Week 3**: Add performance metrics and detailed context +**Week 4**: Create Grafana dashboards using the collected data + +## ๐ŸŽ‰ Benefits You'll See Immediately + +- **Real usage data**: Which commands are used most? +- **Error tracking**: What breaks and how often? +- **Performance insights**: Which operations are slow? +- **User behavior**: How do users actually use your CLI? +- **Debugging**: Rich context when things go wrong + +Start with just **one command** and **one log line** - you'll see the value immediately! ๐ŸŽฏ diff --git a/pyproject.toml b/pyproject.toml index c987c3489..738e2d43f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,10 @@ dependencies = [ "unidiff>=0.7.5", "datamodel-code-generator>=0.26.5", "fastmcp>=2.9.0", + # OpenTelemetry logging dependencies + "opentelemetry-api>=1.26.0", + "opentelemetry-sdk>=1.26.0", + "opentelemetry-exporter-otlp>=1.26.0", # Utility dependencies "colorlog>=6.9.0", "psutil>=5.8.0", diff --git a/src/codegen/cli/cli.py b/src/codegen/cli/cli.py index c7a49ae24..ab19f73ae 100644 --- a/src/codegen/cli/cli.py +++ b/src/codegen/cli/cli.py @@ -1,13 +1,11 @@ +import atexit + import typer from rich.traceback import install from codegen import __version__ - -# Import config command (still a Typer app) from codegen.cli.commands.agent.main import agent from codegen.cli.commands.agents.main import agents_app - -# Import the actual command functions from codegen.cli.commands.claude.main import claude from codegen.cli.commands.config.main import config_command from codegen.cli.commands.init.main import init @@ -21,13 +19,39 @@ from codegen.cli.commands.tools.main import tools from codegen.cli.commands.tui.main import tui from codegen.cli.commands.update.main import update +from codegen.shared.logging.get_logger import get_logger + +# Initialize logger for CLI command tracking +logger = get_logger(__name__) + +# Set up global exception logging early +try: + from codegen.cli.telemetry.exception_logger import setup_global_exception_logging + + setup_global_exception_logging() +except ImportError: + # Exception logging dependencies not available - continue without it + pass + install(show_locals=True) +# Register telemetry shutdown on exit +try: + from codegen.cli.telemetry.exception_logger import teardown_global_exception_logging + from codegen.cli.telemetry.otel_setup import shutdown_otel_logging + + atexit.register(shutdown_otel_logging) + atexit.register(teardown_global_exception_logging) +except ImportError: + # OTel dependencies not available + pass + def version_callback(value: bool): """Print version and exit.""" if value: + logger.info("Version command invoked", extra={"operation": "cli.version", "version": __version__}) print(__version__) raise typer.Exit() @@ -35,21 +59,20 @@ def version_callback(value: bool): # Create the main Typer app main = typer.Typer(name="codegen", help="Codegen - the Operating System for Code Agents.", rich_markup_mode="rich") -# Add individual commands to the main app +# Add individual commands to the main app (logging now handled within each command) main.command("agent", help="Create a new agent run with a prompt.")(agent) main.command("claude", help="Run Claude Code with OpenTelemetry monitoring and logging.")(claude) main.command("init", help="Initialize or update the Codegen folder.")(init) main.command("login", help="Store authentication token.")(login) main.command("logout", help="Clear stored authentication token.")(logout) main.command("org", help="Manage and switch between organizations.")(org) -# Profile is now a Typer app main.command("repo", help="Manage repository configuration and environment variables.")(repo) main.command("style-debug", help="Debug command to visualize CLI styling (spinners, etc).")(style_debug) main.command("tools", help="List available tools from the Codegen API.")(tools) main.command("tui", help="Launch the interactive TUI interface.")(tui) main.command("update", help="Update Codegen to the latest or specified version")(update) -# Add Typer apps as sub-applications +# Add Typer apps as sub-applications (these will handle their own sub-command logging) main.add_typer(agents_app, name="agents") main.add_typer(config_command, name="config") main.add_typer(integrations_app, name="integrations") @@ -61,9 +84,13 @@ def main_callback(ctx: typer.Context, version: bool = typer.Option(False, "--ver """Codegen - the Operating System for Code Agents""" if ctx.invoked_subcommand is None: # No subcommand provided, launch TUI + logger.info("CLI launched without subcommand - starting TUI", extra={"operation": "cli.main", "action": "default_tui_launch", "command": "codegen"}) from codegen.cli.tui.app import run_tui run_tui() + else: + # Log when a subcommand is being invoked + logger.debug("CLI main callback with subcommand", extra={"operation": "cli.main", "subcommand": ctx.invoked_subcommand, "command": f"codegen {ctx.invoked_subcommand}"}) if __name__ == "__main__": diff --git a/src/codegen/cli/commands/agents/main.py b/src/codegen/cli/commands/agents/main.py index 69c2dbda4..2be9a13df 100644 --- a/src/codegen/cli/commands/agents/main.py +++ b/src/codegen/cli/commands/agents/main.py @@ -9,6 +9,10 @@ from codegen.cli.auth.token_manager import get_current_token from codegen.cli.rich.spinners import create_spinner from codegen.cli.utils.org import resolve_org_id +from codegen.shared.logging.get_logger import get_logger + +# Initialize logger +logger = get_logger(__name__) console = Console() @@ -19,9 +23,12 @@ @agents_app.command("list") def list_agents(org_id: int | None = typer.Option(None, help="Organization ID (defaults to CODEGEN_ORG_ID/REPOSITORY_ORG_ID or auto-detect)")): """List agent runs from the Codegen API.""" + logger.info("Agents list command invoked", extra={"operation": "agents.list", "org_id": org_id, "command": "codegen agents list"}) + # Get the current token token = get_current_token() if not token: + logger.error("Agents list failed - not authenticated", extra={"operation": "agents.list", "error_type": "not_authenticated"}) console.print("[red]Error:[/red] Not authenticated. Please run 'codegen login' first.") raise typer.Exit(1) diff --git a/src/codegen/cli/commands/claude/main.py b/src/codegen/cli/commands/claude/main.py index afe69e504..41e532d42 100644 --- a/src/codegen/cli/commands/claude/main.py +++ b/src/codegen/cli/commands/claude/main.py @@ -5,6 +5,7 @@ import signal import subprocess import sys +import time import requests import typer @@ -26,14 +27,38 @@ from codegen.cli.commands.claude.utils import resolve_claude_path from codegen.cli.rich.spinners import create_spinner from codegen.cli.utils.org import resolve_org_id +from codegen.shared.logging.get_logger import get_logger + +# Initialize logger +logger = get_logger(__name__) + + +def _get_session_context() -> dict: + """Get session context for logging.""" + try: + from codegen.cli.telemetry.otel_setup import get_session_uuid + + return {"session_id": get_session_uuid()} + except ImportError: + return {} + t_console = Console() def _run_claude_background(resolved_org_id: int, prompt: str | None) -> None: """Create a background agent run with Claude context and exit.""" + logger.info( + "Claude background run started", + extra={"operation": "claude.background", "org_id": resolved_org_id, "prompt_length": len(prompt) if prompt else 0, "command": "codegen claude --background", **_get_session_context()}, + ) + + start_time = time.time() token = get_current_token() if not token: + logger.error( + "Claude background run failed - not authenticated", extra={"operation": "claude.background", "org_id": resolved_org_id, "error_type": "not_authenticated", **_get_session_context()} + ) console.print("[red]Error:[/red] Not authenticated. Please run 'codegen login' first.") raise typer.Exit(1) @@ -51,6 +76,32 @@ def _run_claude_background(resolved_org_id: int, prompt: str | None) -> None: response = requests.post(url, headers=headers, json=payload) response.raise_for_status() agent_run_data = response.json() + + duration_ms = (time.time() - start_time) * 1000 + run_id = agent_run_data.get("id", "Unknown") + status = agent_run_data.get("status", "Unknown") + + logger.info( + "Claude background run created successfully", + extra={"operation": "claude.background", "org_id": resolved_org_id, "agent_run_id": run_id, "status": status, "duration_ms": duration_ms, "success": True, **_get_session_context()}, + ) + + except Exception as e: + duration_ms = (time.time() - start_time) * 1000 + logger.error( + "Claude background run failed", + extra={ + "operation": "claude.background", + "org_id": resolved_org_id, + "error_type": type(e).__name__, + "error_message": str(e), + "duration_ms": duration_ms, + "success": False, + **_get_session_context(), + }, + exc_info=True, + ) + raise finally: spinner.stop() @@ -83,6 +134,12 @@ def _run_claude_interactive(resolved_org_id: int, no_mcp: bool | None) -> None: """Launch Claude Code with session tracking and log watching.""" # Generate session ID for tracking session_id = generate_session_id() + + logger.info( + "Claude interactive session started", + extra={"operation": "claude.interactive", "org_id": resolved_org_id, "claude_session_id": session_id, "mcp_disabled": bool(no_mcp), "command": "codegen claude", **_get_session_context()}, + ) + console.print(f"๐Ÿ†” Generated session ID: {session_id[:8]}...", style="dim") console.print("๐Ÿš€ Starting Claude Code with session tracking...", style="blue") @@ -129,6 +186,10 @@ def _run_claude_interactive(resolved_org_id: int, no_mcp: bool | None) -> None: # Resolve Claude CLI path and test accessibility claude_path = resolve_claude_path() if not claude_path: + logger.error( + "Claude CLI not found", + extra={"operation": "claude.interactive", "org_id": resolved_org_id, "claude_session_id": session_id, "error_type": "claude_cli_not_found", **_get_session_context()}, + ) console.print("โŒ Claude Code CLI not found.", style="red") console.print( "๐Ÿ’ก If you migrated a local install, ensure `~/.claude/local/claude` exists, or add it to PATH.", @@ -202,21 +263,71 @@ def signal_handler(signum, frame): update_claude_session_status(session_id, session_status, resolved_org_id) if returncode != 0: + logger.error( + "Claude interactive session failed", + extra={ + "operation": "claude.interactive", + "org_id": resolved_org_id, + "claude_session_id": session_id, + "exit_code": returncode, + "session_status": session_status, + **_get_session_context(), + }, + ) console.print(f"โŒ Claude Code exited with error code {returncode}", style="red") else: + logger.info( + "Claude interactive session completed successfully", + extra={ + "operation": "claude.interactive", + "org_id": resolved_org_id, + "claude_session_id": session_id, + "exit_code": returncode, + "session_status": session_status, + **_get_session_context(), + }, + ) console.print("โœ… Claude Code finished successfully", style="green") except FileNotFoundError: + logger.error( + "Claude Code executable not found", + extra={"operation": "claude.interactive", "org_id": resolved_org_id, "claude_session_id": session_id, "error_type": "claude_executable_not_found", **_get_session_context()}, + ) console.print("โŒ Claude Code not found. Please install Claude Code first.", style="red") console.print("๐Ÿ’ก Visit: https://claude.ai/download", style="dim") log_watcher_manager.stop_all_watchers() update_claude_session_status(session_id, "ERROR", resolved_org_id) raise typer.Exit(1) except KeyboardInterrupt: + logger.info( + "Claude interactive session interrupted by user", + extra={ + "operation": "claude.interactive", + "org_id": resolved_org_id, + "claude_session_id": session_id, + "session_status": "CANCELLED", + "exit_reason": "user_interrupt", + **_get_session_context(), + }, + ) console.print("\n๐Ÿ›‘ Interrupted by user", style="yellow") log_watcher_manager.stop_all_watchers() update_claude_session_status(session_id, "CANCELLED", resolved_org_id) except Exception as e: + logger.error( + "Claude interactive session error", + extra={ + "operation": "claude.interactive", + "org_id": resolved_org_id, + "claude_session_id": session_id, + "error_type": type(e).__name__, + "error_message": str(e), + "session_status": "ERROR", + **_get_session_context(), + }, + exc_info=True, + ) console.print(f"โŒ Error running Claude Code: {e}", style="red") log_watcher_manager.stop_all_watchers() update_claude_session_status(session_id, "ERROR", resolved_org_id) @@ -244,16 +355,49 @@ def claude( background: str | None = typer.Option(None, "--background", "-b", help="Create a background agent run with this prompt instead of launching Claude Code"), ): """Run Claude Code with session tracking or create a background run.""" + logger.info( + "Claude command invoked", + extra={ + "operation": "claude.command", + "org_id": org_id, + "no_mcp": bool(no_mcp), + "is_background": background is not None, + "background_prompt_length": len(background) if background else 0, + "command": f"codegen claude{' --background' if background else ''}", + **_get_session_context(), + }, + ) + # Resolve org_id early for session management resolved_org_id = resolve_org_id(org_id) if resolved_org_id is None: + logger.error("Claude command failed - no org ID", extra={"operation": "claude.command", "error_type": "org_id_missing", **_get_session_context()}) console.print("[red]Error:[/red] Organization ID not provided. Pass --org-id, set CODEGEN_ORG_ID, or REPOSITORY_ORG_ID.") raise typer.Exit(1) - if background is not None: - # Use the value from --background as the prompt - final_prompt = background - _run_claude_background(resolved_org_id, final_prompt) - return + try: + if background is not None: + # Use the value from --background as the prompt + final_prompt = background + _run_claude_background(resolved_org_id, final_prompt) + return - _run_claude_interactive(resolved_org_id, no_mcp) + _run_claude_interactive(resolved_org_id, no_mcp) + + except typer.Exit: + # Let typer exits pass through without additional logging + raise + except Exception as e: + logger.error( + "Claude command failed unexpectedly", + extra={ + "operation": "claude.command", + "org_id": resolved_org_id, + "error_type": type(e).__name__, + "error_message": str(e), + "is_background": background is not None, + **_get_session_context(), + }, + exc_info=True, + ) + raise diff --git a/src/codegen/cli/commands/config/main.py b/src/codegen/cli/commands/config/main.py index ea03409c1..efff72d78 100644 --- a/src/codegen/cli/commands/config/main.py +++ b/src/codegen/cli/commands/config/main.py @@ -4,17 +4,26 @@ import typer from rich.table import Table +from codegen.cli.commands.config.telemetry import telemetry_app from codegen.configs.constants import ENV_FILENAME, GLOBAL_ENV_FILE from codegen.configs.user_config import UserConfig +from codegen.shared.logging.get_logger import get_logger from codegen.shared.path import get_git_root_path +# Initialize logger for config commands +logger = get_logger(__name__) + # Create a Typer app for the config command config_command = typer.Typer(help="Manage codegen configuration.") +# Add telemetry subcommands +config_command.add_typer(telemetry_app, name="telemetry") + @config_command.command(name="list") def list_config(): """List current configuration values.""" + logger.info("Config list command invoked", extra={"operation": "config.list", "command": "codegen config list"}) def flatten_dict(data: dict, prefix: str = "") -> dict: items = {} @@ -79,12 +88,16 @@ def flatten_dict(data: dict, prefix: str = "") -> dict: @config_command.command(name="get") def get_config(key: str = typer.Argument(..., help="Configuration key to get")): """Get a configuration value.""" + logger.info("Config get command invoked", extra={"operation": "config.get", "key": key, "command": f"codegen config get {key}"}) + config = _get_user_config() if not config.has_key(key): + logger.warning("Config key not found", extra={"operation": "config.get", "key": key, "error_type": "key_not_found"}) rich.print(f"[red]Error: Configuration key '{key}' not found[/red]") return value = config.get(key) + # Don't log debug info for successful value retrieval - focus on user actions rich.print(f"[cyan]{key}[/cyan]=[magenta]{value}[/magenta]") diff --git a/src/codegen/cli/commands/config/telemetry.py b/src/codegen/cli/commands/config/telemetry.py new file mode 100644 index 000000000..9aed6d204 --- /dev/null +++ b/src/codegen/cli/commands/config/telemetry.py @@ -0,0 +1,156 @@ +"""Telemetry configuration commands.""" + +import json +from pathlib import Path + +import typer +from rich.console import Console +from rich.panel import Panel +from rich.syntax import Syntax +from rich.table import Table + +from codegen.cli.telemetry import update_telemetry_consent +from codegen.configs.constants import GLOBAL_CONFIG_DIR, GLOBAL_ENV_FILE +from codegen.configs.models.telemetry import TelemetryConfig + +console = Console() + +# Create the telemetry sub-app +telemetry_app = typer.Typer(help="Manage telemetry settings") + + +@telemetry_app.command() +def enable(): + """Enable telemetry data collection.""" + update_telemetry_consent(enabled=True) + + +@telemetry_app.command() +def disable(): + """Disable telemetry data collection.""" + update_telemetry_consent(enabled=False) + + +@telemetry_app.command() +def status(): + """Show current telemetry settings.""" + telemetry = TelemetryConfig(env_filepath=GLOBAL_ENV_FILE) + + table = Table(title="Telemetry Settings", show_header=False) + table.add_column("Setting", style="cyan") + table.add_column("Value", style="white") + + table.add_row("Enabled", "โœ… Yes" if telemetry.enabled else "โŒ No") + table.add_row("Debug Mode", "Yes" if telemetry.debug else "No") + + console.print(table) + console.print("\n[dim]Telemetry helps us improve the CLI experience.[/dim]") + console.print("[dim]No personal information or source code is collected.[/dim]") + + +@telemetry_app.command() +def debug( + enable: bool = typer.Option(None, "--enable/--disable", help="Enable or disable debug mode"), + show_logs: bool = typer.Option(False, "--logs", help="Show recent debug logs"), + clear: bool = typer.Option(False, "--clear", help="Clear debug logs"), +): + """Manage telemetry debug mode and logs.""" + telemetry = TelemetryConfig(env_filepath=GLOBAL_ENV_FILE) + debug_dir = GLOBAL_CONFIG_DIR / "telemetry_debug" + + # Handle enable/disable + if enable is not None: + telemetry.debug = enable + telemetry.write_to_file(GLOBAL_ENV_FILE) + + # Refresh logging configuration to immediately apply the debug mode change + try: + from codegen.shared.logging.get_logger import refresh_telemetry_config + + refresh_telemetry_config() + except ImportError: + pass # Logging refresh not available + + console.print(f"[green]โœ“ Debug mode {'enabled' if enable else 'disabled'}[/green]") + if enable: + console.print(f"[dim]Debug logs will be written to: {debug_dir}[/dim]") + console.print("[dim]Console logging will now be enabled for all CLI operations[/dim]") + else: + console.print("[dim]Console logging will now be disabled for CLI operations[/dim]") + + # Handle clear + if clear: + if debug_dir.exists(): + import shutil + + shutil.rmtree(debug_dir) + console.print("[green]โœ“ Debug logs cleared[/green]") + else: + console.print("[yellow]No debug logs to clear[/yellow]") + return + + # Handle show logs + if show_logs: + if not debug_dir.exists(): + console.print("[yellow]No debug logs found[/yellow]") + return + + # Find most recent session file + session_files = sorted(debug_dir.glob("session_*.jsonl"), reverse=True) + if not session_files: + console.print("[yellow]No debug sessions found[/yellow]") + return + + latest_file = session_files[0] + console.print(f"\n[cyan]Latest session:[/cyan] {latest_file.name}") + + # Read and display spans + with open(latest_file) as f: + spans = [] + for line in f: + data = json.loads(line) + if data["type"] == "span": + spans.append(data) + + if not spans: + console.print("[yellow]No spans recorded in this session[/yellow]") + return + + # Create table + table = Table(title=f"Telemetry Spans ({len(spans)} total)") + table.add_column("Operation", style="cyan") + table.add_column("Duration (ms)", style="green") + table.add_column("Status", style="yellow") + table.add_column("Key Attributes", style="white") + + for span in spans[-10:]: # Show last 10 spans + duration = f"{span.get('duration_ms', 0):.2f}" if span.get("duration_ms") else "N/A" + status = span["status"]["status_code"] + + # Extract key attributes + attrs = span.get("attributes", {}) + key_attrs = [] + for key in ["cli.command.name", "cli.operation.name", "event.name"]: + if key in attrs: + key_attrs.append(f"{key.split('.')[-1]}: {attrs[key]}") + + table.add_row(span["name"], duration, status, "\n".join(key_attrs[:2]) if key_attrs else "") + + console.print(table) + console.print(f"\n[dim]Full logs available at: {latest_file}[/dim]") + + # If no action specified, show current status + if enable is None and not show_logs and not clear: + console.print(f"Debug mode: {'[green]Enabled[/green]' if telemetry.debug else '[red]Disabled[/red]'}") + if debug_dir.exists(): + log_count = len(list(debug_dir.glob("session_*.jsonl"))) + console.print(f"Debug sessions: {log_count}") + console.print(f"Debug directory: {debug_dir}") + + +@telemetry_app.callback(invoke_without_command=True) +def telemetry_callback(ctx: typer.Context): + """Manage telemetry settings.""" + if ctx.invoked_subcommand is None: + # If no subcommand is provided, show status + status() diff --git a/src/codegen/cli/commands/init/main.py b/src/codegen/cli/commands/init/main.py index 58da4ac62..3d6b4d421 100644 --- a/src/codegen/cli/commands/init/main.py +++ b/src/codegen/cli/commands/init/main.py @@ -5,8 +5,12 @@ from codegen.cli.auth.session import CodegenSession from codegen.cli.rich.codeblocks import format_command +from codegen.shared.logging.get_logger import get_logger from codegen.shared.path import get_git_root_path +# Initialize logger +logger = get_logger(__name__) + def init( path: str | None = typer.Option(None, help="Path within a git repository. Defaults to the current directory."), @@ -15,8 +19,11 @@ def init( fetch_docs: bool = typer.Option(False, "--fetch-docs", help="Fetch docs and examples (requires auth)"), ): """Initialize or update the Codegen folder.""" + logger.info("Init command started", extra={"operation": "init", "path": path, "language": language, "fetch_docs": fetch_docs, "has_token": bool(token)}) + # Validate language option if language and language.lower() not in ["python", "typescript"]: + logger.error("Invalid language specified", extra={"operation": "init", "language": language, "error_type": "invalid_language"}) rich.print(f"[bold red]Error:[/bold red] Invalid language '{language}'. Must be 'python' or 'typescript'.") raise typer.Exit(1) @@ -26,6 +33,7 @@ def init( rich.print(f"Found git repository at: {repo_path}") if repo_path is None: + logger.error("Not in a git repository", extra={"operation": "init", "path": str(path_obj), "error_type": "not_git_repo"}) rich.print(f"\n[bold red]Error:[/bold red] Path={path_obj} is not in a git repository") rich.print("[white]Please run this command from within a git repository.[/white]") rich.print("\n[dim]To initialize a new git repository:[/dim]") @@ -35,17 +43,38 @@ def init( # At this point, repo_path is guaranteed to be not None assert repo_path is not None + + # Session creation details not needed in logs + session = CodegenSession(repo_path=repo_path, git_token=token) if language: session.config.repository.language = language.upper() session.config.save() + # Language override details included in completion log action = "Updating" if session.existing else "Initializing" + logger.info( + "Codegen session created", + extra={"operation": "init", "repo_path": str(repo_path), "action": action.lower(), "existing": session.existing, "language": getattr(session.config.repository, "language", None)}, + ) + # Create the codegen directory codegen_dir = session.codegen_dir codegen_dir.mkdir(parents=True, exist_ok=True) + logger.info( + "Init completed successfully", + extra={ + "operation": "init", + "repo_path": str(repo_path), + "codegen_dir": str(codegen_dir), + "action": action.lower(), + "language": getattr(session.config.repository, "language", None), + "fetch_docs": fetch_docs, + }, + ) + # Print success message rich.print(f"โœ… {action} complete\n") rich.print(f"Codegen workspace initialized at: [bold]{codegen_dir}[/bold]") diff --git a/src/codegen/cli/commands/login/main.py b/src/codegen/cli/commands/login/main.py index 688642ac6..6953a29fc 100644 --- a/src/codegen/cli/commands/login/main.py +++ b/src/codegen/cli/commands/login/main.py @@ -2,12 +2,36 @@ from codegen.cli.auth.login import login_routine from codegen.cli.auth.token_manager import get_current_token +from codegen.shared.logging.get_logger import get_logger + +# Initialize logger +logger = get_logger(__name__) + + +def _get_session_context() -> dict: + """Get session context for logging.""" + try: + from codegen.cli.telemetry.otel_setup import get_session_uuid + + return {"session_id": get_session_uuid()} + except ImportError: + return {} def login(token: str | None = typer.Option(None, help="API token for authentication")): """Store authentication token.""" + extra = {"operation": "auth.login", "has_provided_token": bool(token), "command": "codegen login", **_get_session_context()} + logger.info("Login command invoked", extra=extra) + # Check if already authenticated - if get_current_token(): + current_token = get_current_token() + if current_token: + logger.debug("User already authenticated", extra={"operation": "auth.login", "already_authenticated": True, **_get_session_context()}) pass # Just proceed silently with re-authentication - login_routine(token) + try: + login_routine(token) + logger.info("Login completed successfully", extra={"operation": "auth.login", "success": True, **_get_session_context()}) + except Exception as e: + logger.error("Login failed", extra={"operation": "auth.login", "error_type": type(e).__name__, "error_message": str(e), "success": False, **_get_session_context()}, exc_info=True) + raise diff --git a/src/codegen/cli/commands/logout/main.py b/src/codegen/cli/commands/logout/main.py index 8c17f966f..1a254f684 100644 --- a/src/codegen/cli/commands/logout/main.py +++ b/src/codegen/cli/commands/logout/main.py @@ -1,10 +1,21 @@ import rich from codegen.cli.auth.token_manager import TokenManager +from codegen.shared.logging.get_logger import get_logger + +# Initialize logger +logger = get_logger(__name__) def logout(): """Clear stored authentication token.""" - token_manager = TokenManager() - token_manager.clear_token() - rich.print("Successfully logged out") + logger.info("Logout command invoked", extra={"operation": "auth.logout", "command": "codegen logout"}) + + try: + token_manager = TokenManager() + token_manager.clear_token() + logger.info("Logout completed successfully", extra={"operation": "auth.logout", "success": True}) + rich.print("Successfully logged out") + except Exception as e: + logger.error("Logout failed", extra={"operation": "auth.logout", "error_type": type(e).__name__, "error_message": str(e), "success": False}, exc_info=True) + raise diff --git a/src/codegen/cli/telemetry/__init__.py b/src/codegen/cli/telemetry/__init__.py new file mode 100644 index 000000000..9de772a57 --- /dev/null +++ b/src/codegen/cli/telemetry/__init__.py @@ -0,0 +1,17 @@ +"""CLI telemetry module for analytics and observability.""" + +from codegen.cli.telemetry.consent import ( + ensure_telemetry_consent, + update_telemetry_consent, +) +from codegen.cli.telemetry.exception_logger import ( + setup_global_exception_logging, + teardown_global_exception_logging, +) + +__all__ = [ + "ensure_telemetry_consent", + "setup_global_exception_logging", + "teardown_global_exception_logging", + "update_telemetry_consent", +] diff --git a/src/codegen/cli/telemetry/consent.py b/src/codegen/cli/telemetry/consent.py new file mode 100644 index 000000000..f6bf6b6d9 --- /dev/null +++ b/src/codegen/cli/telemetry/consent.py @@ -0,0 +1,105 @@ +"""Telemetry consent management for the CLI.""" + +import uuid +from pathlib import Path + +import rich +import typer + +from codegen.configs.constants import GLOBAL_ENV_FILE +from codegen.configs.models.telemetry import TelemetryConfig + + +def prompt_telemetry_consent() -> bool: + """Prompt user for telemetry consent during first-time setup. + + Returns: + bool: True if user consents to telemetry, False otherwise + """ + # Display Codegen header + print("\033[38;2;82;19;217m" + "/" * 20 + " Codegen\033[0m") + print() + + rich.print("[bold]๐Ÿ“Š Help Improve Codegen CLI[/bold]") + rich.print( + "We'd like to collect anonymous usage data to improve the CLI experience.\n" + "This includes:\n" + " โ€ข Command usage patterns\n" + " โ€ข Performance metrics\n" + " โ€ข Error diagnostics (no source code or PII)\n" + " โ€ข CLI version and platform info\n" + ) + rich.print("[dim]You can change this setting anytime with 'codegen config telemetry'[/dim]\n") + + consent = typer.confirm("Enable anonymous telemetry?", default=False) + return consent + + +def ensure_telemetry_consent() -> TelemetryConfig: + """Ensure telemetry consent has been obtained and configured. + + This function: + 1. Loads existing telemetry config + 2. If not previously prompted, asks for consent + 3. Saves the configuration + + Returns: + TelemetryConfig: The telemetry configuration + """ + # Load telemetry config (uses global config file) + telemetry = TelemetryConfig(env_filepath=GLOBAL_ENV_FILE) + + # If already prompted, return existing config + if telemetry.consent_prompted: + return telemetry + + # Prompt for consent + consent = prompt_telemetry_consent() + + # Update configuration + telemetry.enabled = consent + telemetry.consent_prompted = True + + if consent: + rich.print("[green]โœ“ Telemetry enabled. Thank you for helping improve Codegen![/green]") + else: + rich.print("[yellow]โœ“ Telemetry disabled. You can enable it later with 'codegen config telemetry'[/yellow]") + + # Save to global config + telemetry.write_to_file(GLOBAL_ENV_FILE) + + # Refresh logging configuration to apply the new settings + try: + from codegen.shared.logging.get_logger import refresh_telemetry_config + + refresh_telemetry_config() + except ImportError: + pass # Logging refresh not available + + return telemetry + + +def update_telemetry_consent(enabled: bool) -> None: + """Update telemetry consent preference. + + Args: + enabled: Whether to enable telemetry + """ + telemetry = TelemetryConfig(env_filepath=GLOBAL_ENV_FILE) + telemetry.enabled = enabled + telemetry.consent_prompted = True + + telemetry.write_to_file(GLOBAL_ENV_FILE) + + # Refresh logging configuration to apply the new settings + try: + from codegen.shared.logging.get_logger import refresh_telemetry_config + + refresh_telemetry_config() + except ImportError: + pass # Logging refresh not available + + if enabled: + rich.print("[green]โœ“ Telemetry enabled[/green]") + else: + rich.print("[yellow]โœ“ Telemetry disabled[/yellow]") diff --git a/src/codegen/cli/telemetry/debug_exporter.py b/src/codegen/cli/telemetry/debug_exporter.py new file mode 100644 index 000000000..ec0e46742 --- /dev/null +++ b/src/codegen/cli/telemetry/debug_exporter.py @@ -0,0 +1,166 @@ +"""Debug exporter for OpenTelemetry that writes spans to local files. + +This module provides a debug exporter that writes telemetry data to disk +for easy inspection and debugging of CLI telemetry. +""" + +import json +import os +from collections.abc import Sequence +from datetime import datetime +from pathlib import Path + +from opentelemetry.sdk.trace import ReadableSpan +from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult +from opentelemetry.trace import format_span_id, format_trace_id + +from codegen.configs.constants import GLOBAL_CONFIG_DIR + + +class DebugFileSpanExporter(SpanExporter): + """Exports spans to JSON files for debugging.""" + + def __init__(self, output_dir: Path | None = None): + """Initialize the debug exporter. + + Args: + output_dir: Directory to write debug files. Defaults to ~/.config/codegen-sh/telemetry_debug + """ + if output_dir is None: + output_dir = GLOBAL_CONFIG_DIR / "telemetry_debug" + + self.output_dir = Path(output_dir) + self.output_dir.mkdir(parents=True, exist_ok=True) + + # Create a session file for this CLI run + self.session_id = datetime.now().strftime("%Y%m%d_%H%M%S") + self.session_file = self.output_dir / f"session_{self.session_id}.jsonl" + + # Write session header + with open(self.session_file, "w") as f: + f.write( + json.dumps( + { + "type": "session_start", + "timestamp": datetime.now().isoformat(), + "pid": os.getpid(), + } + ) + + "\n" + ) + + def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: + """Export spans to file. + + Args: + spans: Spans to export + + Returns: + Export result status + """ + try: + with open(self.session_file, "a") as f: + for span in spans: + # Convert span to JSON-serializable format + span_data = { + "type": "span", + "name": span.name, + "trace_id": format_trace_id(span.context.trace_id), + "span_id": format_span_id(span.context.span_id), + "parent_span_id": format_span_id(span.parent.span_id) if span.parent else None, + "start_time": span.start_time, + "end_time": span.end_time, + "duration_ms": (span.end_time - span.start_time) / 1_000_000 if span.end_time else None, + "status": { + "status_code": span.status.status_code.name, + "description": span.status.description, + }, + "attributes": dict(span.attributes or {}), + "events": [ + { + "name": event.name, + "timestamp": event.timestamp, + "attributes": dict(event.attributes or {}), + } + for event in span.events + ], + "resource": dict(span.resource.attributes), + } + + # Handle exceptions + if span.status.status_code.name == "ERROR" and span.events: + for event in span.events: + if event.name == "exception": + span_data["exception"] = dict(event.attributes or {}) + + f.write(json.dumps(span_data, default=str) + "\n") + + return SpanExportResult.SUCCESS + + except Exception as e: + print(f"[Telemetry Debug] Failed to write spans: {e}") + return SpanExportResult.FAILURE + + def shutdown(self) -> None: + """Shutdown the exporter.""" + # Write session end marker + try: + with open(self.session_file, "a") as f: + f.write( + json.dumps( + { + "type": "session_end", + "timestamp": datetime.now().isoformat(), + } + ) + + "\n" + ) + except Exception: + pass + + +class DebugConsoleSpanExporter(SpanExporter): + """Exports spans to console for debugging.""" + + def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: + """Export spans to console. + + Args: + spans: Spans to export + + Returns: + Export result status + """ + try: + for span in spans: + duration_ms = (span.end_time - span.start_time) / 1_000_000 if span.end_time else 0 + + print(f"\n[Telemetry] {span.name}") + print(f" Duration: {duration_ms:.2f}ms") + print(f" Status: {span.status.status_code.name}") + + if span.attributes: + print(" Attributes:") + for key, value in span.attributes.items(): + print(f" {key}: {value}") + + if span.events: + print(" Events:") + for event in span.events: + print(f" - {event.name}") + if event.attributes: + for key, value in event.attributes.items(): + print(f" {key}: {value}") + + if span.status.description: + print(f" Error: {span.status.description}") + + return SpanExportResult.SUCCESS + + except Exception as e: + print(f"[Telemetry Debug] Console export failed: {e}") + return SpanExportResult.FAILURE + + def shutdown(self) -> None: + """Shutdown the exporter.""" + pass diff --git a/src/codegen/cli/telemetry/exception_logger.py b/src/codegen/cli/telemetry/exception_logger.py new file mode 100644 index 000000000..72ecc2a15 --- /dev/null +++ b/src/codegen/cli/telemetry/exception_logger.py @@ -0,0 +1,176 @@ +"""Global exception logging for CLI telemetry. + +This module provides a global exception handler that captures unhandled exceptions +and logs them through the existing OpenTelemetry telemetry system. +""" + +import sys +import traceback +from typing import Any + +from codegen.shared.logging.get_logger import get_logger +from codegen.cli.telemetry.otel_setup import get_session_uuid, get_otel_logging_handler +from codegen.cli.telemetry.consent import ensure_telemetry_consent + +# Initialize logger for exception handling +logger = get_logger(__name__) + +# Store the original excepthook to allow chaining +_original_excepthook = sys.excepthook + + +def _get_exception_context(exc_type: type[BaseException], exc_value: BaseException, tb: Any) -> dict[str, Any]: + """Extract relevant context from an exception for logging. + + Args: + exc_type: The exception type + exc_value: The exception instance + tb: The traceback object + + Returns: + Dictionary with exception context for structured logging + """ + context = { + "operation": "cli.unhandled_exception", + "exception_type": exc_type.__name__, + "exception_message": str(exc_value), + "session_id": get_session_uuid(), + } + + # Add module and function information from the traceback + if tb is not None: + # Get the last frame (where the exception occurred) + last_frame = tb + while last_frame.tb_next is not None: + last_frame = last_frame.tb_next + + frame = last_frame.tb_frame + context.update( + { + "exception_file": frame.f_code.co_filename, + "exception_function": frame.f_code.co_name, + "exception_line": last_frame.tb_lineno, + } + ) + + # Get the full stack trace as a string + context["stack_trace"] = "".join(traceback.format_exception(exc_type, exc_value, tb)) + + # Add command context if available from CLI args + try: + # Try to extract command information from sys.argv + if len(sys.argv) > 1: + context["cli_command"] = sys.argv[1] + context["cli_args"] = sys.argv[2:] if len(sys.argv) > 2 else [] + except Exception: + # Don't let context extraction break exception logging + pass + + return context + + +def global_exception_handler(exc_type: type[BaseException], exc_value: BaseException, tb: Any) -> None: + """Global exception handler that logs unhandled exceptions. + + This function is designed to be set as sys.excepthook to capture all unhandled + exceptions in the CLI and log them through the telemetry system. + + Args: + exc_type: The exception type + exc_value: The exception instance + tb: The traceback object + """ + # Skip logging for KeyboardInterrupt (Ctrl+C) - this is expected user behavior + if issubclass(exc_type, KeyboardInterrupt): + # Call the original excepthook for normal handling + _original_excepthook(exc_type, exc_value, tb) + return + + # Skip logging for SystemExit with code 0 (normal exit) + if issubclass(exc_type, SystemExit) and getattr(exc_value, "code", None) == 0: + _original_excepthook(exc_type, exc_value, tb) + return + + try: + # Check telemetry configuration to determine logging behavior + telemetry_config = ensure_telemetry_consent() + + # Extract context for structured logging + context = _get_exception_context(exc_type, exc_value, tb) + + # Always send to telemetry backend if enabled (regardless of debug mode) + if telemetry_config.enabled: + # Get the OpenTelemetry handler for backend logging + otel_handler = get_otel_logging_handler() + if otel_handler: + # Create a separate logger that only sends to OTEL backend + import logging + + telemetry_logger = logging.getLogger("codegen.telemetry.exceptions") + telemetry_logger.setLevel(logging.ERROR) + + # Remove any existing handlers to avoid console output + telemetry_logger.handlers.clear() + telemetry_logger.addHandler(otel_handler) + telemetry_logger.propagate = False # Don't propagate to parent loggers + + # Log to telemetry backend only + telemetry_logger.error(f"Unhandled CLI exception: {exc_type.__name__}: {exc_value}", extra=context, exc_info=(exc_type, exc_value, tb)) + + # Only log to console if debug mode is enabled + if telemetry_config.debug: + logger.error(f"Unhandled CLI exception: {exc_type.__name__}: {exc_value}", extra=context, exc_info=(exc_type, exc_value, tb)) + logger.debug("Exception details logged for telemetry", extra={"operation": "cli.exception_logging", "session_id": get_session_uuid()}) + + except Exception as logging_error: + # If logging itself fails, at least print to stderr in debug mode or if telemetry is disabled + try: + telemetry_config = ensure_telemetry_consent() + if telemetry_config.debug or not telemetry_config.enabled: + print(f"Failed to log exception: {logging_error}", file=sys.stderr) + print(f"Original exception: {exc_type.__name__}: {exc_value}", file=sys.stderr) + except Exception: + # If even the telemetry config check fails, always print to stderr + print(f"Failed to log exception: {logging_error}", file=sys.stderr) + print(f"Original exception: {exc_type.__name__}: {exc_value}", file=sys.stderr) + + # Always call the original excepthook to preserve normal error handling behavior + _original_excepthook(exc_type, exc_value, tb) + + +def setup_global_exception_logging() -> None: + """Set up global exception logging by installing the custom excepthook. + + This should be called early in the CLI initialization to ensure all unhandled + exceptions are captured and logged. + """ + # Only install if not already installed (avoid double installation) + if sys.excepthook != global_exception_handler: + sys.excepthook = global_exception_handler + + # Only log setup message to console if debug mode is enabled + try: + telemetry_config = ensure_telemetry_consent() + if telemetry_config.debug: + logger.debug("Global exception logging enabled", extra={"operation": "cli.exception_logging_setup", "session_id": get_session_uuid()}) + except Exception: + # If we can't check telemetry config, silently continue + pass + + +def teardown_global_exception_logging() -> None: + """Restore the original exception handler. + + This can be called during cleanup to restore normal exception handling. + """ + if sys.excepthook == global_exception_handler: + sys.excepthook = _original_excepthook + + # Only log teardown message to console if debug mode is enabled + try: + telemetry_config = ensure_telemetry_consent() + if telemetry_config.debug: + logger.debug("Global exception logging disabled", extra={"operation": "cli.exception_logging_teardown", "session_id": get_session_uuid()}) + except Exception: + # If we can't check telemetry config, silently continue + pass diff --git a/src/codegen/cli/telemetry/otel_setup.py b/src/codegen/cli/telemetry/otel_setup.py new file mode 100644 index 000000000..e3acd934d --- /dev/null +++ b/src/codegen/cli/telemetry/otel_setup.py @@ -0,0 +1,300 @@ +"""Simple OpenTelemetry logging setup for CLI telemetry. + +This module provides a clean, minimal setup for sending CLI logs to the +OTLP collector when telemetry is enabled by the user. +""" + +import logging +import os +import platform +import subprocess +import sys +import uuid +from typing import Any + +from opentelemetry import _logs as logs +from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter +from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler +from opentelemetry.sdk._logs.export import BatchLogRecordProcessor +from opentelemetry.sdk.resources import Resource + +from codegen import __version__ +from codegen.cli.api.modal import get_modal_prefix +from codegen.cli.auth.token_manager import TokenManager +from codegen.cli.env.enums import Environment +from codegen.cli.env.global_env import global_env +from codegen.cli.utils.org import resolve_org_id +from codegen.configs.models.telemetry import TelemetryConfig + +# Global logger provider instance +_logger_provider: LoggerProvider | None = None + +# Global session UUID for this CLI invocation +_session_uuid: str = str(uuid.uuid4()) + + +def _get_otlp_logs_endpoint() -> tuple[str, dict[str, str]]: + """Get the OTLP logs endpoint and headers based on environment. + + This replicates the backend logic for determining the correct collector endpoint + based on whether we're running in Kubernetes or Modal environment. + + Returns: + Tuple of (endpoint_url, headers_dict) + """ + # Check if we're running in Kubernetes by looking for K8S_POD_NAME env var + k8s_pod_name = os.environ.get("K8S_POD_NAME") + if k8s_pod_name: + # Running in Kubernetes - use Grafana Alloy + return "http://grafana-monitoring-staging-alloy-receiver.monitoring.svc.cluster.local:4318/v1/logs", {} + + # Running in Modal - use Modal OTEL collector + modal_prefix = get_modal_prefix() + suffix = "otel-collector.modal.run" + + if global_env.ENV == Environment.PRODUCTION: + collector_endpoint = f"https://{modal_prefix}--{suffix}/cli/v1/logs" + elif global_env.ENV == Environment.STAGING: + collector_endpoint = f"https://{modal_prefix}--{suffix}/cli/v1/logs" + else: # DEVELOPMENT + collector_endpoint = f"https://{modal_prefix}--{suffix}/cli/v1/logs" + + # Create basic auth header for Modal collector + token_manager = TokenManager() + token = token_manager.get_token() + if not token: + # Return empty headers if no auth configured + return collector_endpoint, {} + + return collector_endpoint, {"Authorization": f"Bearer {token}"} + + +def _get_claude_info() -> dict[str, str]: + """Get Claude Code path and version information quickly.""" + claude_info = {} + + try: + # Use the same logic as the Claude command to find the CLI + # Import here to avoid circular imports + try: + from codegen.cli.commands.claude.utils import resolve_claude_path + + claude_path = resolve_claude_path() + except ImportError: + # Fallback to basic path detection if utils not available + claude_path = None + + # Quick check in PATH first + import shutil + + claude_path = shutil.which("claude") + + # If not found, check common local paths + if not claude_path: + local_path = os.path.expanduser("~/.claude/local/claude") + if os.path.isfile(local_path) and os.access(local_path, os.X_OK): + claude_path = local_path + + if claude_path: + claude_info["claude.path"] = claude_path + + # Only get version if we found the path - use short timeout + try: + version_result = subprocess.run( + [claude_path, "--version"], + capture_output=True, + text=True, + timeout=3, # Short timeout for telemetry setup + ) + if version_result.returncode == 0: + version_output = version_result.stdout.strip() + claude_info["claude.version"] = version_output if version_output else "unknown" + else: + claude_info["claude.version"] = "check_failed" + except (subprocess.TimeoutExpired, Exception): + claude_info["claude.version"] = "check_timeout" + else: + claude_info["claude.available"] = "false" + + except Exception: + # If anything fails, mark as error but don't break telemetry setup + claude_info["claude.available"] = "detection_error" + + return claude_info + + +def _create_cli_resource(telemetry_config: TelemetryConfig) -> Resource: + """Create OpenTelemetry resource with CLI-specific attributes.""" + global _session_uuid + + # Base service attributes + resource_attributes: dict[str, Any] = { + "service.name": "codegen-cli", + "service.version": __version__, + "session.id": _session_uuid, # Unique UUID for this CLI invocation + "os.type": platform.system().lower(), + "os.version": platform.version(), + "python.version": sys.version.split()[0], + } + + # Add user context if logged in + try: + # Try to get the current user ID (if authenticated) + auth_data = TokenManager().get_auth_data() + if auth_data: + user = auth_data.get("user") + if user: + resource_attributes["user.id"] = str(user.get("id")) + + organization = auth_data.get("organization") + if organization: + resource_attributes["organization.id"] = str(organization.get("id")) + resource_attributes["cli_session_id"] = _session_uuid + + except Exception: + # If user ID lookup fails, continue without it + pass + + # Add organization context if available + try: + org_id = resolve_org_id() + if org_id: + resource_attributes["org.id"] = str(org_id) + except Exception: + # If org ID lookup fails, continue without it + pass + + # Add environment context + if os.environ.get("CI"): + resource_attributes["deployment.environment"] = "ci" + elif os.environ.get("CODESPACES"): + resource_attributes["deployment.environment"] = "codespaces" + elif os.environ.get("GITPOD_WORKSPACE_ID"): + resource_attributes["deployment.environment"] = "gitpod" + else: + resource_attributes["deployment.environment"] = "local" + + # Add Claude Code information + claude_info = _get_claude_info() + resource_attributes.update(claude_info) + + return Resource.create(resource_attributes) + + +def setup_otel_logging() -> LoggerProvider | None: + """Set up OpenTelemetry logging if telemetry is enabled. + + Returns: + LoggerProvider if telemetry is enabled and setup succeeds, None otherwise + """ + global _logger_provider + + # Return cached provider if already set up + if _logger_provider is not None: + return _logger_provider + + # Ensure telemetry consent and load configuration + from codegen.cli.telemetry.consent import ensure_telemetry_consent + + telemetry_config = ensure_telemetry_consent() + + # Only set up if explicitly enabled + if not telemetry_config.enabled: + return None + + try: + # Create resource with CLI metadata + resource = _create_cli_resource(telemetry_config) + + # Create logger provider + logger_provider = LoggerProvider(resource=resource) + + # Get OTLP endpoint and headers + endpoint, headers = _get_otlp_logs_endpoint() + + # Create OTLP log exporter + log_exporter = OTLPLogExporter( + endpoint=endpoint, + headers=headers, + timeout=10, # 10 second timeout + ) + + # Create batch processor for performance + log_processor = BatchLogRecordProcessor( + log_exporter, + max_queue_size=1024, + max_export_batch_size=256, + export_timeout_millis=10000, # 10 seconds + schedule_delay_millis=2000, # Export every 2 seconds + ) + + logger_provider.add_log_record_processor(log_processor) + + # Set as global provider + logs.set_logger_provider(logger_provider) + _logger_provider = logger_provider + + # Debug output if enabled + if telemetry_config.debug: + print(f"[Telemetry] Logging initialized with endpoint: {endpoint}") + print(f"[Telemetry] Session UUID: {_session_uuid}") + # Show key resource attributes + resource_attrs = resource.attributes + if "user.id" in resource_attrs: + print(f"[Telemetry] User ID: {resource_attrs['user.id']}") + if "org.id" in resource_attrs: + print(f"[Telemetry] Org ID: {resource_attrs['org.id']}") + if "claude.path" in resource_attrs: + print(f"[Telemetry] Claude Path: {resource_attrs['claude.path']}") + if "claude.version" in resource_attrs: + print(f"[Telemetry] Claude Version: {resource_attrs['claude.version']}") + elif "claude.available" in resource_attrs: + print(f"[Telemetry] Claude Available: {resource_attrs['claude.available']}") + + return logger_provider + + except Exception as e: + if telemetry_config.debug: + print(f"[Telemetry] Failed to initialize logging: {e}") + return None + + +def get_otel_logging_handler() -> logging.Handler | None: + """Get an OpenTelemetry logging handler. + + This handler will send logs to the OTLP collector when telemetry is enabled. + + Returns: + LoggingHandler if telemetry is enabled, None otherwise + """ + logger_provider = setup_otel_logging() + if logger_provider is None: + return None + + # Create handler that bridges Python logging to OpenTelemetry + handler = LoggingHandler(level=logging.NOTSET, logger_provider=logger_provider) + return handler + + +def get_session_uuid() -> str: + """Get the session UUID for this CLI invocation. + + Returns: + The session UUID string that uniquely identifies this CLI run + """ + global _session_uuid + return _session_uuid + + +def shutdown_otel_logging(): + """Gracefully shutdown OpenTelemetry logging and flush pending data.""" + global _logger_provider + + if _logger_provider is not None: + try: + # Type checker workaround: assert that provider is not None after the check + assert _logger_provider is not None + _logger_provider.shutdown() + except Exception: + pass # Ignore shutdown errors + _logger_provider = None diff --git a/src/codegen/cli/telemetry/viewer.py b/src/codegen/cli/telemetry/viewer.py new file mode 100644 index 000000000..c9e229bb8 --- /dev/null +++ b/src/codegen/cli/telemetry/viewer.py @@ -0,0 +1,129 @@ +"""Simple telemetry log viewer for debugging. + +This script provides utilities for analyzing telemetry debug logs. +""" + +import json +from pathlib import Path +from typing import Any + +from rich.console import Console +from rich.tree import Tree + +from codegen.configs.constants import GLOBAL_CONFIG_DIR + + +def load_session(session_file: Path) -> list[dict[str, Any]]: + """Load all records from a session file.""" + records = [] + with open(session_file) as f: + for line in f: + records.append(json.loads(line)) + return records + + +def print_span_tree(spans: list[dict[str, Any]], console: Console): + """Print spans as a tree structure.""" + # Build parent-child relationships + span_by_id = {span["span_id"]: span for span in spans} + root_spans = [] + + for span in spans: + if not span.get("parent_span_id") or span["parent_span_id"] not in span_by_id: + root_spans.append(span) + + # Create tree + tree = Tree("Telemetry Trace Tree") + + def add_span_to_tree(span: dict[str, Any], parent_node): + """Recursively add span and its children to tree.""" + duration = span.get("duration_ms", 0) + status = span["status"]["status_code"] + status_icon = "โœ…" if status == "OK" else "โŒ" + + label = f"{status_icon} {span['name']} ({duration:.2f}ms)" + node = parent_node.add(label) + + # Add key attributes + attrs = span.get("attributes", {}) + for key, value in attrs.items(): + if key.startswith("cli.") or key.startswith("event."): + node.add(f"[dim]{key}: {value}[/dim]") + + # Find children + for other_span in spans: + if other_span.get("parent_span_id") == span["span_id"]: + add_span_to_tree(other_span, node) + + # Add root spans + for root_span in root_spans: + add_span_to_tree(root_span, tree) + + console.print(tree) + + +def analyze_session(session_file: Path): + """Analyze a telemetry session file.""" + console = Console() + + console.print(f"\n[bold]Analyzing session:[/bold] {session_file.name}\n") + + records = load_session(session_file) + spans = [r for r in records if r["type"] == "span"] + + if not spans: + console.print("[yellow]No spans found in session[/yellow]") + return + + # Basic stats + total_duration = sum(s.get("duration_ms", 0) for s in spans) + error_count = sum(1 for s in spans if s["status"]["status_code"] == "ERROR") + + console.print(f"[cyan]Total spans:[/cyan] {len(spans)}") + console.print(f"[cyan]Total duration:[/cyan] {total_duration:.2f}ms") + console.print(f"[cyan]Errors:[/cyan] {error_count}") + console.print() + + # Show errors if any + if error_count > 0: + console.print("[bold red]Errors:[/bold red]") + for span in spans: + if span["status"]["status_code"] == "ERROR": + console.print(f" - {span['name']}: {span['status'].get('description', 'Unknown error')}") + console.print() + + # Show span tree + print_span_tree(spans, console) + + # Show slowest operations + console.print("\n[bold]Slowest Operations:[/bold]") + sorted_spans = sorted(spans, key=lambda s: s.get("duration_ms", 0), reverse=True) + for span in sorted_spans[:5]: + duration = span.get("duration_ms", 0) + console.print(f" - {span['name']}: {duration:.2f}ms") + + +def latest_session() -> Path | None: + """Get the latest session file.""" + debug_dir = GLOBAL_CONFIG_DIR / "telemetry_debug" + if not debug_dir.exists(): + return None + + session_files = sorted(debug_dir.glob("session_*.jsonl"), reverse=True) + return session_files[0] if session_files else None + + +if __name__ == "__main__": + # Simple CLI for viewing logs + import sys + + if len(sys.argv) > 1: + session_file = Path(sys.argv[1]) + else: + session_file = latest_session() + + if session_file and session_file.exists(): + analyze_session(session_file) + else: + print("No session file found. Run with debug enabled first.") + print("Usage: python -m codegen.cli.telemetry.viewer [session_file.jsonl]") diff --git a/src/codegen/cli/tui/app.py b/src/codegen/cli/tui/app.py index 96389cf9c..b0f6acfc9 100644 --- a/src/codegen/cli/tui/app.py +++ b/src/codegen/cli/tui/app.py @@ -18,16 +18,27 @@ from codegen.cli.commands.claude.main import _run_claude_interactive from codegen.cli.utils.org import resolve_org_id from codegen.cli.utils.url import generate_webapp_url, get_domain +from codegen.shared.logging.get_logger import get_logger + +# Initialize logger for TUI telemetry +logger = get_logger(__name__) class MinimalTUI: """Minimal non-full-screen TUI for browsing agent runs.""" def __init__(self): + # Log TUI initialization + logger.info("TUI session started", extra={"operation": "tui.init", "component": "minimal_tui"}) + self.token = get_current_token() self.is_authenticated = bool(self.token) if self.is_authenticated: self.org_id = resolve_org_id() + logger.info("TUI authenticated successfully", extra={"operation": "tui.auth", "org_id": self.org_id, "authenticated": True}) + else: + logger.warning("TUI started without authentication", extra={"operation": "tui.auth", "authenticated": False}) + self.agent_runs: list[dict[str, Any]] = [] self.selected_index = 0 self.running = True @@ -57,6 +68,8 @@ def __init__(self): self._auto_refresh_thread = threading.Thread(target=self._auto_refresh_loop, daemon=True) self._auto_refresh_thread.start() + logger.debug("TUI initialization completed", extra={"operation": "tui.init", "tabs": self.tabs, "auto_refresh_interval": self._auto_refresh_interval_seconds}) + def _auto_refresh_loop(self): """Background loop to auto-refresh recent tab every interval.""" while True: @@ -137,8 +150,16 @@ def _format_status_line(self, left_text: str) -> str: def _load_agent_runs(self) -> bool: """Load the last 10 agent runs.""" if not self.token or not self.org_id: + logger.warning("Cannot load agent runs - missing auth", extra={"operation": "tui.load_agent_runs", "has_token": bool(self.token), "has_org_id": bool(getattr(self, "org_id", None))}) return False + start_time = time.time() + + # Only log debug info for initial load, not refreshes + is_initial_load = not hasattr(self, "_has_loaded_before") + if is_initial_load: + logger.debug("Loading agent runs", extra={"operation": "tui.load_agent_runs", "org_id": self.org_id, "is_initial_load": True}) + try: import requests @@ -168,13 +189,40 @@ def _load_agent_runs(self) -> bool: self.agent_runs = response_data.get("items", []) self.initial_loading = False # Mark initial loading as complete + + duration_ms = (time.time() - start_time) * 1000 + + # Only log the initial load, not refreshes to avoid noise + is_initial_load = not hasattr(self, "_has_loaded_before") + if is_initial_load: + logger.info( + "Agent runs loaded successfully", + extra={ + "operation": "tui.load_agent_runs", + "org_id": self.org_id, + "user_id": user_id, + "agent_count": len(self.agent_runs), + "duration_ms": duration_ms, + "is_initial_load": True, + }, + ) + + # Mark that we've loaded at least once + self._has_loaded_before = True return True except Exception as e: + duration_ms = (time.time() - start_time) * 1000 + # Always log errors regardless of refresh vs initial load + logger.error( + "Failed to load agent runs", + extra={"operation": "tui.load_agent_runs", "org_id": self.org_id, "error_type": type(e).__name__, "error_message": str(e), "duration_ms": duration_ms}, + exc_info=True, + ) print(f"Error loading agent runs: {e}") return False - def _format_status(self, status: str, agent_run: dict | None = None) -> str: + def _format_status(self, status: str, agent_run: dict | None = None) -> tuple[str, str]: """Format status with colored indicators matching kanban style.""" # Check if this agent has a merged PR (done status) is_done = False @@ -392,18 +440,23 @@ def _display_new_tab(self): def _create_background_agent(self, prompt: str): """Create a background agent run.""" + logger.info("Creating background agent via TUI", extra={"operation": "tui.create_agent", "org_id": getattr(self, "org_id", None), "prompt_length": len(prompt), "client": "tui"}) + if not self.token or not self.org_id: + logger.error("Cannot create agent - missing auth", extra={"operation": "tui.create_agent", "has_token": bool(self.token), "has_org_id": bool(getattr(self, "org_id", None))}) print("\nโŒ Not authenticated or no organization configured.") input("Press Enter to continue...") return if not prompt.strip(): + logger.warning("Agent creation cancelled - empty prompt", extra={"operation": "tui.create_agent", "org_id": self.org_id, "prompt_length": len(prompt)}) print("\nโŒ Please enter a prompt.") input("Press Enter to continue...") return print(f"\n\033[90mCreating agent run with prompt: '{prompt[:50]}{'...' if len(prompt) > 50 else ''}'\033[0m") + start_time = time.time() try: payload = {"prompt": prompt.strip()} headers = { @@ -412,6 +465,9 @@ def _create_background_agent(self, prompt: str): "x-codegen-client": "codegen__claude_code", } url = f"{API_ENDPOINT.rstrip('/')}/v1/organizations/{self.org_id}/agent/run" + + # API request details not needed in logs - focus on user actions and results + response = requests.post(url, headers=headers, json=payload, timeout=30) response.raise_for_status() agent_run_data = response.json() @@ -420,6 +476,12 @@ def _create_background_agent(self, prompt: str): status = agent_run_data.get("status", "Unknown") web_url = self._generate_agent_url(run_id) + duration_ms = (time.time() - start_time) * 1000 + logger.info( + "Background agent created successfully", + extra={"operation": "tui.create_agent", "org_id": self.org_id, "agent_run_id": run_id, "status": status, "duration_ms": duration_ms, "prompt_length": len(prompt.strip())}, + ) + print("\n\033[90mAgent run created successfully!\033[0m") print(f"\033[90m Run ID: {run_id}\033[0m") print(f"\033[90m Status: {status}\033[0m") @@ -434,6 +496,12 @@ def _create_background_agent(self, prompt: str): self._show_post_creation_menu(web_url) except Exception as e: + duration_ms = (time.time() - start_time) * 1000 + logger.error( + "Failed to create background agent", + extra={"operation": "tui.create_agent", "org_id": self.org_id, "error_type": type(e).__name__, "error_message": str(e), "duration_ms": duration_ms, "prompt_length": len(prompt)}, + exc_info=True, + ) print(f"\nโŒ Failed to create agent run: {e}") input("\nPress Enter to continue...") @@ -510,22 +578,55 @@ def _display_claude_tab(self): def _pull_agent_branch(self, agent_id: str): """Pull the PR branch for an agent run locally.""" + logger.info("Starting local pull via TUI", extra={"operation": "tui.pull_branch", "agent_id": agent_id, "org_id": getattr(self, "org_id", None)}) + print(f"\n๐Ÿ”„ Pulling PR branch for agent {agent_id}...") print("โ”€" * 50) + start_time = time.time() try: # Call the existing pull command with the agent_id pull(agent_id=int(agent_id), org_id=self.org_id) + duration_ms = (time.time() - start_time) * 1000 + logger.info("Local pull completed successfully", extra={"operation": "tui.pull_branch", "agent_id": agent_id, "org_id": self.org_id, "duration_ms": duration_ms, "success": True}) + except typer.Exit as e: + duration_ms = (time.time() - start_time) * 1000 # typer.Exit is expected for both success and failure cases if e.exit_code == 0: + logger.info( + "Local pull completed via typer exit", + extra={"operation": "tui.pull_branch", "agent_id": agent_id, "org_id": self.org_id, "duration_ms": duration_ms, "exit_code": e.exit_code, "success": True}, + ) print("\nโœ… Pull completed successfully!") else: + logger.error( + "Local pull failed via typer exit", + extra={"operation": "tui.pull_branch", "agent_id": agent_id, "org_id": self.org_id, "duration_ms": duration_ms, "exit_code": e.exit_code, "success": False}, + ) print(f"\nโŒ Pull failed (exit code: {e.exit_code})") except ValueError: + duration_ms = (time.time() - start_time) * 1000 + logger.error( + "Invalid agent ID for pull", + extra={"operation": "tui.pull_branch", "agent_id": agent_id, "org_id": getattr(self, "org_id", None), "duration_ms": duration_ms, "error_type": "invalid_agent_id"}, + ) print(f"\nโŒ Invalid agent ID: {agent_id}") except Exception as e: + duration_ms = (time.time() - start_time) * 1000 + logger.error( + "Unexpected error during pull", + extra={ + "operation": "tui.pull_branch", + "agent_id": agent_id, + "org_id": getattr(self, "org_id", None), + "duration_ms": duration_ms, + "error_type": type(e).__name__, + "error_message": str(e), + }, + exc_info=True, + ) print(f"\nโŒ Unexpected error during pull: {e}") print("โ”€" * 50) @@ -620,15 +721,45 @@ def _handle_keypress(self, key: str): """Handle key presses for navigation.""" # Global quit (but not when typing in new tab) if key == "\x03": # Ctrl+C + logger.info( + "TUI session ended by user", + extra={ + "operation": "tui.session_end", + "org_id": getattr(self, "org_id", None), + "reason": "ctrl_c", + "current_tab": self.tabs[self.current_tab] if self.current_tab < len(self.tabs) else "unknown", + }, + ) self.running = False return elif key.lower() == "q" and not (self.input_mode and self.current_tab == 2): # q only if not typing in new tab + logger.info( + "TUI session ended by user", + extra={ + "operation": "tui.session_end", + "org_id": getattr(self, "org_id", None), + "reason": "quit_key", + "current_tab": self.tabs[self.current_tab] if self.current_tab < len(self.tabs) else "unknown", + }, + ) self.running = False return # Tab switching (works even in input mode) if key == "\t": # Tab key + old_tab = self.current_tab self.current_tab = (self.current_tab + 1) % len(self.tabs) + + # Log significant tab switches but at info level since it's user action + logger.info( + f"TUI tab switched to {self.tabs[self.current_tab]}", + extra={ + "operation": "tui.tab_switch", + "from_tab": self.tabs[old_tab] if old_tab < len(self.tabs) else "unknown", + "to_tab": self.tabs[self.current_tab] if self.current_tab < len(self.tabs) else "unknown", + }, + ) + # Reset state when switching tabs self.show_action_menu = False self.action_menu_selection = 0 @@ -746,13 +877,15 @@ def _handle_new_tab_keypress(self, key: str): def _handle_dashboard_tab_keypress(self, key: str): """Handle keypresses in the kanban tab.""" if key == "\r" or key == "\n": # Enter - open web kanban + logger.info("Opening web kanban from TUI", extra={"operation": "tui.open_kanban", "org_id": getattr(self, "org_id", None)}) try: import webbrowser me_url = generate_webapp_url("me") webbrowser.open(me_url) - print("\nโœ… Opening web kanban in browser...") + # Debug details not needed for successful browser opens except Exception as e: + logger.error("Failed to open kanban in browser", extra={"operation": "tui.open_kanban", "error": str(e)}) print(f"\nโŒ Failed to open browser: {e}") input("Press Enter to continue...") @@ -763,7 +896,10 @@ def _handle_claude_tab_keypress(self, key: str): def _run_claude_code(self): """Launch Claude Code with session tracking.""" + logger.info("Launching Claude Code from TUI", extra={"operation": "tui.launch_claude", "org_id": getattr(self, "org_id", None), "source": "tui"}) + if not self.token or not self.org_id: + logger.error("Cannot launch Claude - missing auth", extra={"operation": "tui.launch_claude", "has_token": bool(self.token), "has_org_id": bool(getattr(self, "org_id", None))}) print("\nโŒ Not authenticated or no organization configured.") input("Press Enter to continue...") return @@ -775,18 +911,34 @@ def _run_claude_code(self): self.running = False print("\033[2J\033[H", end="") # Clear entire screen and move cursor to top + start_time = time.time() try: + # Transition details not needed - the launch and completion logs are sufficient + # Call the interactive claude function with the current org_id # The function handles all the session tracking and launching _run_claude_interactive(self.org_id, no_mcp=False) + + duration_ms = (time.time() - start_time) * 1000 + logger.info("Claude Code session completed via TUI", extra={"operation": "tui.launch_claude", "org_id": self.org_id, "duration_ms": duration_ms, "exit_reason": "normal"}) + except typer.Exit: # Claude Code finished, just continue silently + duration_ms = (time.time() - start_time) * 1000 + logger.info("Claude Code session exited via TUI", extra={"operation": "tui.launch_claude", "org_id": self.org_id, "duration_ms": duration_ms, "exit_reason": "typer_exit"}) pass except Exception as e: + duration_ms = (time.time() - start_time) * 1000 + logger.error( + "Error launching Claude Code from TUI", + extra={"operation": "tui.launch_claude", "org_id": self.org_id, "error_type": type(e).__name__, "error_message": str(e), "duration_ms": duration_ms}, + exc_info=True, + ) print(f"\nโŒ Unexpected error launching Claude Code: {e}") input("Press Enter to continue...") # Exit the TUI completely - don't return to it + logger.info("TUI session ended - transitioning to Claude", extra={"operation": "tui.session_end", "org_id": getattr(self, "org_id", None), "reason": "claude_launch"}) sys.exit(0) def _execute_inline_action(self): @@ -817,14 +969,20 @@ def _execute_inline_action(self): if len(options) > self.action_menu_selection: selected_option = options[self.action_menu_selection] + logger.info( + "TUI action executed", extra={"operation": "tui.execute_action", "action": selected_option, "agent_id": agent_id, "org_id": getattr(self, "org_id", None), "has_prs": bool(github_prs)} + ) + if selected_option == "open PR": pr_url = github_prs[0]["url"] try: import webbrowser webbrowser.open(pr_url) + # Debug details not needed for successful browser opens # No pause - seamless flow back to collapsed state except Exception as e: + logger.error("Failed to open PR in browser", extra={"operation": "tui.open_pr", "agent_id": agent_id, "error": str(e)}) print(f"\nโŒ Failed to open PR: {e}") input("Press Enter to continue...") # Only pause on errors elif selected_option == "pull locally": @@ -834,8 +992,10 @@ def _execute_inline_action(self): import webbrowser webbrowser.open(web_url) + # Debug details not needed for successful browser opens # No pause - let it flow back naturally to collapsed state except Exception as e: + logger.error("Failed to open trace in browser", extra={"operation": "tui.open_trace", "agent_id": agent_id, "error": str(e)}) print(f"\nโŒ Failed to open browser: {e}") input("Press Enter to continue...") # Only pause on errors @@ -923,5 +1083,13 @@ def initial_load(): def run_tui(): """Run the minimal Codegen TUI.""" - tui = MinimalTUI() - tui.run() + logger.info("Starting TUI session", extra={"operation": "tui.start", "component": "run_tui"}) + + try: + tui = MinimalTUI() + tui.run() + except Exception as e: + logger.error("TUI session crashed", extra={"operation": "tui.crash", "error_type": type(e).__name__, "error_message": str(e)}, exc_info=True) + raise + finally: + logger.info("TUI session ended", extra={"operation": "tui.end", "component": "run_tui"}) diff --git a/src/codegen/configs/models/telemetry.py b/src/codegen/configs/models/telemetry.py new file mode 100644 index 000000000..6a4173996 --- /dev/null +++ b/src/codegen/configs/models/telemetry.py @@ -0,0 +1,29 @@ +"""Telemetry configuration for CLI usage analytics and debugging.""" + +from codegen.configs.models.base_config import BaseConfig + + +class TelemetryConfig(BaseConfig): + """Configuration for CLI telemetry. + + Telemetry is opt-in by default and helps improve the CLI experience + by collecting usage analytics, performance metrics, and error diagnostics. + """ + + # Whether telemetry is enabled (opt-in by default) + enabled: bool = False + + # Whether user has been prompted for telemetry consent + consent_prompted: bool = False + + # Anonymous user ID for telemetry correlation + anonymous_id: str | None = None + + # Telemetry endpoint (defaults to production collector) + endpoint: str | None = None + + # Debug mode for verbose telemetry logging + debug: bool = False + + def __init__(self, env_filepath=None, **kwargs): + super().__init__(prefix="TELEMETRY", env_filepath=env_filepath, **kwargs) diff --git a/src/codegen/exports.py b/src/codegen/exports.py new file mode 100644 index 000000000..fe9bba50c --- /dev/null +++ b/src/codegen/exports.py @@ -0,0 +1,18 @@ +"""Public API exports for the codegen package. + +This file provides convenient imports for commonly used classes. +Since __init__.py is auto-generated by setuptools-scm, we use this +separate file for manual exports. +""" + +from codegen.agents.agent import Agent +from codegen.sdk.core.codebase import Codebase # type: ignore[import-untyped] +from codegen.sdk.core.function import Function # type: ignore[import-untyped] +from codegen.shared.enums.programming_language import ProgrammingLanguage + +__all__ = [ + "Agent", + "Codebase", + "Function", + "ProgrammingLanguage", +] diff --git a/src/codegen/shared/logging/get_logger.py b/src/codegen/shared/logging/get_logger.py index 57b5129b3..677363bdf 100644 --- a/src/codegen/shared/logging/get_logger.py +++ b/src/codegen/shared/logging/get_logger.py @@ -43,13 +43,80 @@ def filter(self, record): stderr_handler.setFormatter(formatter) stderr_handler.addFilter(StdErrFilter()) +# Global OpenTelemetry handler (lazy-loaded) +_otel_handler = None +_otel_handler_checked = False + +# Global telemetry config cache +_telemetry_config = None +_telemetry_config_checked = False + + +def _get_telemetry_config(): + """Get telemetry configuration for debug mode checking.""" + global _telemetry_config, _telemetry_config_checked + + if _telemetry_config_checked: + return _telemetry_config + + _telemetry_config_checked = True + + try: + # Use non-prompting config loader to avoid consent prompts during logging setup + from codegen.configs.models.telemetry import TelemetryConfig + from codegen.configs.constants import GLOBAL_ENV_FILE + + _telemetry_config = TelemetryConfig(env_filepath=GLOBAL_ENV_FILE) + except ImportError: + # Telemetry dependencies not available + _telemetry_config = None + except Exception: + # Other setup errors - fallback to console logging + _telemetry_config = None + + return _telemetry_config + + +def _get_otel_handler(): + """Get OpenTelemetry handler if available and enabled.""" + global _otel_handler, _otel_handler_checked + + if _otel_handler_checked: + return _otel_handler + + _otel_handler_checked = True + + try: + from codegen.cli.telemetry.otel_setup import get_otel_logging_handler + + _otel_handler = get_otel_logging_handler() + except ImportError: + # OTel dependencies not available + _otel_handler = None + except Exception: + # Other setup errors + _otel_handler = None + + return _otel_handler + def get_logger(name: str, level: int = logging.INFO) -> logging.Logger: logger = _setup_logger(name, level) - _setup_exception_logging(logger) + # Note: Global exception handling is managed by cli/telemetry/exception_logger.py return logger +def refresh_telemetry_config(): + """Refresh the cached telemetry configuration. + + This should be called when telemetry settings change to ensure + logging behavior updates accordingly. + """ + global _telemetry_config_checked, _telemetry_config + _telemetry_config_checked = False + _telemetry_config = None + + def _setup_logger(name: str, level: int = logging.INFO) -> logging.Logger: # Force configure the root logger with a NullHandler to prevent duplicate logs logging.basicConfig(handlers=[logging.NullHandler()], force=True) @@ -58,8 +125,27 @@ def _setup_logger(name: str, level: int = logging.INFO) -> logging.Logger: for h in logger.handlers: logger.removeHandler(h) - logger.addHandler(stdout_handler) - logger.addHandler(stderr_handler) + # Check telemetry configuration to determine console logging behavior + telemetry_config = _get_telemetry_config() + + # Only add console handlers if: + # 1. Telemetry is not configured (default behavior) + # 2. Telemetry debug mode is enabled + # 3. Telemetry is disabled (fallback to console logging) + should_log_to_console = ( + telemetry_config is None # Telemetry not configured + or telemetry_config.debug # Debug mode enabled + or not telemetry_config.enabled # Telemetry disabled + ) + + if should_log_to_console: + logger.addHandler(stdout_handler) + logger.addHandler(stderr_handler) + + # Always add OpenTelemetry handler if telemetry is enabled (regardless of debug mode) + otel_handler = _get_otel_handler() + if otel_handler is not None: + logger.addHandler(otel_handler) # Ensure the logger propagates to the root logger logger.propagate = True @@ -68,9 +154,4 @@ def _setup_logger(name: str, level: int = logging.INFO) -> logging.Logger: return logger -def _setup_exception_logging(logger: logging.Logger) -> None: - def log_exception(exc_type, exc_value, exc_traceback): - logger.exception("Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback)) - - # Set the log_exception function as the exception hook - sys.excepthook = log_exception +# Note: Exception logging is handled by cli/telemetry/exception_logger.py diff --git a/uv.lock b/uv.lock index a8d76c2ef..ef72efacc 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12, <3.14" +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version < '3.13'", +] [[package]] name = "aiohappyeyeballs" @@ -430,6 +434,9 @@ dependencies = [ { name = "hatch-vcs" }, { name = "hatchling" }, { name = "humanize" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp" }, + { name = "opentelemetry-sdk" }, { name = "packaging" }, { name = "psutil" }, { name = "pydantic" }, @@ -492,6 +499,9 @@ requires-dist = [ { name = "hatch-vcs", specifier = ">=0.4.0" }, { name = "hatchling", specifier = ">=1.25.0" }, { name = "humanize", specifier = ">=4.10.0" }, + { name = "opentelemetry-api", specifier = ">=1.26.0" }, + { name = "opentelemetry-exporter-otlp", specifier = ">=1.26.0" }, + { name = "opentelemetry-sdk", specifier = ">=1.26.0" }, { name = "packaging", specifier = ">=24.2" }, { name = "psutil", specifier = ">=5.8.0" }, { name = "pydantic", specifier = ">=2.9.2" }, @@ -971,6 +981,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dd/94/c6ff3388b8e3225a014e55aed957188639aa0966443e0408d38f0c9614a7/giturlparse-0.12.0-py2.py3-none-any.whl", hash = "sha256:412b74f2855f1da2fefa89fd8dde62df48476077a72fc19b62039554d27360eb", size = 15752, upload-time = "2023-09-24T07:22:35.465Z" }, ] +[[package]] +name = "googleapis-common-protos" +version = "1.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/24/33db22342cf4a2ea27c9955e6713140fedd51e8b141b5ce5260897020f1a/googleapis_common_protos-1.70.0.tar.gz", hash = "sha256:0e1b44e0ea153e6594f9f394fef15193a68aaaea2d843f83e2742717ca753257", size = 145903, upload-time = "2025-04-14T10:17:02.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/f1/62a193f0227cf15a920390abe675f386dec35f7ae3ffe6da582d3ade42c7/googleapis_common_protos-1.70.0-py3-none-any.whl", hash = "sha256:b8bfcca8c25a2bb253e0e0b0adaf8c00773e5e6af6fd92397576680b807e0fd8", size = 294530, upload-time = "2025-04-14T10:17:01.271Z" }, +] + +[[package]] +name = "grpcio" +version = "1.74.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/b4/35feb8f7cab7239c5b94bd2db71abb3d6adb5f335ad8f131abb6060840b6/grpcio-1.74.0.tar.gz", hash = "sha256:80d1f4fbb35b0742d3e3d3bb654b7381cd5f015f8497279a1e9c21ba623e01b1", size = 12756048, upload-time = "2025-07-24T18:54:23.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/5d/e504d5d5c4469823504f65687d6c8fb97b7f7bf0b34873b7598f1df24630/grpcio-1.74.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:8533e6e9c5bd630ca98062e3a1326249e6ada07d05acf191a77bc33f8948f3d8", size = 5445551, upload-time = "2025-07-24T18:53:23.641Z" }, + { url = "https://files.pythonhosted.org/packages/43/01/730e37056f96f2f6ce9f17999af1556df62ee8dab7fa48bceeaab5fd3008/grpcio-1.74.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:2918948864fec2a11721d91568effffbe0a02b23ecd57f281391d986847982f6", size = 10979810, upload-time = "2025-07-24T18:53:25.349Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/09fd100473ea5c47083889ca47ffd356576173ec134312f6aa0e13111dee/grpcio-1.74.0-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:60d2d48b0580e70d2e1954d0d19fa3c2e60dd7cbed826aca104fff518310d1c5", size = 5941946, upload-time = "2025-07-24T18:53:27.387Z" }, + { url = "https://files.pythonhosted.org/packages/8a/99/12d2cca0a63c874c6d3d195629dcd85cdf5d6f98a30d8db44271f8a97b93/grpcio-1.74.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3601274bc0523f6dc07666c0e01682c94472402ac2fd1226fd96e079863bfa49", size = 6621763, upload-time = "2025-07-24T18:53:29.193Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2c/930b0e7a2f1029bbc193443c7bc4dc2a46fedb0203c8793dcd97081f1520/grpcio-1.74.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:176d60a5168d7948539def20b2a3adcce67d72454d9ae05969a2e73f3a0feee7", size = 6180664, upload-time = "2025-07-24T18:53:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/db/d5/ff8a2442180ad0867717e670f5ec42bfd8d38b92158ad6bcd864e6d4b1ed/grpcio-1.74.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e759f9e8bc908aaae0412642afe5416c9f983a80499448fcc7fab8692ae044c3", size = 6301083, upload-time = "2025-07-24T18:53:32.454Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ba/b361d390451a37ca118e4ec7dccec690422e05bc85fba2ec72b06cefec9f/grpcio-1.74.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:9e7c4389771855a92934b2846bd807fc25a3dfa820fd912fe6bd8136026b2707", size = 6994132, upload-time = "2025-07-24T18:53:34.506Z" }, + { url = "https://files.pythonhosted.org/packages/3b/0c/3a5fa47d2437a44ced74141795ac0251bbddeae74bf81df3447edd767d27/grpcio-1.74.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:cce634b10aeab37010449124814b05a62fb5f18928ca878f1bf4750d1f0c815b", size = 6489616, upload-time = "2025-07-24T18:53:36.217Z" }, + { url = "https://files.pythonhosted.org/packages/ae/95/ab64703b436d99dc5217228babc76047d60e9ad14df129e307b5fec81fd0/grpcio-1.74.0-cp312-cp312-win32.whl", hash = "sha256:885912559974df35d92219e2dc98f51a16a48395f37b92865ad45186f294096c", size = 3807083, upload-time = "2025-07-24T18:53:37.911Z" }, + { url = "https://files.pythonhosted.org/packages/84/59/900aa2445891fc47a33f7d2f76e00ca5d6ae6584b20d19af9c06fa09bf9a/grpcio-1.74.0-cp312-cp312-win_amd64.whl", hash = "sha256:42f8fee287427b94be63d916c90399ed310ed10aadbf9e2e5538b3e497d269bc", size = 4490123, upload-time = "2025-07-24T18:53:39.528Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d8/1004a5f468715221450e66b051c839c2ce9a985aa3ee427422061fcbb6aa/grpcio-1.74.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:2bc2d7d8d184e2362b53905cb1708c84cb16354771c04b490485fa07ce3a1d89", size = 5449488, upload-time = "2025-07-24T18:53:41.174Z" }, + { url = "https://files.pythonhosted.org/packages/94/0e/33731a03f63740d7743dced423846c831d8e6da808fcd02821a4416df7fa/grpcio-1.74.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c14e803037e572c177ba54a3e090d6eb12efd795d49327c5ee2b3bddb836bf01", size = 10974059, upload-time = "2025-07-24T18:53:43.066Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c6/3d2c14d87771a421205bdca991467cfe473ee4c6a1231c1ede5248c62ab8/grpcio-1.74.0-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:f6ec94f0e50eb8fa1744a731088b966427575e40c2944a980049798b127a687e", size = 5945647, upload-time = "2025-07-24T18:53:45.269Z" }, + { url = "https://files.pythonhosted.org/packages/c5/83/5a354c8aaff58594eef7fffebae41a0f8995a6258bbc6809b800c33d4c13/grpcio-1.74.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:566b9395b90cc3d0d0c6404bc8572c7c18786ede549cdb540ae27b58afe0fb91", size = 6626101, upload-time = "2025-07-24T18:53:47.015Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ca/4fdc7bf59bf6994aa45cbd4ef1055cd65e2884de6113dbd49f75498ddb08/grpcio-1.74.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1ea6176d7dfd5b941ea01c2ec34de9531ba494d541fe2057c904e601879f249", size = 6182562, upload-time = "2025-07-24T18:53:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/fd/48/2869e5b2c1922583686f7ae674937986807c2f676d08be70d0a541316270/grpcio-1.74.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:64229c1e9cea079420527fa8ac45d80fc1e8d3f94deaa35643c381fa8d98f362", size = 6303425, upload-time = "2025-07-24T18:53:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0e/bac93147b9a164f759497bc6913e74af1cb632c733c7af62c0336782bd38/grpcio-1.74.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:0f87bddd6e27fc776aacf7ebfec367b6d49cad0455123951e4488ea99d9b9b8f", size = 6996533, upload-time = "2025-07-24T18:53:52.747Z" }, + { url = "https://files.pythonhosted.org/packages/84/35/9f6b2503c1fd86d068b46818bbd7329db26a87cdd8c01e0d1a9abea1104c/grpcio-1.74.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3b03d8f2a07f0fea8c8f74deb59f8352b770e3900d143b3d1475effcb08eec20", size = 6491489, upload-time = "2025-07-24T18:53:55.06Z" }, + { url = "https://files.pythonhosted.org/packages/75/33/a04e99be2a82c4cbc4039eb3a76f6c3632932b9d5d295221389d10ac9ca7/grpcio-1.74.0-cp313-cp313-win32.whl", hash = "sha256:b6a73b2ba83e663b2480a90b82fdae6a7aa6427f62bf43b29912c0cfd1aa2bfa", size = 3805811, upload-time = "2025-07-24T18:53:56.798Z" }, + { url = "https://files.pythonhosted.org/packages/34/80/de3eb55eb581815342d097214bed4c59e806b05f1b3110df03b2280d6dfd/grpcio-1.74.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd3c71aeee838299c5887230b8a1822795325ddfea635edd82954c1eaa831e24", size = 4489214, upload-time = "2025-07-24T18:53:59.771Z" }, +] + [[package]] name = "grpclib" version = "0.4.7" @@ -1115,14 +1165,14 @@ wheels = [ [[package]] name = "importlib-metadata" -version = "8.7.0" +version = "8.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "zipp" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/bd/fa8ce65b0a7d4b6d143ec23b0f5fd3f7ab80121078c465bc02baeaab22dc/importlib_metadata-8.4.0.tar.gz", hash = "sha256:9a547d3bc3608b025f93d403fdd1aae741c24fbb8314df4b155675742ce303c5", size = 54320, upload-time = "2024-08-20T17:11:42.348Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/c0/14/362d31bf1076b21e1bcdcb0dc61944822ff263937b804a79231df2774d28/importlib_metadata-8.4.0-py3-none-any.whl", hash = "sha256:66f342cc6ac9818fc6ff340576acd24d65ba0b3efabb2b4ac08b598965a4a2f1", size = 26269, upload-time = "2024-08-20T17:11:41.102Z" }, ] [[package]] @@ -1840,6 +1890,119 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "importlib-metadata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/83/93114b6de85a98963aec218a51509a52ed3f8de918fe91eb0f7299805c3f/opentelemetry_api-1.27.0.tar.gz", hash = "sha256:ed673583eaa5f81b5ce5e86ef7cdaf622f88ef65f0b9aab40b843dcae5bef342", size = 62693, upload-time = "2024-08-28T21:35:31.445Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/1f/737dcdbc9fea2fa96c1b392ae47275165a7c641663fbb08a8d252968eed2/opentelemetry_api-1.27.0-py3-none-any.whl", hash = "sha256:953d5871815e7c30c81b56d910c707588000fff7a3ca1c73e6531911d53065e7", size = 63970, upload-time = "2024-08-28T21:35:00.598Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/d3/8156cc14e8f4573a3572ee7f30badc7aabd02961a09acc72ab5f2c789ef1/opentelemetry_exporter_otlp-1.27.0.tar.gz", hash = "sha256:4a599459e623868cc95d933c301199c2367e530f089750e115599fccd67cb2a1", size = 6166, upload-time = "2024-08-28T21:35:33.746Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/6d/95e1fc2c8d945a734db32e87a5aa7a804f847c1657a21351df9338bd1c9c/opentelemetry_exporter_otlp-1.27.0-py3-none-any.whl", hash = "sha256:7688791cbdd951d71eb6445951d1cfbb7b6b2d7ee5948fac805d404802931145", size = 7001, upload-time = "2024-08-28T21:35:04.02Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/2e/7eaf4ba595fb5213cf639c9158dfb64aacb2e4c7d74bfa664af89fa111f4/opentelemetry_exporter_otlp_proto_common-1.27.0.tar.gz", hash = "sha256:159d27cf49f359e3798c4c3eb8da6ef4020e292571bd8c5604a2a573231dd5c8", size = 17860, upload-time = "2024-08-28T21:35:34.896Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/27/4610ab3d9bb3cde4309b6505f98b3aabca04a26aa480aa18cede23149837/opentelemetry_exporter_otlp_proto_common-1.27.0-py3-none-any.whl", hash = "sha256:675db7fffcb60946f3a5c43e17d1168a3307a94a930ecf8d2ea1f286f3d4f79a", size = 17848, upload-time = "2024-08-28T21:35:05.412Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/d0/c1e375b292df26e0ffebf194e82cd197e4c26cc298582bda626ce3ce74c5/opentelemetry_exporter_otlp_proto_grpc-1.27.0.tar.gz", hash = "sha256:af6f72f76bcf425dfb5ad11c1a6d6eca2863b91e63575f89bb7b4b55099d968f", size = 26244, upload-time = "2024-08-28T21:35:36.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/80/32217460c2c64c0568cea38410124ff680a9b65f6732867bbf857c4d8626/opentelemetry_exporter_otlp_proto_grpc-1.27.0-py3-none-any.whl", hash = "sha256:56b5bbd5d61aab05e300d9d62a6b3c134827bbd28d0b12f2649c2da368006c9e", size = 18541, upload-time = "2024-08-28T21:35:06.493Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/0a/f05c55e8913bf58a033583f2580a0ec31a5f4cf2beacc9e286dcb74d6979/opentelemetry_exporter_otlp_proto_http-1.27.0.tar.gz", hash = "sha256:2103479092d8eb18f61f3fbff084f67cc7f2d4a7d37e75304b8b56c1d09ebef5", size = 15059, upload-time = "2024-08-28T21:35:37.079Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/8d/4755884afc0b1db6000527cac0ca17273063b6142c773ce4ecd307a82e72/opentelemetry_exporter_otlp_proto_http-1.27.0-py3-none-any.whl", hash = "sha256:688027575c9da42e179a69fe17e2d1eba9b14d81de8d13553a21d3114f3b4d75", size = 17203, upload-time = "2024-08-28T21:35:08.141Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/59/959f0beea798ae0ee9c979b90f220736fbec924eedbefc60ca581232e659/opentelemetry_proto-1.27.0.tar.gz", hash = "sha256:33c9345d91dafd8a74fc3d7576c5a38f18b7fdf8d02983ac67485386132aedd6", size = 34749, upload-time = "2024-08-28T21:35:45.839Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/56/3d2d826834209b19a5141eed717f7922150224d1a982385d19a9444cbf8d/opentelemetry_proto-1.27.0-py3-none-any.whl", hash = "sha256:b133873de5581a50063e1e4b29cdcf0c5e253a8c2d8dc1229add20a4c3830ace", size = 52464, upload-time = "2024-08-28T21:35:21.434Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.27.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/0d/9a/82a6ac0f06590f3d72241a587cb8b0b751bd98728e896cc4cbd4847248e6/opentelemetry_sdk-1.27.0.tar.gz", hash = "sha256:d525017dea0ccce9ba4e0245100ec46ecdc043f2d7b8315d56b19aff0904fa6f", size = 145019, upload-time = "2024-08-28T21:35:46.708Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/bd/a6602e71e315055d63b2ff07172bd2d012b4cba2d4e00735d74ba42fc4d6/opentelemetry_sdk-1.27.0-py3-none-any.whl", hash = "sha256:365f5e32f920faf0fd9e14fdfd92c086e317eaa5f860edba9cdc17a380d9197d", size = 110505, upload-time = "2024-08-28T21:35:24.769Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.48b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "opentelemetry-api" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0a/89/1724ad69f7411772446067cdfa73b598694c8c91f7f8c922e344d96d81f9/opentelemetry_semantic_conventions-0.48b0.tar.gz", hash = "sha256:12d74983783b6878162208be57c9effcb89dc88691c64992d70bb89dc00daa1a", size = 89445, upload-time = "2024-08-28T21:35:47.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/7a/4f0063dbb0b6c971568291a8bc19a4ca70d3c185db2d956230dd67429dfc/opentelemetry_semantic_conventions-0.48b0-py3-none-any.whl", hash = "sha256:a0de9f45c413a8669788a38569c7e0a11ce6ce97861a628cca785deecdc32a1f", size = 149685, upload-time = "2024-08-28T21:35:25.983Z" }, +] + [[package]] name = "overrides" version = "7.7.0"