dynamo/.github/workflows/upload_complete_workflow_me...

1051 lines
42 KiB
Python

# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""
Enhanced script to upload complete GitHub Actions workflow and job metrics.
This version runs as the final job in a workflow and captures metrics for
the entire workflow including all previous jobs.
"""
import glob
import json
import os
import re
import time
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from urllib.parse import urlparse
import requests
# FILTERING CONFIGURATION - Process all jobs except excluded ones
EXCLUDED_JOB_NAMES = [
"Upload Workflow Metrics", # Avoid infinite loops
# Add other job names to exclude here as needed
]
FRAMEWORK_IMAGE_BUILD_JOBS = ["vllm", "sglang", "trtllm"]
# NEW STANDARDIZED FIELD SCHEMA - Using consistent prefixes for OpenSearch mapping
# Using prefixes: s_ for strings, l_ for longs, ts_ for timestamps
# Common fields across all tables
FIELD_ID = "_id"
FIELD_USER_ALIAS = "s_user_alias"
FIELD_REPO = "s_repo"
FIELD_WORKFLOW_NAME = "s_workflow_name"
FIELD_GITHUB_EVENT = "s_github_event"
FIELD_BRANCH = "s_branch"
FIELD_PR_ID = "s_pr_id" # Pull request ID as string ("N/A" if not a PR)
FIELD_STATUS = "s_status"
FIELD_STATUS_NUMBER = "l_status_number"
FIELD_WORKFLOW_ID = "s_workflow_id"
FIELD_COMMIT_SHA = "s_commit_sha"
# Timing fields
FIELD_CREATION_TIME = "ts_creation_time"
FIELD_START_TIME = "ts_start_time"
FIELD_END_TIME = "ts_end_time"
FIELD_QUEUE_TIME = "l_queue_time_sec" # Integer seconds as long
FIELD_DURATION_SEC = "l_duration_sec"
# Job-specific fields
FIELD_JOB_ID = "s_job_id"
FIELD_JOB_NAME = "s_job_name"
FIELD_RUNNER_ID = "s_runner_id"
FIELD_RUNNER_NAME = "s_runner_name"
# Step-specific fields
FIELD_STEP_ID = "s_step_id"
FIELD_NAME = "s_step_name"
FIELD_STEP_NUMBER = "l_step_number"
FIELD_COMMAND = "s_command"
# Container-specific fields (for CONTAINER_INDEX)
FIELD_BUILD_DURATION_SEC = "l_build_duration_sec"
FIELD_BUILD_START_TIME = "ts_build_start_time"
FIELD_BUILD_END_TIME = "ts_build_end_time"
FIELD_BUILD_TARGET = "s_build_target"
FIELD_BUILD_FRAMEWORK = "s_build_framework"
FIELD_BUILD_SIZE_BYTES = "l_build_size_bytes"
# Test Info
FIELD_FRAMEWORK = "s_framework"
FIELD_ERROR_MESSAGE = "s_error_message"
FIELD_TEST_NAME = "s_test_name" # Test name (e.g., test_sglang_deployment[aggregated])
FIELD_TEST_CLASSNAME = (
"s_test_classname" # Test class name (e.g., tests.serve.test_sglang)
)
FIELD_TEST_DURATION = "l_test_duration_ms"
FIELD_TEST_STATUS = "s_test_status" # Test status (passed, failed, error, skipped)
class BuildMetricsReader:
"""Reader for build metrics from environment variables and artifacts"""
@staticmethod
def _process_artifact_metrics(artifact_metrics: Dict[str, Any]) -> Dict[str, Any]:
"""Process and clean up artifact metrics data"""
# Convert types (same as in get_build_metrics)
if "build_duration_sec" in artifact_metrics:
try:
artifact_metrics["build_duration_sec"] = int(
artifact_metrics["build_duration_sec"]
)
except (ValueError, TypeError):
artifact_metrics["build_duration_sec"] = 0
if "image_size_bytes" in artifact_metrics:
try:
artifact_metrics["image_size_bytes"] = int(
artifact_metrics["image_size_bytes"]
)
except (ValueError, TypeError):
artifact_metrics["image_size_bytes"] = 0
# Convert Unix timestamps to ISO format if needed
for time_field in ["build_start_time", "build_end_time"]:
if time_field in artifact_metrics and artifact_metrics[time_field]:
time_value = artifact_metrics[time_field]
if isinstance(time_value, (int, float)) or (
isinstance(time_value, str) and time_value.isdigit()
):
try:
timestamp = float(time_value)
artifact_metrics[time_field] = datetime.fromtimestamp(
timestamp, tz=timezone.utc
).isoformat()
except (ValueError, OSError):
pass # Keep original value if conversion fails
return artifact_metrics
@staticmethod
def get_build_metrics_for_job(job_name: str) -> Optional[Dict[str, Any]]:
"""Get build metrics for a specific job by looking for framework-specific artifacts"""
# Determine framework from job name
framework = None
job_name_lower = job_name.lower()
if "vllm" in job_name_lower:
framework = "vllm"
elif "sglang" in job_name_lower:
framework = "sglang"
elif "trtllm" in job_name_lower:
framework = "trtllm"
if not framework:
print(f"⚠️ Could not determine framework from job name: {job_name}")
return None
# Determine architecture preference from job name
preferred_arch = "amd64" # default
if "arm64" in job_name_lower:
preferred_arch = "arm64"
# Try to read consolidated metrics file first
consolidated_path = "build-metrics/consolidated-metrics.json"
if os.path.exists(consolidated_path):
try:
with open(consolidated_path, "r") as f:
all_metrics = json.load(f)
# Look for job-specific metrics
# Try preferred architecture first
job_key = f"{framework}-{preferred_arch}"
if job_key in all_metrics:
return BuildMetricsReader._process_artifact_metrics(
all_metrics[job_key]
)
# Try other architecture
other_arch = "arm64" if preferred_arch == "amd64" else "amd64"
job_key = f"{framework}-{other_arch}"
if job_key in all_metrics:
return BuildMetricsReader._process_artifact_metrics(
all_metrics[job_key]
)
# Try just framework name (backward compatibility)
if framework in all_metrics:
return BuildMetricsReader._process_artifact_metrics(
all_metrics[framework]
)
print(
f"⚠️ No metrics found for {framework} in consolidated file. Available keys: {list(all_metrics.keys())}"
)
except Exception as e:
print(f"❌ Error reading consolidated build metrics: {e}")
# Fallback to individual file approach for backward compatibility
# Try framework-specific artifact (direct path)
artifact_path = f"build-metrics/metrics-{framework}-{preferred_arch}-*.json"
if not os.path.exists(artifact_path):
# Try the other architecture (direct path)
other_arch = "arm64" if preferred_arch == "amd64" else "amd64"
artifact_path = f"build-metrics/metrics-{framework}-{other_arch}-*.json"
if not os.path.exists(artifact_path):
# Try artifact subdirectory structure (new format)
artifact_path = f"build-metrics/build-metrics-{framework}-{preferred_arch}/metrics-{framework}-{preferred_arch}-*.json"
if not os.path.exists(artifact_path):
# Try other architecture in subdirectory
other_arch = "arm64" if preferred_arch == "amd64" else "amd64"
artifact_path = f"build-metrics/build-metrics-{framework}-{other_arch}/metrics-{framework}-{other_arch}-*.json"
if not os.path.exists(artifact_path):
# Try old naming convention (backward compatibility)
artifact_path = f"build-metrics/metrics-{framework}-*.json"
if not os.path.exists(artifact_path):
# Try alternative path (old format)
artifact_path = f"build-metrics/build-metrics-{framework}/metrics.json"
if os.path.exists(artifact_path):
try:
with open(artifact_path, "r") as f:
artifact_metrics = json.load(f)
return BuildMetricsReader._process_artifact_metrics(
artifact_metrics
)
except Exception as e:
print(
f"⚠️ Could not read {framework} build metrics from {artifact_path}: {e}"
)
print(f"⚠️ No build metrics artifact found for {framework} at {artifact_path}")
return None
class TimingProcessor:
"""Centralized processor for all datetime and duration conversions using Python built-ins"""
@staticmethod
def _parse_iso(iso_string: str) -> datetime:
"""Parse ISO datetime string using built-in fromisoformat"""
if not iso_string:
return None
try:
# Handle 'Z' suffix by replacing with '+00:00'
if iso_string.endswith("Z"):
iso_string = iso_string[:-1] + "+00:00"
return datetime.fromisoformat(iso_string)
except ValueError:
return None
@staticmethod
def calculate_time_diff(start_time: str, end_time: str) -> int:
"""Calculate duration/queue time in integer seconds"""
if not start_time or not end_time:
return 0
start_dt = TimingProcessor._parse_iso(start_time)
end_dt = TimingProcessor._parse_iso(end_time)
if not start_dt or not end_dt:
return 0
# Return integer seconds directly
duration = end_dt - start_dt
return max(0, int(duration.total_seconds()))
def mask_sensitive_urls(error_msg: str, url: str) -> str:
"""Comprehensively mask sensitive URLs and hostnames in error messages"""
if not url:
return error_msg
try:
parsed_url = urlparse(url)
hostname = parsed_url.hostname
path = parsed_url.path
# Replace components in order of specificity
if hostname:
error_msg = error_msg.replace(hostname, "***HOSTNAME***")
if url in error_msg:
error_msg = error_msg.replace(url, "***DATABASE_URL***")
if path and path in error_msg:
error_msg = error_msg.replace(path, "***PATH***")
# Also mask any remaining URL patterns
if hostname:
pattern = rf"https?://{re.escape(hostname)}"
error_msg = re.sub(pattern, "***MASKED_URL***", error_msg)
except Exception:
# If URL parsing fails, do basic masking
if url in error_msg:
error_msg = error_msg.replace(url, "***DATABASE_URL***")
return error_msg
class WorkflowMetricsUploader:
def __init__(self):
self.headers = {"Content-Type": "application/json", "Accept-Charset": "UTF-8"}
self.workflow_index = os.getenv("WORKFLOW_INDEX", "")
self.jobs_index = os.getenv("JOB_INDEX", "")
self.steps_index = os.getenv("STEPS_INDEX", "")
# Validate that database URLs are provided
if not self.workflow_index or not self.jobs_index or not self.steps_index:
raise ValueError(
"Database URLs not configured. Please set environment variables:\n"
" WORKFLOW_INDEX - URL for workflow metrics\n"
" JOB_INDEX - URL for job metrics\n"
" STEPS_INDEX - URL for step metrics"
)
# Get current workflow information
self.repo = os.getenv("GITHUB_REPOSITORY")
self.run_id = os.getenv("GITHUB_RUN_ID")
self.workflow_name = os.getenv("GITHUB_WORKFLOW")
self.actor = os.getenv("GITHUB_ACTOR")
self.event_name = os.getenv("GITHUB_EVENT_NAME")
self.ref = os.getenv("GITHUB_REF")
self.ref_name = os.getenv("GITHUB_REF_NAME")
self.sha = os.getenv("GITHUB_SHA")
if not self.repo or not self.run_id:
raise ValueError("Missing required GitHub environment variables")
print(
f"Uploading metrics for workflow '{self.workflow_name}' (run {self.run_id}) in {self.repo}"
)
def handle_upload_error(self, error: Exception, operation: str) -> str:
"""Centralized error handling with URL masking for all upload operations
Args:
error: The exception that occurred
operation: Description of the operation that failed
Returns:
Sanitized error message with URLs masked
"""
error_msg = str(error)
# Mask all configured URLs to prevent exposure
for url in [self.workflow_index, self.jobs_index, self.steps_index]:
if url: # Only mask non-empty URLs
error_msg = mask_sensitive_urls(error_msg, url)
return f"Error during {operation}: {error_msg}"
def post_to_db(self, url: str, data: Dict[str, Any]) -> None:
"""Push json data to the database/OpenSearch URL"""
print(f"Posting metrics to database... with data: {data}")
try:
response = requests.post(
url, data=json.dumps(data), headers=self.headers, timeout=30
)
if not (200 <= response.status_code < 300):
raise ValueError(f"Error posting to DB: HTTP {response.status_code}")
print(f"Successfully posted metrics with ID: {data.get('_id', 'unknown')}")
except requests.exceptions.RequestException as e:
# Use centralized error handling
sanitized_error = self.handle_upload_error(e, "database upload")
raise ValueError(sanitized_error)
def get_github_api_data(self, endpoint: str) -> Optional[Dict[str, Any]]:
"""Fetch data from GitHub API"""
token = os.getenv("GITHUB_TOKEN")
if not token:
print(
"Error: No GitHub token found. Set GITHUB_TOKEN environment variable or repository secret."
)
return None
headers = {
"Authorization": f"token {token}",
"Accept": "application/vnd.github.v3+json",
}
try:
response = requests.get(
f"https://api.github.com{endpoint}", headers=headers, timeout=30
)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
print(self.handle_upload_error(e, f"GitHub API GET {endpoint}"))
return None
def add_common_context_fields(
self, db_data: Dict[str, Any], workflow_data: Optional[Dict[str, Any]] = None
) -> None:
"""Add common context fields used across all metric types"""
db_data[FIELD_USER_ALIAS] = self.actor
db_data[FIELD_REPO] = self.repo
db_data[FIELD_WORKFLOW_NAME] = self.workflow_name
db_data[FIELD_GITHUB_EVENT] = self.event_name
db_data[FIELD_BRANCH] = self.ref_name
db_data[FIELD_WORKFLOW_ID] = str(self.run_id)
db_data[FIELD_COMMIT_SHA] = self.sha
# Extract PR ID from workflow data if available
pr_id = "N/A" # Default to "N/A" for non-PR workflows
if workflow_data:
pull_requests = workflow_data.get("pull_requests", [])
if pull_requests and len(pull_requests) > 0:
pr_number = pull_requests[0].get("number")
if pr_number:
pr_id = str(pr_number)
db_data[FIELD_PR_ID] = pr_id
def add_standardized_timing_fields(
self,
db_data: Dict[str, Any],
creation_time: str,
start_time: str,
end_time: str,
metric_type: str = "workflow",
) -> None:
"""Add standardized timing-related fields across all metric types
Args:
db_data: Dictionary to add timing fields to
creation_time: ISO datetime string for creation time
start_time: ISO datetime string for when execution actually started
end_time: ISO datetime string for end time
metric_type: Type of metric ("workflow", "job", "step") for field naming consistency
"""
# Store original ISO timestamps
db_data[FIELD_START_TIME] = start_time or ""
db_data[FIELD_END_TIME] = end_time or ""
if creation_time: # Don't add for steps
db_data[FIELD_CREATION_TIME] = creation_time
# Duration in integer seconds (using l_ prefix for long type)
db_data[FIELD_DURATION_SEC] = TimingProcessor.calculate_time_diff(
start_time, end_time
)
# Queue time in integer seconds (using l_ prefix for long type)
if metric_type != "step":
db_data[FIELD_QUEUE_TIME] = TimingProcessor.calculate_time_diff(
creation_time, start_time
)
# Use the end_time if available, otherwise use current time
if end_time:
# Ensure timestamp is in proper ISO format for OpenSearch date detection
db_data["@timestamp"] = end_time
else:
# Use Z format to match 24h script format
db_data["@timestamp"] = datetime.now(timezone.utc).strftime(
"%Y-%m-%dT%H:%M:%SZ"
)
def post_all_metrics(self) -> None:
"""Upload complete workflow metrics including workflow, jobs, and steps in one operation"""
print(
f"Uploading complete metrics for workflow '{self.workflow_name}' (run {self.run_id})"
)
max_retries = 1
retry_delay = 15 # seconds
for attempt in range(max_retries):
# Get workflow and jobs data from GitHub API
workflow_data = self.get_github_api_data(
f"/repos/{self.repo}/actions/runs/{self.run_id}"
)
if not workflow_data:
print("Could not fetch workflow data from GitHub API")
return
jobs_data = self.get_github_api_data(
f"/repos/{self.repo}/actions/runs/{self.run_id}/jobs"
)
if not jobs_data or "jobs" not in jobs_data:
print("Could not fetch jobs data from GitHub API")
return
# Count jobs to process (exclude specified jobs)
workflow_name = workflow_data.get("name", "")
jobs_to_process = [
job
for job in jobs_data.get("jobs", [])
if job.get("name") not in EXCLUDED_JOB_NAMES
]
if not jobs_to_process:
print(
f"❌ No jobs to process after excluding jobs: {EXCLUDED_JOB_NAMES}"
)
print(
f" Available jobs: {[job.get('name') for job in jobs_data.get('jobs', [])]}"
)
return
print("✅ Processing workflow metrics - proceeding with upload")
print(f" Workflow: '{workflow_name}'")
print(
f" Jobs to process: {len(jobs_to_process)} (excluding {EXCLUDED_JOB_NAMES})"
)
print(f" Job names: {[job.get('name') for job in jobs_to_process]}")
# Check if workflow is completed
workflow_status = workflow_data.get("status", "")
workflow_conclusion = workflow_data.get("conclusion")
if workflow_status == "completed" or workflow_conclusion:
print(
f"Workflow completed with status: {workflow_status}, conclusion: {workflow_conclusion}"
)
break
elif attempt < max_retries - 1:
print(
f"Workflow still {workflow_status}, waiting {retry_delay}s before retry {attempt + 1}/{max_retries}"
)
time.sleep(retry_delay)
else:
print(
f"Workflow still {workflow_status} after {max_retries} attempts, uploading current state"
)
break
# Upload workflow metrics
try:
print("Processing workflow metrics...")
self._upload_workflow_metrics(workflow_data, jobs_data)
print("Workflow metrics uploaded successfully")
except Exception as e:
sanitized_error = self.handle_upload_error(e, "workflow metrics upload")
print(sanitized_error)
# Upload all job and step metrics
try:
print(f"Processing {len(jobs_data['jobs'])} jobs and their steps...")
jobs_processed, steps_processed = self._upload_all_job_and_step_metrics(
jobs_data
)
print(
f"Successfully uploaded {jobs_processed} job metrics and {steps_processed} step metrics"
)
except Exception as e:
sanitized_error = self.handle_upload_error(e, "job/step metrics upload")
print(sanitized_error)
def _upload_workflow_metrics(
self, workflow_data: Dict[str, Any], jobs_data: Dict[str, Any]
) -> None:
"""Internal method to upload workflow metrics"""
db_data = {}
db_data[FIELD_ID] = f"github-workflow-{self.run_id}"
# Schema fields
# Use conclusion for completed workflows, fallback to status
db_data[FIELD_STATUS] = str(
workflow_data.get("conclusion") or workflow_data.get("status", "unknown")
)
if db_data[FIELD_STATUS] == "success":
db_data[FIELD_STATUS_NUMBER] = 1
elif db_data[FIELD_STATUS] == "failure":
db_data[FIELD_STATUS_NUMBER] = 0
print(f"Checking branch: {str(workflow_data.get('head_branch'))}")
# Timing fields
created_at = workflow_data.get("created_at")
run_started_at = workflow_data.get("run_started_at")
end_time = workflow_data.get("completed_at") or workflow_data.get("updated_at")
self.add_standardized_timing_fields(
db_data, created_at, run_started_at, end_time, "workflow"
)
# Common context fields
self.add_common_context_fields(db_data, workflow_data)
# Post to database
self.post_to_db(self.workflow_index, db_data)
def _upload_all_job_and_step_metrics(
self, jobs_data: Dict[str, Any]
) -> tuple[int, int]:
"""Internal method to upload all job and step metrics, returns (jobs_processed, steps_processed)"""
jobs_processed = 0
steps_processed = 0
for job in jobs_data["jobs"]:
try:
job_name = job.get("name", "")
# FILTER: Skip excluded jobs to avoid infinite loops and other unwanted jobs
if job_name in EXCLUDED_JOB_NAMES:
print(f"⏭️ Skipping excluded job '{job_name}'")
continue
print(f"📤 Uploading job: '{job_name}'")
# Upload job metrics
self._upload_single_job_metrics(job)
jobs_processed += 1
# Upload step metrics for this job
if self.steps_index:
step_count = self._upload_job_step_metrics(job)
steps_processed += step_count
except Exception as e:
print(
f"Error uploading metrics for job {job.get('name', 'unknown')}: {e}"
)
continue
return jobs_processed, steps_processed
def _upload_single_job_metrics(self, job_data: Dict[str, Any]) -> None:
"""Extract and post metrics for a single job"""
# Extract job metrics using standardized functions
db_data = {}
job_id = job_data["id"]
job_name = job_data["name"]
db_data[FIELD_ID] = f"github-job-{job_id}"
# Schema fields
db_data[FIELD_JOB_ID] = str(job_id)
# Handle job status - prefer conclusion for completed jobs, fallback to status
db_data[FIELD_STATUS] = str(
job_data.get("conclusion") or job_data.get("status") or "unknown"
)
if db_data[FIELD_STATUS] == "success":
db_data[FIELD_STATUS_NUMBER] = 1
elif db_data[FIELD_STATUS] == "failure":
db_data[FIELD_STATUS_NUMBER] = 0
db_data[FIELD_JOB_NAME] = str(job_name)
# Timing fields
created_at = job_data.get("created_at")
started_at = job_data.get("started_at")
completed_at = job_data.get("completed_at")
self.add_standardized_timing_fields(
db_data, created_at, started_at, completed_at, "job"
)
# Runner info
runner_id = job_data.get("runner_id")
db_data[FIELD_RUNNER_ID] = str(runner_id) if runner_id is not None else ""
db_data[FIELD_RUNNER_NAME] = str(job_data.get("runner_name", ""))
# Add common context fields
self.add_common_context_fields(db_data)
self.post_to_db(self.jobs_index, db_data)
print(f"Uploaded metrics for job: {job_name}")
# Upload container metrics if this is a build job and metrics are available
# Check if this is one of our framework build jobs
is_framework_job = any(
framework in job_name.lower() for framework in FRAMEWORK_IMAGE_BUILD_JOBS
)
if is_framework_job:
self._upload_container_metrics(job_data)
# Also upload test metrics if available for this framework job
self._upload_test_metrics(job_data)
def _upload_job_step_metrics(self, job_data: Dict[str, Any]) -> int:
"""Extract and post metrics for all steps in a job"""
job_name = job_data["name"]
steps = job_data.get("steps", [])
if not steps:
print(f"No steps found for job {job_name}")
return 0
steps_processed = 0
for step_index, step in enumerate(steps):
try:
self._upload_single_step_metrics(step, job_data, step_index)
steps_processed += 1
except Exception as e:
step_name = step.get("name", f"step_{step_index}")
print(
f"Error uploading metrics for step {step_name} in job {job_name}: {e}"
)
continue
print(f"Uploaded metrics for {steps_processed} steps in job {job_name}")
return steps_processed
def _upload_single_step_metrics(
self, step_data: Dict[str, Any], job_data: Dict[str, Any], step_index: int
) -> None:
"""Extract and post metrics for a single step"""
# Extract step metrics using standardized functions
db_data = {}
job_id = job_data["id"]
job_name = job_data["name"]
step_name = step_data.get("name", f"step_{step_index}")
step_number = step_data.get("number", step_index + 1)
# Create unique step ID and use standardized ID generation
step_id = f"{job_id}_{step_number}"
db_data[FIELD_ID] = f"github-step-{step_id}"
# Schema-compliant fields
db_data[FIELD_STEP_ID] = str(step_id)
db_data[FIELD_JOB_ID] = str(job_id)
db_data[FIELD_NAME] = str(step_name)
db_data[FIELD_STEP_NUMBER] = int(
step_number
) # Using l_ prefix, should be integer
db_data[FIELD_STATUS] = str(
step_data.get("conclusion") or step_data.get("status") or "unknown"
)
db_data[FIELD_JOB_NAME] = str(job_name)
if db_data[FIELD_STATUS] == "success":
db_data[FIELD_STATUS_NUMBER] = 1
elif db_data[FIELD_STATUS] == "failure":
db_data[FIELD_STATUS_NUMBER] = 0
# Timing fields using standardized method - Fix parameter order for steps
started_at = step_data.get("started_at")
completed_at = step_data.get("completed_at")
# For steps: creation_time=None (no queue time), start_time=started_at, end_time=completed_at
self.add_standardized_timing_fields(
db_data, None, started_at, completed_at, "step"
)
# Command/script executed (GitHub API doesn't always provide this, but we can infer)
command = ""
if step_data.get("action"):
command = f"uses: {step_data['action']}"
elif "run" in step_name.lower() or "script" in step_name.lower():
command = (
"run: <script>" # GitHub API doesn't expose the actual script content
)
db_data[FIELD_COMMAND] = command
# Add common context fields
self.add_common_context_fields(db_data)
# Post to database
self.post_to_db(self.steps_index, db_data)
print(f"Uploaded metrics for step: {step_name} (step {step_number})")
def _upload_container_metrics(
self, job_data: Dict[str, Any], build_metrics: Optional[Dict[str, Any]] = None
) -> None:
"""Upload container-specific metrics to CONTAINER_INDEX"""
container_index = os.getenv("CONTAINER_INDEX")
if not container_index:
print(
"⚠️ CONTAINER_INDEX not configured, skipping container metrics upload"
)
return
# Get build metrics if not provided
if build_metrics is None:
# Try to get framework-specific build metrics based on job name
job_name = job_data.get("name", "")
build_metrics = BuildMetricsReader.get_build_metrics_for_job(job_name)
if not build_metrics:
print(
f"⚠️ No build metrics available for container upload for job: {job_data.get('name', 'unknown')}"
)
return
print(f"📦 Uploading container metrics to {container_index}")
# Create container metrics payload
container_data = {}
# Identity & Context - container-specific fields only
job_id = str(job_data["id"])
job_name = job_data["name"]
container_data[
FIELD_ID
] = f"github-container-{job_id}-{build_metrics.get('framework', 'unknown')}"
container_data[FIELD_JOB_NAME] = str(job_name)
container_data[FIELD_JOB_ID] = job_id
# Find the "Build Container" step ID
build_step_id = None
steps = job_data.get("steps", [])
for step in steps:
if (
"build" in step.get("name", "").lower()
and "container" in step.get("name", "").lower()
):
build_step_id = f"{job_id}_{step.get('number', 1)}"
break
container_data[FIELD_STEP_ID] = build_step_id or f"{job_id}_build"
# Status - container-specific
container_data[FIELD_STATUS] = str(
job_data.get("conclusion") or job_data.get("status", "unknown")
)
# Container Info (only truly container-specific fields)
container_data[FIELD_BUILD_FRAMEWORK] = build_metrics.get(
"framework", "unknown"
)
container_data[FIELD_BUILD_SIZE_BYTES] = build_metrics.get(
"image_size_bytes", 0
)
# Timing (reusing existing build timing fields)
if "build_start_time" in build_metrics:
container_data[FIELD_BUILD_START_TIME] = build_metrics["build_start_time"]
if "build_end_time" in build_metrics:
container_data[FIELD_BUILD_END_TIME] = build_metrics["build_end_time"]
container_data[FIELD_BUILD_DURATION_SEC] = build_metrics.get(
"build_duration_sec", 0
)
# Add @timestamp for time-series data
container_data["@timestamp"] = build_metrics.get(
"build_end_time", datetime.now(timezone.utc).isoformat()
)
# Add common context fields
self.add_common_context_fields(container_data)
# Upload to container index
try:
self.post_to_db(container_index, container_data)
print(
f"✅ Container metrics uploaded for {build_metrics.get('framework', 'unknown')} framework"
)
except Exception as e:
print(f"❌ Failed to upload container metrics: {e}")
def _upload_test_metrics(self, job_data: Dict[str, Any]) -> None:
"""Upload individual test metrics by parsing JUnit XML directly from test-results"""
test_index = os.getenv("TEST_INDEX")
if not test_index:
print("⚠️ TEST_INDEX not configured, skipping test metrics upload")
return
job_name = job_data.get("name", "")
job_id = str(job_data["id"])
print(f"🧪 Looking for test results for job '{job_name}'")
# Determine framework from job name to filter metadata files
framework = None
job_name_lower = job_name.lower()
if "vllm" in job_name_lower:
framework = "vllm"
elif "sglang" in job_name_lower:
framework = "sglang"
elif "trtllm" in job_name_lower:
framework = "trtllm"
if not framework:
print(f"⚠️ Could not determine framework from job name: {job_name}")
return
# Determine platform architecture from job name
# Job names typically look like: "vllm (amd64)" or "sglang (arm64)"
platform_arch = None
if "(amd64)" in job_name_lower or "amd64" in job_name_lower:
platform_arch = "amd64"
elif "(arm64)" in job_name_lower or "arm64" in job_name_lower:
platform_arch = "arm64"
if not platform_arch:
print(
f"⚠️ Could not determine platform architecture from job name: {job_name}"
)
# Default to amd64 if not specified
platform_arch = "amd64"
print(f" Defaulting to platform_arch: {platform_arch}")
print(f"📦 Job framework: {framework}, platform_arch: {platform_arch}")
# Look for test results directory
test_results_dir = "test-results"
if not os.path.exists(test_results_dir):
print(f"⚠️ Test results directory not found: {test_results_dir}")
return
# Look for metadata files to get accurate step and framework info
# Updated pattern to match new unique naming: test_metadata_<framework>_<test_type>_<arch>.json
# Filter by both framework AND architecture to only process this job's tests
metadata_files = glob.glob(
f"{test_results_dir}/test_metadata_{framework}_*_{platform_arch}.json"
)
if not metadata_files:
print(
f"⚠️ No test metadata files found for framework '{framework}' with arch '{platform_arch}' in {test_results_dir}"
)
return
print(
f"📄 Found {len(metadata_files)} test metadata files for {framework} ({platform_arch})"
)
total_tests_processed = 0
# Process each metadata file
for metadata_file in metadata_files:
try:
# Read metadata to get accurate step and framework info
with open(metadata_file, "r") as f:
metadata = json.load(f)
framework = metadata.get("framework", "unknown")
test_type = metadata.get("test_type", "unknown")
step_name = metadata.get("step_name", "Run tests")
junit_xml_file = metadata.get(
"junit_xml_file", "pytest_test_report.xml"
)
# Construct step ID from metadata
test_step_id = f"{job_id}_{step_name.lower().replace(' ', '_')}"
print("📋 Processing test results:")
print(f" Framework: {framework}")
print(f" Test Type: {test_type}")
print(f" Step Name: {step_name}")
print(f" Step ID: {test_step_id}")
# Find the corresponding XML file
xml_file = f"{test_results_dir}/{junit_xml_file}"
if not os.path.exists(xml_file):
print(f"⚠️ JUnit XML file not found: {xml_file}")
continue
print(f"📄 Processing JUnit XML: {xml_file}")
# Parse JUnit XML using xml.etree.ElementTree
tree = ET.parse(xml_file)
root = tree.getroot()
# Process each test case
for testsuite in root.findall(".//testsuite"):
for testcase in testsuite.findall("testcase"):
# Extract test case information
test_classname = testcase.get("classname", "")
test_name = testcase.get("name", "")
test_time = float(testcase.get("time", 0))
test_status = "passed" # Default status
# Create individual test data payload
test_data = {}
# Identity & Context
test_full_name = (
f"{test_classname}::{test_name}"
if test_classname
else test_name
)
test_data[
FIELD_ID
] = f"github-test-{job_id}-{hash(test_full_name) & 0x7FFFFFFF}" # Use hash for unique ID
test_data[FIELD_STEP_ID] = test_step_id
test_data[FIELD_JOB_ID] = job_id
# Test Info
test_data[FIELD_FRAMEWORK] = framework
test_data[FIELD_TEST_NAME] = test_name
test_data[FIELD_TEST_CLASSNAME] = test_classname
test_data[FIELD_TEST_DURATION] = int(
test_time * 1000
) # Convert to milliseconds
# Check for failure, error, or skipped elements
error_msg = ""
if testcase.find("failure") is not None:
test_status = "failed"
failure_elem = testcase.find("failure")
error_msg = (
failure_elem.get("message", "")
if failure_elem is not None
else ""
)
if (
not error_msg
and failure_elem is not None
and failure_elem.text
):
error_msg = failure_elem.text
elif testcase.find("error") is not None:
test_status = "error"
error_elem = testcase.find("error")
error_msg = (
error_elem.get("message", "")
if error_elem is not None
else ""
)
if (
not error_msg
and error_elem is not None
and error_elem.text
):
error_msg = error_elem.text
elif testcase.find("skipped") is not None:
test_status = "skipped"
skipped_elem = testcase.find("skipped")
error_msg = (
skipped_elem.get("message", "")
if skipped_elem is not None
else ""
)
test_data[FIELD_TEST_STATUS] = test_status
test_data[
FIELD_STATUS
] = test_status # Also set general status field
if error_msg:
test_data[FIELD_ERROR_MESSAGE] = error_msg[
:1000
] # Limit error message length
# Add timing (use job completion time as more accurate timestamp)
job_completed_at = job_data.get("completed_at")
if job_completed_at:
test_data["@timestamp"] = job_completed_at
else:
# Fallback to current time if job completion time not available
test_data["@timestamp"] = datetime.now(
timezone.utc
).isoformat()
# Add common context fields (repo, branch, pr_id, etc.)
self.add_common_context_fields(test_data)
# Upload individual test
try:
self.post_to_db(test_index, test_data)
print(
f"✅ Uploaded test: {test_full_name} ({test_status}, {test_time:.3f}s)"
)
total_tests_processed += 1
except Exception as e:
print(f"❌ Failed to upload test {test_full_name}: {e}")
except Exception as e:
print(f"❌ Failed to process metadata file {metadata_file}: {e}")
print(f"📊 Processed {total_tests_processed} individual tests total")
print(" " + "=" * 50)
def main():
"""Main function to upload complete GitHub Actions workflow metrics"""
try:
uploader = WorkflowMetricsUploader()
except ValueError as e:
print(f"Configuration error: {e}")
return
print(
f"Processing complete metrics for workflow '{uploader.workflow_name}' (run {uploader.run_id})"
)
# Upload all metrics (workflow, jobs, and steps) in one coordinated operation
uploader.post_all_metrics()
if __name__ == "__main__":
main()