blob: f6f3fe66305ae39485eec7ac56773c6102ad5914 [file]
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)