| import os |
| import json |
| import base64 |
| import re |
| import subprocess |
| import urllib |
| import urllib.request |
| import tempfile |
| |
| import collections |
| import dataclasses |
| import datetime |
| from typing import List |
| |
| import bazelci |
| |
| @dataclasses.dataclass |
| class JobTimestamps: |
| created_at: str = None |
| started_at: str = None |
| finished_at: str = None |
| |
| @dataclasses.dataclass |
| class TestTarget: |
| label: str |
| status: str |
| duration_s: float |
| shard_count: int = 1 |
| shard_durations: List[float] = dataclasses.field(default_factory=list) |
| |
| # --- BigQuery Configuration constants --- |
| PROJECT_ID = "bazel-untrusted" |
| DATASET_ID = "bazel_ci_metrics" |
| TABLE_ID = "ci_builds" |
| |
| @dataclasses.dataclass |
| class BazelMetrics: |
| wall_time_ms: int = 0 |
| critical_path_s: float = 0.0 |
| remote_and_disk_cache_hits: int = 0 |
| total_actions: int = 0 |
| output_size_bytes: int = 0 |
| bytes_downloaded: int = 0 |
| failed_test_count: int = 0 |
| exit_code: int = 0 |
| targets: List[TestTarget] = dataclasses.field(default_factory=list) |
| |
| def to_dict(self, is_test=False): |
| res = { |
| "wall_time_s": self.wall_time_ms / 1000.0, |
| "critical_path_s": self.critical_path_s, |
| "remote_and_disk_cache_hits": self.remote_and_disk_cache_hits, |
| "total_actions": self.total_actions, |
| "output_size_bytes": self.output_size_bytes, |
| "bytes_downloaded": self.bytes_downloaded, |
| "exit_code": self.exit_code, |
| } |
| if is_test: |
| res["failed_test_count"] = self.failed_test_count |
| res["targets"] = [dataclasses.asdict(t) for t in self.targets] |
| return res |
| |
| def print_and_annotate_warning(message): |
| """ |
| Prints a warning to the logs and annotates the Buildkite UI so it's visible on the build page. |
| """ |
| bazelci.eprint(message) |
| try: |
| job_url = f"{os.getenv('BUILDKITE_BUILD_URL')}#{os.getenv('BUILDKITE_JOB_ID')}" |
| bazelci.execute_command( |
| [ |
| "buildkite-agent", |
| "annotate", |
| "--style=warning", |
| f"{message} (for [this job]({job_url}))", |
| "--context", |
| "ctx-metrics_upload_failed", |
| ], |
| fail_if_nonzero=False, |
| ) |
| except Exception as e: |
| bazelci.eprint(f"Failed to annotate Buildkite: {e}") |
| |
| |
| def fetch_job_timestamps(org_slug, pipeline_slug, build_number, job_id): |
| """ |
| Fetches real timestamps from Buildkite API for the current job. |
| Returns: |
| JobTimestamps: An object containing created_at, started_at, and finished_at strings. |
| """ |
| try: |
| client = bazelci.BuildkiteClient(org_slug, pipeline_slug) |
| build_data = client.get_build_info(build_number) |
| for job in build_data.get("jobs", []): |
| if job.get("id") == job_id: |
| # If the job is still running when this script executes (which is typical |
| # when called from bazelci.py), finished_at is None. We fallback to the |
| # current time so Grafana queries have a valid timestamp for metrics. |
| finished_at = job.get("finished_at") |
| if not finished_at: |
| finished_at = datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.%fZ") |
| return JobTimestamps( |
| created_at=job.get("created_at"), |
| started_at=job.get("started_at"), |
| finished_at=finished_at, |
| ) |
| except Exception as e: |
| bazelci.eprint(f"Warning: Failed to fetch job timestamps: {e}") |
| |
| return JobTimestamps() |
| |
| |
| def get_git_stats(target_dir="."): |
| """Gets the number of files changed between HEAD and HEAD~1.""" |
| try: |
| # Use git show instead of diff HEAD~1 because PRs might be squashed or shallow cloned |
| # --shortstat gives a summary of "X files changed" includes NEW and DELETED files too |
| output = subprocess.check_output( |
| ["git", "show", "--shortstat", "--format="], |
| cwd=target_dir, |
| text=True, |
| stderr=subprocess.STDOUT, |
| ).strip() |
| # Output format: " 1 file changed, 1 insertion(+)" |
| match = re.search(r"(\d+)\s+file[s]?\s+changed", output) |
| if match: |
| return int(match.group(1)) |
| except Exception as e: |
| bazelci.eprint(f"Warning: Git diff failed ({e}). Defaulting changed_files to large value (9999).") |
| |
| # This will be passed by the cache hit metric, as it only considers small changes. |
| # but we can also return -1 and update Grafana to skip these values. |
| return 9999 |
| |
| |
| def extract_critical_path(build_tool_logs): |
| """ |
| Decodes the 'critical path' log from Base64 and extracts the duration. |
| """ |
| for log in build_tool_logs: |
| if log.get("name") == "critical path": |
| try: |
| content_b64 = log.get("contents") |
| if not content_b64: |
| continue |
| content_str = base64.b64decode(content_b64).decode("utf-8") |
| match = re.search(r"Critical Path: ([\d\.]+)s", content_str) |
| if match: |
| return float(match.group(1)) |
| except Exception as e: |
| bazelci.eprint(f"Error parsing critical path log: {e}") |
| return 0.0 |
| |
| |
| def parse_bep(filepath): |
| """ |
| Parses the Build Event Protocol (BEP) JSON file to extract build/test metrics. |
| |
| Returns: |
| BazelMetrics: An object containing aggregated build metrics and test targets. |
| None: If the file does not exist. |
| """ |
| |
| if not os.path.exists(filepath): |
| bazelci.eprint(f"Error: BEP file not found at {filepath}") |
| return None |
| |
| bazel_metrics = BazelMetrics() |
| target_map = collections.defaultdict(list) |
| target_status = {} |
| |
| with open(filepath, "r") as f: |
| for line_num, line in enumerate(f, 1): |
| if not line.strip(): |
| continue |
| try: |
| event = json.loads(line) |
| except json.JSONDecodeError: |
| bazelci.eprint(f"Skipping invalid JSON line at line {line_num}") |
| continue |
| |
| event_id = event.get("id", {}) |
| |
| # --- 1. Test Results --- |
| if "testResult" in event: |
| data = event["testResult"] |
| label = event_id.get("testResult", {}).get("label") |
| |
| if label: |
| duration_ms = int(data.get("testAttemptDurationMillis", 0)) |
| duration_s = duration_ms / 1000.0 |
| target_map[label].append(duration_s) |
| |
| # Status (PASSED, FAILED, FLAKY) |
| current_status = data.get("status", "UNKNOWN") |
| if label not in target_status or current_status != "PASSED": |
| target_status[label] = current_status |
| |
| if current_status != "PASSED": |
| bazel_metrics.failed_test_count += 1 |
| |
| # --- 2. Build Metrics --- |
| elif "buildMetrics" in event: |
| buildMetrics = event["buildMetrics"] |
| bazel_metrics.wall_time_ms = int( |
| buildMetrics.get("timingMetrics", {}).get("wallTimeInMs", 0) |
| ) |
| |
| action_summary = buildMetrics.get("actionSummary", {}) |
| bazel_metrics.total_actions = int(action_summary.get("actionsExecuted", 0)) |
| |
| for runner in action_summary.get("runnerCount", []): |
| name = runner.get("name", "").lower() |
| if "remote cache hit" in name or "disk cache hit" in name: |
| bazel_metrics.remote_and_disk_cache_hits += int(runner.get("count", 0)) |
| |
| artifacts = buildMetrics.get("artifactMetrics", {}) |
| bazel_metrics.output_size_bytes = int( |
| artifacts.get("topLevelArtifacts", {}).get("sizeInBytes", 0) |
| ) |
| if bazel_metrics.output_size_bytes == 0: |
| bazel_metrics.output_size_bytes = int( |
| artifacts.get("outputArtifactsSeen", {}).get("sizeInBytes", 0) |
| ) |
| |
| # Network |
| net = buildMetrics.get("networkMetrics", {}).get("systemNetworkStats", {}) |
| bazel_metrics.bytes_downloaded = int(net.get("bytesRecv", 0)) |
| |
| # --- 3. Build Tool Logs --- |
| elif "buildToolLogs" in event: |
| logs = event["buildToolLogs"].get("log", []) |
| bazel_metrics.critical_path_s = extract_critical_path(logs) |
| |
| # --- Build Finished (Exit Code) --- |
| if "buildFinished" in event_id: |
| exit_data = event.get("finished").get("exitCode", {}) |
| bazel_metrics.exit_code = int(exit_data.get("code", 0)) |
| |
| # --- 4. Post-Process Nested Targets --- |
| for label, shards in target_map.items(): |
| bazel_metrics.targets.append( |
| TestTarget( |
| label=label, |
| status=target_status.get(label, "UNKNOWN"), |
| duration_s=max(shards) if shards else 0.0, |
| shard_count=len(shards), |
| shard_durations=shards, |
| ) |
| ) |
| |
| return bazel_metrics |
| |
| |
| def publish_to_bigquery(row): |
| """ |
| Pushes a single row to BigQuery using the 'bq' CLI tool via subprocess. |
| """ |
| |
| bazelci.eprint(f"Publishing Metrics to BigQuery ...") |
| table_ref = f"{PROJECT_ID}:{DATASET_ID}.{TABLE_ID}" |
| if bazelci.is_windows(): |
| bq_cmd = "bq.cmd" |
| elif bazelci.is_mac() and not bazelci.is_64_bit(): |
| bq_cmd = "/usr/local/bin/google-cloud-sdk/bin/bq" |
| else: |
| bq_cmd = "bq" |
| |
| try: |
| with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tf: |
| json.dump(row, tf) |
| tf.write("\n") |
| temp_path = tf.name |
| |
| result = subprocess.run( |
| [bq_cmd, "insert", table_ref, temp_path], |
| capture_output=True, |
| text=True, |
| check=False |
| ) |
| |
| if result.returncode != 0: |
| print_and_annotate_warning(f"BigQuery CLI Insert Error:\nSTDOUT: {result.stdout}\nSTDERR: {result.stderr}") |
| return |
| |
| bazelci.eprint("Success: Metrics pushed to BigQuery via CLI.") |
| |
| except Exception as e: |
| print_and_annotate_warning(f"Failed to execute bq CLI: {e}") |
| finally: |
| if 'temp_path' in locals() and os.path.exists(temp_path): |
| os.remove(temp_path) |
| |
| def normalize_job_label(label): |
| """ |
| Normalizes a Buildkite job label by stripping the sharding suffix and emoji shortcodes. |
| Example: "Clang on :ubuntu: Ubuntu 20.04 LTS (shard 1)" -> "Clang on Ubuntu 20.04 LTS" |
| """ |
| if not label: |
| return None |
| # 1. Strip " (shard \d+)" at the end of the string |
| label = re.sub(r'\s*\(shard \d+\)$', '', label) |
| # 2. Strip emoji shortcodes like :ubuntu: or :rocky: |
| label = re.sub(r':[a-zA-Z0-9_+-]+:', '', label) |
| # 3. Collapse multiple spaces into a single space and strip |
| return re.sub(r'\s+', ' ', label).strip() |
| |
| def collect_metrics_and_push_to_bigquery(build_bep_path=None, test_bep_path=None, task_id=None): |
| """ |
| Reads the BEP files, collects environment variables, and pushes metrics to BigQuery. |
| Called from bazelci.py after the build finishes. |
| """ |
| |
| bazelci.eprint(f"Collecting CI Metrics ...") |
| |
| # --- Configuration (Read Env Vars inside function) --- |
| BUILDKITE_BUILD_ID = os.getenv("BUILDKITE_BUILD_ID") |
| BUILDKITE_BUILD_NUMBER = int(os.getenv("BUILDKITE_BUILD_NUMBER")) |
| BUILDKITE_JOB_ID = os.getenv("BUILDKITE_JOB_ID") |
| BUILDKITE_LABEL = os.getenv("BUILDKITE_LABEL") |
| PIPELINE = os.getenv("BUILDKITE_PIPELINE_SLUG") |
| ORG = os.getenv("BUILDKITE_ORGANIZATION_SLUG") |
| REPO = os.getenv("BUILDKITE_REPO") |
| queue = os.getenv("BUILDKITE_AGENT_META_DATA_QUEUE", "default") |
| PLATFORM = {"default": "linux", "arm64": "linux_arm64"}.get(queue, queue) |
| TASK_LABEL = normalize_job_label(BUILDKITE_LABEL) |
| BUILD_SHARD_ID = int(os.getenv("BUILDKITE_PARALLEL_JOB", "0")) |
| BUILD_SHARD_COUNT = int(os.getenv("BUILDKITE_PARALLEL_JOB_COUNT", "1")) |
| AGENT_ID = os.getenv("BUILDKITE_AGENT_ID") |
| BRANCH = os.getenv("BUILDKITE_BRANCH", "main") |
| COMMIT_SHA = os.getenv("BUILDKITE_COMMIT") |
| RETRY_COUNT = int(os.getenv("BUILDKITE_RETRY_COUNT", "0")) |
| |
| # Injected via webhooks |
| try: |
| CHECKOUT_DURATION_S = float(os.getenv("CHECKOUT_DURATION_S")) |
| except (ValueError, TypeError): |
| CHECKOUT_DURATION_S = None |
| try: |
| PREP_DURATION_S = float(os.getenv("PREP_DURATION_S")) |
| except (ValueError, TypeError): |
| PREP_DURATION_S = None |
| |
| # Calculate Changed Files |
| CHANGED_FILES_COUNT = get_git_stats() |
| |
| # Parse BEP Data |
| build_metrics = None |
| if build_bep_path: |
| build_metrics = parse_bep(build_bep_path) |
| |
| test_metrics = None |
| if test_bep_path: |
| test_metrics = parse_bep(test_bep_path) |
| |
| if not build_metrics and not test_metrics: |
| print_and_annotate_warning("Skipping BigQuery push due to missing or failed BEP parsing.") |
| return |
| |
| # Get Timestamps & calculate Queue time |
| timestamps = fetch_job_timestamps( |
| ORG, PIPELINE, BUILDKITE_BUILD_NUMBER, BUILDKITE_JOB_ID |
| ) |
| queue_duration = 0.0 |
| if timestamps.created_at and timestamps.started_at: |
| try: |
| created_dt = datetime.datetime.fromisoformat(timestamps.created_at.replace("Z", "+00:00")) |
| started_dt = datetime.datetime.fromisoformat(timestamps.started_at.replace("Z", "+00:00")) |
| queue_duration = (started_dt - created_dt).total_seconds() |
| except Exception as e: |
| bazelci.eprint(f"Warning: Could not parse timestamps: {e}") |
| |
| # Construct BigQuery Row |
| row = { |
| "build_id": BUILDKITE_BUILD_ID, |
| "build_number": BUILDKITE_BUILD_NUMBER, |
| "job_id": BUILDKITE_JOB_ID, |
| "job_label": BUILDKITE_LABEL, |
| "finished_at": timestamps.finished_at, |
| "created_at": timestamps.created_at, |
| "started_at": timestamps.started_at, |
| "pipeline": PIPELINE, |
| "org": ORG, |
| "platform": PLATFORM, |
| "task_label": TASK_LABEL, |
| "build_shard_id": BUILD_SHARD_ID, |
| "build_shard_count": BUILD_SHARD_COUNT, |
| "agent_id": AGENT_ID, |
| "branch": BRANCH, |
| "repo": REPO, |
| "commit_sha": COMMIT_SHA, |
| "retry_count": RETRY_COUNT, |
| "queue_duration_s": queue_duration, |
| "checkout_duration_s": CHECKOUT_DURATION_S, |
| "prep_duration_s": PREP_DURATION_S, |
| "changed_files_count": CHANGED_FILES_COUNT, |
| } |
| |
| if build_metrics: |
| row["build"] = build_metrics.to_dict(is_test=False) |
| |
| if test_metrics: |
| row["test"] = test_metrics.to_dict(is_test=True) |
| |
| publish_to_bigquery(row) |