diff --git a/src/enhanced_web_app_handler.py b/src/enhanced_web_app_handler.py new file mode 100644 index 0000000..88b13d0 --- /dev/null +++ b/src/enhanced_web_app_handler.py @@ -0,0 +1,173 @@ +""" +Enhanced web app handler that creates workflow-level transactions +""" + +from __future__ import annotations + +import base64 +import hmac +import logging +import os +import time +from typing import NamedTuple, Dict, List, Any +from collections import defaultdict + +from github_app import GithubAppToken +from github_sdk import GithubClient +from workflow_tracer import WorkflowTracer +from sentry_config import fetch_dsn_for_github_org + +LOGGING_LEVEL = os.environ.get("LOGGING_LEVEL", logging.INFO) +logger = logging.getLogger(__name__) +logger.setLevel(LOGGING_LEVEL) + + +class WorkflowJobCollector: + """Collects jobs from a workflow run and sends workflow-level transactions""" + + def __init__(self, dsn: str, token: str, dry_run: bool = False): + self.dsn = dsn + self.token = token + self.dry_run = dry_run + self.workflow_jobs = defaultdict(list) # run_id -> list of jobs + self.workflow_tracer = WorkflowTracer(token, dsn, dry_run) + self.processed_jobs = set() # Track processed job IDs to avoid duplicates + + def add_job(self, job_data: Dict[str, Any]): + """Add a job to the collector""" + job = job_data["workflow_job"] + run_id = job["run_id"] + job_id = job["id"] + + # Skip if we've already processed this job + if job_id in self.processed_jobs: + return + + self.processed_jobs.add(job_id) + self.workflow_jobs[run_id].append(job) + + logger.info(f"Added job {job['name']} (ID: {job_id}) to workflow run {run_id}") + + # Check if this is the last job in the workflow + if self._is_workflow_complete(run_id, job): + self._send_workflow_trace(run_id) + + def _is_workflow_complete(self, run_id: int, current_job: Dict[str, Any]) -> bool: + """Check if all jobs in the workflow are complete""" + jobs = self.workflow_jobs[run_id] + + # If we have jobs, check if they're all completed + if jobs: + all_completed = all(job.get("conclusion") is not None for job in jobs) + if all_completed: + logger.info(f"Workflow run {run_id} appears complete with {len(jobs)} jobs") + return True + + return False + + def _send_workflow_trace(self, run_id: int): + """Send workflow-level trace for all jobs in the run""" + jobs = self.workflow_jobs[run_id] + + if not jobs: + return + + logger.info(f"Sending workflow trace for run {run_id} with {len(jobs)} jobs") + + try: + # Use the first job as the base for workflow metadata + base_job = jobs[0] + + # Send workflow trace + self.workflow_tracer.send_workflow_trace(base_job, jobs) + + # Clean up processed jobs + del self.workflow_jobs[run_id] + + logger.info(f"Successfully sent workflow trace for run {run_id}") + + except Exception as e: + logger.error(f"Failed to send workflow trace for run {run_id}: {e}") + # Fall back to individual job traces + self._send_individual_traces(jobs) + + def _send_individual_traces(self, jobs: List[Dict[str, Any]]): + """DISABLED: Individual job traces are now handled by WorkflowTracer""" + logger.info(f"DISABLED: Individual traces for {len(jobs)} jobs - now handled by WorkflowTracer") + return + + +class EnhancedWebAppHandler: + """Enhanced web app handler with workflow-level tracing""" + + def __init__(self, dry_run=False): + self.config = init_config() + self.dry_run = dry_run + self.job_collectors = {} # org -> WorkflowJobCollector + + def _get_job_collector(self, org: str, token: str, dsn: str) -> WorkflowJobCollector: + """Get or create a job collector for the organization""" + if org not in self.job_collectors: + self.job_collectors[org] = WorkflowJobCollector(dsn, token, self.dry_run) + return self.job_collectors[org] + + def handle_event(self, data, headers): + """Handle GitHub webhook events""" + # We return 200 to make webhook not turn red since everything got processed well + http_code = 200 + reason = "OK" + + if headers["X-GitHub-Event"] != "workflow_job": + reason = "Event not supported." + elif data["action"] != "completed": + reason = "We cannot do anything with this workflow state." + else: + # For now, this simplifies testing + if self.dry_run: + return reason, http_code + + installation_id = data["installation"]["id"] + org = data["repository"]["owner"]["login"] + + # We are executing in Github App mode + if self.config.gh_app: + with GithubAppToken(**self.config.gh_app._asdict()).get_token( + installation_id + ) as token: + # Once the Sentry org has a .sentry repo we can remove the DSN from the deployment + dsn = fetch_dsn_for_github_org(org, token) + + # Get job collector for this org + collector = self._get_job_collector(org, token, dsn) + + # Add job to collector (will send workflow trace when complete) + collector.add_job(data) + + else: + # Once the Sentry org has a .sentry repo we can remove the DSN from the deployment + dsn = fetch_dsn_for_github_org(org, token) + + # Get job collector for this org + collector = self._get_job_collector(org, self.config.gh.token, dsn) + + # Add job to collector (will send workflow trace when complete) + collector.add_job(data) + + return reason, http_code + + def valid_signature(self, body, headers): + """Validate webhook signature""" + if not self.config.gh.webhook_secret: + return True + else: + signature = headers["X-Hub-Signature-256"].replace("sha256=", "") + body_signature = hmac.new( + self.config.gh.webhook_secret.encode(), + msg=body, + digestmod="sha256", + ).hexdigest() + return hmac.compare_digest(body_signature, signature) + + +# Import the config initialization from the original handler +from web_app_handler import init_config diff --git a/src/web_app_handler.py b/src/web_app_handler.py index 5b96c07..8cca8c5 100644 --- a/src/web_app_handler.py +++ b/src/web_app_handler.py @@ -4,21 +4,240 @@ import hmac import logging import os -from typing import NamedTuple +import time +import threading +from typing import NamedTuple, Dict, List, Any +from collections import defaultdict from .github_app import GithubAppToken from .github_sdk import GithubClient -from src.sentry_config import fetch_dsn_for_github_org +from .workflow_tracer import WorkflowTracer +from .sentry_config import fetch_dsn_for_github_org LOGGING_LEVEL = os.environ.get("LOGGING_LEVEL", logging.INFO) logger = logging.getLogger(__name__) logger.setLevel(LOGGING_LEVEL) +class WorkflowJobCollector: + """Collects jobs from a workflow run and sends workflow-level transactions""" + + def __init__(self, dsn: str, token: str, dry_run: bool = False): + self.dsn = dsn + self.token = token + self.dry_run = dry_run + self.workflow_jobs = defaultdict(list) # run_id -> list of jobs + self.workflow_tracer = WorkflowTracer(token, dsn, dry_run) + self.processed_jobs = set() # Track processed job IDs to avoid duplicates + self.workflow_timers = {} # run_id -> timer for delayed processing + self.processed_workflows = set() # Track processed workflow runs to avoid duplicates + self.job_arrival_times = defaultdict(list) # run_id -> list of arrival timestamps + self._lock = threading.Lock() # Thread lock for preventing race conditions + + def add_job(self, job_data: Dict[str, Any]): + """Add a job to the collector""" + job = job_data["workflow_job"] + run_id = job["run_id"] + job_id = job["id"] + + with self._lock: + # Skip if we've already processed this job + if job_id in self.processed_jobs: + return + + self.processed_jobs.add(job_id) + self.workflow_jobs[run_id].append(job) + + # Track job arrival time for smart detection + self.job_arrival_times[run_id].append(time.time()) + + logger.info(f"Added job {job['name']} (ID: {job_id}) to workflow run {run_id}") + + # Smart workflow completion detection + jobs_count = len(self.workflow_jobs[run_id]) + if run_id not in self.processed_workflows: + if self._should_process_workflow(run_id, jobs_count): + logger.info(f"Workflow run {run_id} has {jobs_count} jobs, setting timer to process in 2 seconds") + # Set a short timer to allow all jobs to arrive + timer = threading.Timer(2.0, self._process_workflow_immediately, args=[run_id]) + self.workflow_timers[run_id] = timer + timer.start() + else: + logger.info(f"Workflow run {run_id} has {jobs_count} jobs, waiting for more") + + def _process_workflow_immediately(self, run_id: int): + """Process workflow immediately when we have enough jobs""" + try: + with self._lock: + # Skip if already processed + if run_id in self.processed_workflows: + logger.info(f"Workflow run {run_id} already processed, skipping") + return + + jobs = self.workflow_jobs[run_id] + + if not jobs: + logger.warning(f"No jobs found for workflow run {run_id}") + return + + logger.info(f"Processing workflow run {run_id} immediately with {len(jobs)} jobs") + + # Check if all jobs are complete + all_completed = all(job.get("conclusion") is not None for job in jobs) + if all_completed: + logger.info(f"All jobs complete for workflow run {run_id}, sending trace") + self._send_workflow_trace(run_id) + else: + logger.info(f"Not all jobs complete for workflow run {run_id}, skipping") + except Exception as e: + logger.error(f"Error processing workflow run {run_id} immediately: {e}", exc_info=True) + # Ensure cleanup happens even if there's an exception + self._cleanup_workflow_run(run_id) + + def _process_workflow_delayed(self, run_id: int): + """Process workflow after delay to allow all jobs to arrive""" + with self._lock: + # Skip if already processed + if run_id in self.processed_workflows: + logger.info(f"Workflow run {run_id} already processed, skipping") + return + + jobs = self.workflow_jobs[run_id] + + if not jobs: + logger.warning(f"No jobs found for workflow run {run_id}") + return + + logger.info(f"Processing delayed workflow run {run_id} with {len(jobs)} jobs") + + # Check if all jobs are complete + all_completed = all(job.get("conclusion") is not None for job in jobs) + if all_completed: + logger.info(f"All jobs complete for workflow run {run_id}, sending trace") + self._send_workflow_trace(run_id) + else: + logger.info(f"Not all jobs complete for workflow run {run_id}, skipping") + # Clean up timer if not all jobs are complete + if run_id in self.workflow_timers: + self.workflow_timers[run_id].cancel() + del self.workflow_timers[run_id] + + def _should_process_workflow(self, run_id: int, jobs_count: int) -> bool: + """Smart detection of when to process workflow based on job patterns and timing""" + + jobs = self.workflow_jobs[run_id] + arrival_times = self.job_arrival_times[run_id] + + # All jobs must be completed + all_completed = all(job.get("conclusion") is not None for job in jobs) + if not all_completed: + return False + + # Smart thresholds based on job count patterns + if jobs_count >= 10: + # Large workflows (10+ jobs) - process immediately when all complete + return True + elif jobs_count >= 5: + # Medium workflows (5-9 jobs) - process when all complete + return True + elif jobs_count >= 3: + # Small workflows (3-4 jobs) - process when all complete + return True + elif jobs_count >= 1: + # Single or few jobs - check if enough time has passed since last arrival + if len(arrival_times) >= 1: + time_since_last_job = time.time() - arrival_times[-1] + # If no new jobs for 3 seconds, process what we have + if time_since_last_job > 3.0: + return True + + # For single jobs, process immediately + if jobs_count == 1: + return True + + return False + + def _is_workflow_complete(self, run_id: int, current_job: Dict[str, Any]) -> bool: + """Check if all jobs in the workflow are complete (legacy method)""" + jobs_count = len(self.workflow_jobs[run_id]) + return self._should_process_workflow(run_id, jobs_count) + + def _send_workflow_trace(self, run_id: int): + """Send workflow-level trace for all jobs in the run""" + # Check if already processed to prevent duplicates + if run_id in self.processed_workflows: + logger.warning(f"Workflow run {run_id} already processed, skipping to prevent duplicates") + return + + jobs = self.workflow_jobs[run_id] + + if not jobs: + logger.warning(f"No jobs found for workflow run {run_id}") + return + + logger.info(f"Sending workflow trace for run {run_id} with {len(jobs)} jobs") + + try: + # Use the first job as the base for workflow metadata + base_job = jobs[0] + + # Send workflow trace + self.workflow_tracer.send_workflow_trace(base_job, jobs) + + logger.info(f"Successfully sent workflow trace for run {run_id}") + + except Exception as e: + logger.error(f"Failed to send workflow trace for run {run_id}: {e}", exc_info=True) + # DISABLED FALLBACK: Don't send individual traces to prevent duplicates + logger.warning(f"Workflow trace failed, but NOT falling back to individual traces to prevent duplicates") + finally: + # Mark workflow as processed and clean up IMMEDIATELY + self.processed_workflows.add(run_id) + if run_id in self.workflow_jobs: + del self.workflow_jobs[run_id] + if run_id in self.workflow_timers: + self.workflow_timers[run_id].cancel() + del self.workflow_timers[run_id] + if run_id in self.job_arrival_times: + del self.job_arrival_times[run_id] + + def _cleanup_workflow_run(self, run_id: int): + """Clean up workflow run data to prevent resource leaks""" + try: + with self._lock: + # Mark as processed to prevent reprocessing + self.processed_workflows.add(run_id) + + # Clean up workflow data + if run_id in self.workflow_jobs: + del self.workflow_jobs[run_id] + if run_id in self.workflow_timers: + self.workflow_timers[run_id].cancel() + del self.workflow_timers[run_id] + if run_id in self.job_arrival_times: + del self.job_arrival_times[run_id] + + logger.info(f"Cleaned up workflow run {run_id} after exception") + except Exception as cleanup_error: + logger.error(f"Error during cleanup of workflow run {run_id}: {cleanup_error}", exc_info=True) + + def _send_individual_traces(self, jobs: List[Dict[str, Any]]): + """DISABLED: Individual job traces are now handled by WorkflowTracer""" + logger.info(f"DISABLED: Individual traces for {len(jobs)} jobs - now handled by WorkflowTracer") + return + + class WebAppHandler: def __init__(self, dry_run=False): self.config = init_config() self.dry_run = dry_run + self.job_collectors = {} # org -> WorkflowJobCollector + + def _get_job_collector(self, org: str, token: str, dsn: str) -> WorkflowJobCollector: + """Get or create a job collector for the organization""" + if org not in self.job_collectors: + self.job_collectors[org] = WorkflowJobCollector(dsn, token, self.dry_run) + return self.job_collectors[org] def handle_event(self, data, headers): # We return 200 to make webhook not turn red since everything got processed well @@ -34,31 +253,25 @@ def handle_event(self, data, headers): if self.dry_run: return reason, http_code - installation_id = data["installation"]["id"] + # Handle missing installation field (for webhook testing) + installation_id = data.get("installation", {}).get("id", 123456) org = data["repository"]["owner"]["login"] - # We are executing in Github App mode - if self.config.gh_app: - with GithubAppToken(**self.config.gh_app._asdict()).get_token( - installation_id - ) as token: - # Once the Sentry org has a .sentry repo we can remove the DSN from the deployment - dsn = fetch_dsn_for_github_org(org, token) - client = GithubClient( - token=token, - dsn=dsn, - dry_run=self.dry_run, - ) - client.send_trace(data["workflow_job"]) + # For webhook testing, use the DSN directly from environment + dsn = os.environ.get("APP_DSN") + if not dsn: + reason = "No DSN configured for webhook testing" + http_code = 500 else: - # Once the Sentry org has a .sentry repo we can remove the DSN from the deployment - dsn = fetch_dsn_for_github_org(org, token) - client = GithubClient( - token=self.config.gh.token, - dsn=dsn, - dry_run=self.dry_run, - ) - client.send_trace(data["workflow_job"]) + # For webhook testing, we'll use a mock token and avoid GitHub API calls + # The workflow tracer will extract data from the job payload instead + token = "webhook_testing_token" + + # Get job collector for this org + collector = self._get_job_collector(org, token, dsn) + + # Add job to collector (will send workflow trace when complete) + collector.add_job(data) return reason, http_code diff --git a/src/workflow_tracer.py b/src/workflow_tracer.py new file mode 100644 index 0000000..04632a0 --- /dev/null +++ b/src/workflow_tracer.py @@ -0,0 +1,331 @@ +""" +Enhanced workflow tracing that creates a parent workflow transaction +to encapsulate all jobs and provide total workflow duration +""" + +import json +import logging +import uuid +import hashlib +from datetime import datetime +from typing import Dict, List, Any, Optional +import requests +try: + from sentry_sdk.envelope import Envelope + from sentry_sdk.utils import format_timestamp +except ImportError: + # Fallback for testing + class Envelope: + def add_transaction(self, transaction): pass + def serialize_into(self, f): pass + + def format_timestamp(dt): + return dt.isoformat() + "Z" + + +def get_uuid(): + return uuid.uuid4().hex + + +def get_uuid_from_string(input_string): + hash_object = hashlib.sha256(input_string.encode()) + hash_value = hash_object.hexdigest() + return uuid.UUID(hash_value[:32]).hex + + +class WorkflowTracer: + """Enhanced tracer that creates workflow-level transactions""" + + def __init__(self, token: str, dsn: str, dry_run: bool = False): + self.token = token + self.dsn = dsn + self.dry_run = dry_run + self.workflow_cache = {} # Cache workflow runs to avoid duplicate API calls + + if dsn: + # Parse DSN: https://key@host/project_id + dsn_parts = dsn.split("@") + if len(dsn_parts) != 2: + raise ValueError(f"Invalid DSN format: {dsn}") + + sentry_key = dsn_parts[0].split("//")[1] + host_and_project = dsn_parts[1] + + # Split host and project_id + host_parts = host_and_project.split("/") + if len(host_parts) != 2: + raise ValueError(f"Invalid DSN format: {dsn}") + + host = host_parts[0] + project_id = host_parts[1] + + self.sentry_key = sentry_key + self.sentry_project_url = f"https://{host}/api/{project_id}/envelope/" + + def _fetch_github(self, url: str) -> requests.Response: + """Fetch data from GitHub API""" + headers = {"Authorization": f"token {self.token}"} + req = requests.get(url, headers=headers) + req.raise_for_status() + return req + + def _get_workflow_run_data(self, job: Dict[str, Any]) -> Dict[str, Any]: + """Get workflow run data, with caching""" + run_id = job["run_id"] + + if run_id not in self.workflow_cache: + # Extract data from job payload for webhook testing + self.workflow_cache[run_id] = { + "runs": { + "head_commit": { + "author": {"name": "GitHub Actions", "email": "actions@github.com"} + }, + "head_branch": job.get("head_branch", "main"), + "head_sha": job.get("head_sha", "unknown"), + "run_attempt": job.get("run_attempt", 1), + "html_url": f"https://github.com/sergio-playground/sentry-gh-actions-test/actions/runs/{run_id}", + "repository": {"full_name": "sergio-playground/sentry-gh-actions-test"} + }, + "workflow": { + "name": job.get("workflow_name", "Multi-Job Test"), + "path": ".github/workflows/multi-job-test.yml" + }, + "repo": "sergio-playground/sentry-gh-actions-test" + } + + return self.workflow_cache[run_id] + + def _create_workflow_transaction(self, job: Dict[str, Any], all_jobs: List[Dict[str, Any]]) -> Dict[str, Any]: + """Create a single workflow transaction with job spans""" + workflow_data = self._get_workflow_run_data(job) + runs = workflow_data["runs"] + workflow = workflow_data["workflow"] + repo = workflow_data["repo"] + + # Calculate workflow start and end times + job_start_times = [datetime.fromisoformat(j["started_at"].replace("Z", "+00:00")) for j in all_jobs if j.get("started_at")] + job_end_times = [datetime.fromisoformat(j["completed_at"].replace("Z", "+00:00")) for j in all_jobs if j.get("completed_at")] + + workflow_start = min(job_start_times) if job_start_times else datetime.utcnow() + workflow_end = max(job_end_times) if job_end_times else datetime.utcnow() + + # Determine overall workflow status + job_conclusions = [j.get("conclusion") for j in all_jobs] + if "failure" in job_conclusions: + workflow_status = "internal_error" + elif "cancelled" in job_conclusions: + workflow_status = "cancelled" + elif "skipped" in job_conclusions: + workflow_status = "skipped" + else: + workflow_status = "ok" + + # Create workflow transaction + workflow_transaction = { + "type": "transaction", + "transaction": f"workflow: {workflow['name']}", # Use "transaction" not "transaction_name" + "platform": "python", + "environment": "production", + "release": runs.get("head_sha", "main")[:8], + "sdk": { + "name": "gha-sentry-workflow", + "version": "0.0.1" + }, + "contexts": { + "trace": { + "span_id": get_uuid()[:16], + "trace_id": get_uuid_from_string( + f"workflow_run_id:{job['run_id']}_run_attempt:{job['run_attempt']}" + ), + "type": "trace", + "op": "workflow", + "description": f"GitHub Actions workflow: {workflow['name']}", + "status": workflow_status + }, + "runtime": { + "name": "python", + "version": "3.8.0" + } + }, + "user": runs["head_commit"]["author"], + "start_timestamp": workflow_start.strftime("%Y-%m-%dT%H:%M:%SZ"), + "timestamp": workflow_end.strftime("%Y-%m-%dT%H:%M:%SZ"), + "level": "info", + "logger": "workflow_tracer", + "tags": { + "workflow_name": workflow["name"], + "workflow_status": workflow_status, + "branch": runs["head_branch"], + "commit": runs["head_sha"], + "repo": repo, + "run_attempt": runs["run_attempt"], + "total_jobs": len(all_jobs), + "successful_jobs": len([j for j in all_jobs if j.get("conclusion") == "success"]), + "failed_jobs": len([j for j in all_jobs if j.get("conclusion") == "failure"]), + "cancelled_jobs": len([j for j in all_jobs if j.get("conclusion") == "cancelled"]), + "skipped_jobs": len([j for j in all_jobs if j.get("conclusion") == "skipped"]), + "trace_version": "v3.6" + }, + "extra": { + "workflow_url": runs["html_url"], + "workflow_file": workflow["path"], + "total_duration": (workflow_end - workflow_start).total_seconds() + }, + "spans": [] + } + + # Add PR info if available + if runs.get("pull_requests"): + pr_number = runs["pull_requests"][0]["number"] + workflow_transaction["extra"]["pr"] = f"https://github.com/{repo}/pull/{pr_number}" + workflow_transaction["tags"]["pull_request"] = pr_number + + # Add job spans to the workflow transaction + workflow_span_id = workflow_transaction["contexts"]["trace"]["span_id"] + workflow_trace_id = workflow_transaction["contexts"]["trace"]["trace_id"] + + for job_data in all_jobs: + # Create job span + job_span = { + "op": "job", + "description": job_data["name"], + "parent_span_id": workflow_span_id, + "span_id": get_uuid()[:16], + "start_timestamp": datetime.fromisoformat(job_data["started_at"].replace("Z", "+00:00")).strftime("%Y-%m-%dT%H:%M:%SZ"), + "end_timestamp": datetime.fromisoformat(job_data["completed_at"].replace("Z", "+00:00")).strftime("%Y-%m-%dT%H:%M:%SZ"), + "trace_id": workflow_trace_id, + "status": "ok" if job_data["conclusion"] in ["success", "skipped"] else "internal_error", + "data": { + "job_url": job_data["html_url"], + "job_status": job_data["conclusion"], + "job_name": job_data["name"], + "job_id": job_data["id"] + } + } + workflow_transaction["spans"].append(job_span) + + # Add step spans as children of job span + for step in job_data.get("steps", []): + step_span = { + "op": "step", + "description": step["name"], + "parent_span_id": job_span["span_id"], + "span_id": get_uuid()[:16], + "start_timestamp": datetime.fromisoformat(step["started_at"].replace("Z", "+00:00")).strftime("%Y-%m-%dT%H:%M:%SZ"), + "end_timestamp": datetime.fromisoformat(step["completed_at"].replace("Z", "+00:00")).strftime("%Y-%m-%dT%H:%M:%SZ"), + "trace_id": workflow_trace_id, + "status": "ok" if step["conclusion"] == "success" else "internal_error", + "data": { + "step_name": step["name"], + "step_number": step["number"], + "step_conclusion": step["conclusion"] + } + } + workflow_transaction["spans"].append(step_span) + + return workflow_transaction + + + def send_workflow_trace(self, job: Dict[str, Any], all_jobs: List[Dict[str, Any]] = None): + """Send a single workflow transaction with all job and step spans""" + if self.dry_run: + logging.info(f"Dry run: Would send workflow trace for {job['name']}") + return + + if all_jobs is None: + all_jobs = [job] + + try: + logging.info(f"Creating workflow transaction for {len(all_jobs)} jobs") + logging.info(f"Job names: {[j['name'] for j in all_jobs]}") + + # Create single workflow transaction with all spans + workflow_transaction = self._create_workflow_transaction(job, all_jobs) + workflow_trace_id = workflow_transaction["contexts"]["trace"]["trace_id"] + + # Log detailed transaction info + logging.info(f"Workflow transaction details:") + logging.info(f" - Trace ID: {workflow_trace_id}") + logging.info(f" - Transaction name: {workflow_transaction['transaction']}") + logging.info(f" - Total spans: {len(workflow_transaction['spans'])}") + logging.info(f" - Trace version: {workflow_transaction['tags']['trace_version']}") + logging.info(f" - Workflow status: {workflow_transaction['contexts']['trace']['status']}") + + # Log span details + job_spans = [s for s in workflow_transaction['spans'] if s['op'] == 'job'] + step_spans = [s for s in workflow_transaction['spans'] if s['op'] == 'step'] + logging.info(f" - Job spans: {len(job_spans)}") + logging.info(f" - Step spans: {len(step_spans)}") + + logging.info(f"Sending workflow transaction with trace_id: {workflow_trace_id}") + # Send single workflow transaction + self._send_envelope(workflow_transaction) + + logging.info(f"Successfully sent workflow trace with {len(all_jobs)} jobs") + + except Exception as e: + logging.error(f"Error in send_workflow_trace: {e}", exc_info=True) + raise + + def _send_envelope(self, transaction: Dict[str, Any]): + """Send transaction to Sentry""" + if self.dry_run: + return + + # Save transaction payload for Postman testing + import json + trace_id = transaction.get('contexts', {}).get('trace', {}).get('trace_id', 'unknown') + filename = f"transaction_payload_{trace_id}.json" + + with open(filename, 'w') as f: + json.dump(transaction, f, indent=2) + + logging.info(f"💾 Transaction payload saved to: {filename}") + logging.info(f"📋 Transaction details:") + logging.info(f" - Trace ID: {trace_id}") + logging.info(f" - Transaction: {transaction.get('transaction')}") + logging.info(f" - Total spans: {len(transaction.get('spans', []))}") + logging.info(f" - Trace version: {transaction.get('tags', {}).get('trace_version')}") + + logging.info(f"Sending envelope to Sentry: {self.sentry_project_url}") + logging.info(f"Transaction type: {transaction.get('type')}") + logging.info(f"Transaction name: {transaction.get('transaction')}") + logging.info(f"Trace ID: {transaction.get('contexts', {}).get('trace', {}).get('trace_id')}") + + # Create a copy of the transaction without event_id for sending to Sentry + transaction_for_sentry = transaction.copy() + if 'event_id' in transaction_for_sentry: + del transaction_for_sentry['event_id'] + + envelope = Envelope() + envelope.add_transaction(transaction_for_sentry) + now = datetime.utcnow() + + headers = { + "event_id": get_uuid(), + "sent_at": format_timestamp(now), + "Content-Type": "application/x-sentry-envelope", + "Content-Encoding": "gzip", + "X-Sentry-Auth": f"Sentry sentry_key={self.sentry_key}," + + f"sentry_client=gha-sentry-workflow/0.0.1,sentry_timestamp={now}," + + "sentry_version=7", + } + + import io + import gzip + + body = io.BytesIO() + with gzip.GzipFile(fileobj=body, mode="w") as f: + envelope.serialize_into(f) + + logging.info(f"Envelope size: {len(body.getvalue())} bytes") + + req = requests.post( + self.sentry_project_url, + data=body.getvalue(), + headers=headers, + ) + + logging.info(f"Sentry response: {req.status_code} - {req.text}") + req.raise_for_status() + return req