blob: c98aff2b6e0f22ae1e92606dc35fe02b954abe88 [file] [edit]
#!/usr/bin/env python3
# Copyright 2026 The PDFium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Finds performance step functions (regressions or improvements) in CI."""
import argparse
import collections
import concurrent.futures
import datetime
import json
import os
import statistics
import subprocess
import sys
import tempfile
def ParseISO8601(date_str):
"""Parses an ISO-8601 date or date-time string into a UTC datetime."""
norm_str = date_str[:-1] + '+00:00' if date_str.endswith('Z') else date_str
dt = datetime.datetime.fromisoformat(norm_str)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=datetime.timezone.utc)
else:
dt = dt.astimezone(datetime.timezone.utc)
return dt
def GetCIBuilders():
"""Returns a list of all CI builders for the pdfium project."""
cmd = ['bb', 'builders', 'pdfium/ci']
proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
if proc.returncode != 0:
err = proc.stderr.strip()
if err:
print(f'Warning: "bb builders" failed: {err}', file=sys.stderr)
return []
builders = []
for line in proc.stdout.splitlines():
line = line.strip()
if line.startswith('pdfium/ci/'):
builders.append(line.replace('pdfium/ci/', ''))
return sorted(builders)
def GetBuildsForBuilder(builder, since_dt, until_dt):
"""Fetches successful builds for `builder` within the date range."""
predicate = json.dumps({
'builder': {
'project': 'pdfium',
'bucket': 'ci',
'builder': builder,
},
'status': 'SUCCESS',
'createTime': {
'startTime': since_dt.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
'endTime': until_dt.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
},
})
cmd = ['bb', 'ls', '-predicate', predicate, '-json']
builds = []
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
with proc:
for line in proc.stdout:
line = line.strip()
if not line:
continue
try:
d = json.loads(line)
create_time = d.get('createTime') or d.get('startTime')
if not create_time:
continue
ct = ParseISO8601(create_time)
if ct > until_dt or ct < since_dt:
continue
build_id = d.get('id')
build_num = d.get('number')
commit = (d.get('input') or {}).get('gitilesCommit', {}).get('id', '')
if not commit:
for t in d.get('tags') or []:
val = t.get('value') or ''
if t.get('key') == 'buildset' and 'commit/gitiles' in val:
commit = val.split('/')[-1]
break
if build_id:
builds.append({
'id': build_id,
'number': build_num,
'commit': commit,
'create_time': create_time,
})
except (json.JSONDecodeError, ValueError):
pass
proc.wait()
if proc.returncode != 0:
err = proc.stderr.read().strip()
if err:
print(f'Warning: "bb ls" failed for {builder}: {err}', file=sys.stderr)
# Buildbucket returns newest first; reverse so order is chronological.
builds.reverse()
return builds
def QueryResultDBForBuilds(build_ids, batch_size=25):
"""Queries ResultDB in batches and yields (build_id, test_result) tuples."""
for i in range(0, len(build_ids), batch_size):
batch = build_ids[i:i + batch_size]
inv_args = [f'build-{bid}' for bid in batch]
cmd = ['rdb', 'query', '-json', '-tr-fields', 'testId,duration,variant'
] + inv_args
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
with proc:
for line in proc.stdout:
line = line.strip()
if not line:
continue
try:
data = json.loads(line)
tr = data.get('testResult')
inv_id = (data.get('invocationId') or '').replace('build-', '')
if tr and inv_id:
yield inv_id, tr
except json.JSONDecodeError:
pass
proc.wait()
if proc.returncode != 0:
err = proc.stderr.read().strip()
if err:
print(f'Warning: "rdb query" failed: {err}', file=sys.stderr)
SUITE_ALIASES = {
'unittest': 'pdfium_unittests',
'unittests': 'pdfium_unittests',
'embedder': 'pdfium_embeddertests',
'embeddertest': 'pdfium_embeddertests',
'embeddertests': 'pdfium_embeddertests',
}
def MatchesSuiteFilter(suite, suite_filters, exact=False):
"""Returns True if `suite` matches any filter in `suite_filters`."""
if not suite_filters:
return True
suite_lower = suite.lower()
if exact:
return suite_lower in (
SUITE_ALIASES.get(sf.lower(), sf.lower()) for sf in suite_filters)
# Strip '_javascript_disabled' when matching general filters like 'javascript'
# so that 'corpus_javascript_disabled' and 'pixel_javascript_disabled' are not
# mistakenly matched by '--javascript'.
base_suite = suite_lower.replace('_javascript_disabled', '')
for sf in suite_filters:
if 'javascript_disabled' in sf:
if sf in suite_lower:
return True
elif sf in base_suite:
return True
return False
def ExtractTestData(build_ids,
suite_filters=None,
exact_suite=False,
use_cache=True,
verbose=False):
"""Collects suite sums, test counts, and test durations across `build_ids`."""
test_timings = collections.defaultdict(lambda: collections.defaultdict(dict))
suite_totals = collections.defaultdict(lambda: collections.defaultdict(float))
suite_counts = collections.defaultdict(lambda: collections.defaultdict(int))
cache_dir = os.path.join(tempfile.gettempdir(), 'pdfium_rdb_cache')
if use_cache:
os.makedirs(cache_dir, exist_ok=True)
needed_build_ids = []
for bid in build_ids:
cache_path = os.path.join(cache_dir, f'{bid}.json') if use_cache else None
if cache_path and os.path.isfile(cache_path):
try:
with open(cache_path, 'r', encoding='utf-8') as f:
build_data = json.load(f)
for suite, tests in build_data.items():
if suite_filters and not MatchesSuiteFilter(
suite, suite_filters, exact=exact_suite):
continue
for test_id, dur in tests.items():
test_timings[bid][suite][test_id] = dur
suite_totals[bid][suite] += dur
suite_counts[bid][suite] += 1
continue
except (json.JSONDecodeError, OSError):
pass
needed_build_ids.append(bid)
if needed_build_ids:
if verbose:
print(
f'Querying ResultDB for {len(needed_build_ids)} builds...',
file=sys.stderr,
flush=True)
new_raw = collections.defaultdict(
lambda: collections.defaultdict(lambda: collections.defaultdict(list)))
for bid, tr in QueryResultDBForBuilds(needed_build_ids):
variant = (tr.get('variant') or {}).get('def') or {}
suite = variant.get('test_suite') or 'unknown'
test_id = tr.get('testId') or 'unknown'
dur_str = tr.get('duration') or '0s'
try:
dur = float(dur_str.rstrip('s')) if dur_str.endswith('s') else 0.0
except ValueError:
dur = 0.0
new_raw[bid][suite][test_id].append(dur)
for bid in needed_build_ids:
suites = new_raw[bid]
build_cache_data = {}
for suite, tests in suites.items():
build_cache_data[suite] = {}
include_suite = not suite_filters or MatchesSuiteFilter(
suite, suite_filters, exact=exact_suite)
for test_id, durs in tests.items():
mean_dur = durs[0] if len(durs) == 1 else (sum(durs) / len(durs))
build_cache_data[suite][test_id] = mean_dur
if include_suite:
test_timings[bid][suite][test_id] = mean_dur
suite_totals[bid][suite] += mean_dur
suite_counts[bid][suite] += 1
if use_cache and build_cache_data:
cache_path = os.path.join(cache_dir, f'{bid}.json')
try:
with open(cache_path, 'w', encoding='utf-8') as f:
json.dump(build_cache_data, f)
except OSError:
pass
return suite_totals, suite_counts, test_timings
def DetectSuiteSteps(runs,
suite_totals,
suite_counts=None,
threshold_pct=15.0,
min_delta=2.0,
persistence_runs=2,
suite_filters=None,
exact_suite=False):
"""Detects persistent step functions in suite durations.
A step occurs at index `i` if the median of `runs[i : i + 1 +
persistence_runs]` differs from the pre-change baseline (median of
`runs[i - 3 : i]`) by at least `threshold_pct` and `min_delta` seconds,
and every run in the post window maintains that shift.
"""
all_suites = sorted(
set().union(*(suite_totals[r['id']].keys() for r in runs)))
if suite_filters:
all_suites = [
s for s in all_suites
if MatchesSuiteFilter(s, suite_filters, exact=exact_suite)
]
post_window_size = persistence_runs + 1
step_events = []
for suite in all_suites:
# Filter to runs where this suite had test results.
suite_runs = [r for r in runs if suite in suite_totals[r['id']]]
if len(suite_runs) < 3 + post_window_size:
continue
durations = [suite_totals[r['id']][suite] for r in suite_runs]
counts = [
suite_counts[r['id']][suite] if suite_counts else 0 for r in suite_runs
]
idx = 3
limit = len(suite_runs) - post_window_size
while idx <= limit:
pre_window = durations[idx - 3:idx]
post_window = durations[idx:idx + post_window_size]
m_pre = statistics.median(pre_window)
m_post = statistics.median(post_window)
delta = m_post - m_pre
pct = (delta / m_pre * 100.0) if m_pre > 0.0 else 0.0
if abs(pct) >= threshold_pct and abs(delta) >= min_delta:
c_pre = statistics.median(counts[idx - 3:idx])
c_post = statistics.median(counts[idx:idx + post_window_size])
if delta > 0:
# Regression: all post-runs must remain elevated.
min_allowed = m_pre * (1.0 + (threshold_pct / 200.0))
if all(d >= min_allowed for d in post_window):
step_events.append({
'type': 'REGRESSION',
'suite': suite,
'run_index': idx,
'pre_runs': suite_runs[idx - 3:idx],
'trigger_run': suite_runs[idx],
'prev_run': suite_runs[idx - 1],
'future_runs': suite_runs[idx + 1:idx + post_window_size],
'm_pre': m_pre,
'm_post': m_post,
'delta': delta,
'pct': pct,
'count_pre': int(c_pre),
'count_post': int(c_post),
})
idx += post_window_size
continue
else:
# Improvement: all post-runs must remain lower.
max_allowed = m_pre * (1.0 - (threshold_pct / 200.0))
if all(d <= max_allowed for d in post_window):
step_events.append({
'type': 'IMPROVEMENT',
'suite': suite,
'run_index': idx,
'pre_runs': suite_runs[idx - 3:idx],
'trigger_run': suite_runs[idx],
'prev_run': suite_runs[idx - 1],
'future_runs': suite_runs[idx + 1:idx + post_window_size],
'm_pre': m_pre,
'm_post': m_post,
'delta': delta,
'pct': pct,
'count_pre': int(c_pre),
'count_post': int(c_post),
})
idx += post_window_size
continue
idx += 1
return step_events
def FindCulpritTests(step_event,
test_timings,
top_n=5,
test_threshold_pct=30.0,
test_min_delta=0.2):
"""Finds individual tests that persistently shifted across the step."""
suite = step_event['suite']
pre_runs = step_event.get('pre_runs', [step_event['prev_run']])
pre_ids = [r['id'] for r in pre_runs]
trigger_id = step_event['trigger_run']['id']
future_ids = [r['id'] for r in step_event['future_runs']]
post_ids = [trigger_id] + future_ids
all_test_ids = set()
for bid in pre_ids + post_ids:
all_test_ids.update(test_timings[bid].get(suite, {}).keys())
candidates = []
is_regression = step_event['type'] == 'REGRESSION'
for test_id in all_test_ids:
pre_durs = [
test_timings[bid][suite][test_id]
for bid in pre_ids
if test_id in test_timings[bid].get(suite, {})
]
post_durs = [
test_timings[bid][suite][test_id]
for bid in post_ids
if test_id in test_timings[bid].get(suite, {})
]
# Test must run at least once in each epoch to compare durations.
if not pre_durs or not post_durs:
continue
t_pre = statistics.median(pre_durs)
m_test_post = statistics.median(post_durs)
t_delta = m_test_post - t_pre
t_pct = (t_delta / t_pre * 100.0) if t_pre > 0.0 else 0.0
if abs(t_pct) >= test_threshold_pct and abs(t_delta) >= test_min_delta:
if is_regression and t_delta > 0 and all(d >= t_pre for d in post_durs):
candidates.append({
'test_id': test_id,
'pre_dur': t_pre,
'post_durs': post_durs,
'm_post': m_test_post,
'delta': t_delta,
'pct': t_pct,
})
elif (not is_regression and t_delta < 0 and
all(d <= t_pre for d in post_durs)):
candidates.append({
'test_id': test_id,
'pre_dur': t_pre,
'post_durs': post_durs,
'm_post': m_test_post,
'delta': t_delta,
'pct': t_pct,
})
# Rank by absolute delta descending.
candidates.sort(key=lambda x: abs(x['delta']), reverse=True)
total_candidate_delta = sum(c['delta'] for c in candidates)
return {
'candidates': candidates[:top_n],
'total_count': len(candidates),
'total_delta': total_candidate_delta,
}
def AnalyzeBuilder(builder,
since_dt,
until_dt,
threshold_pct,
min_delta,
persistence_runs,
top_n_culprits,
test_threshold_pct=30.0,
test_min_delta=0.2,
suite_filters=None,
exact_suite=False,
use_cache=True,
verbose=False):
"""Fetches and evaluates performance step functions for `builder`."""
if verbose:
print(
f'Fetching builds for {builder} between {since_dt.isoformat()} '
f'and {until_dt.isoformat()}...',
file=sys.stderr,
flush=True)
builds = GetBuildsForBuilder(builder, since_dt, until_dt)
if len(builds) < 3 + 1 + persistence_runs:
if verbose:
print(
f'Insufficient builds found for {builder} ({len(builds)} builds).',
file=sys.stderr)
return []
build_ids = [b['id'] for b in builds]
suite_totals, suite_counts, test_timings = ExtractTestData(
build_ids,
suite_filters=suite_filters,
exact_suite=exact_suite,
use_cache=use_cache,
verbose=verbose)
step_events = DetectSuiteSteps(
runs=builds,
suite_totals=suite_totals,
suite_counts=suite_counts,
threshold_pct=threshold_pct,
min_delta=min_delta,
persistence_runs=persistence_runs,
suite_filters=suite_filters,
exact_suite=exact_suite)
results = []
for step in step_events:
culprit_info = FindCulpritTests(
step_event=step,
test_timings=test_timings,
top_n=top_n_culprits,
test_threshold_pct=test_threshold_pct,
test_min_delta=test_min_delta)
results.append({
'builder': builder,
'step': step,
'culprits': culprit_info['candidates'],
'culprit_total_count': culprit_info['total_count'],
'culprit_total_delta': culprit_info['total_delta'],
})
return results
def GetCommitRangeUrl(prev_commit, trigger_commit):
"""Returns a Gitiles log URL between `prev_commit` and `trigger_commit`."""
if prev_commit and trigger_commit and prev_commit != trigger_commit:
return (f'https://pdfium.googlesource.com/pdfium/+log/'
f'{prev_commit[:10]}..{trigger_commit[:10]}')
return ''
def FormatEventText(event):
"""Formats a single step function event for text display."""
builder = event['builder']
step = event['step']
culprits = event['culprits']
culprit_total_count = event.get('culprit_total_count', len(culprits))
culprit_total_delta = event.get('culprit_total_delta',
sum(c['delta'] for c in culprits))
trigger = step['trigger_run']
prev = step['prev_run']
future = step['future_runs']
stype = step['type']
sign = '+' if step['delta'] > 0 else ''
trigger_commit = trigger.get('commit') or ''
prev_commit = prev.get('commit') or ''
range_url = GetCommitRangeUrl(prev_commit, trigger_commit)
range_line = f' Commit Range: {range_url}\n' if range_url else ''
test_count_line = ''
c_pre = step.get('count_pre', 0)
c_post = step.get('count_post', 0)
if c_pre or c_post:
c_delta = c_post - c_pre
csign = '+' if c_delta > 0 else ''
test_count_line = f' Tests Run: {c_pre} -> {c_post} ({csign}{c_delta} tests)\n'
header = (f'=== [{stype}] builder: {builder} | suite: {step["suite"]} ===\n'
f' Trigger Build: #{trigger.get("number") or "unknown"} '
f'(commit: {(trigger_commit or "unknown")[:10]})\n'
f' Pre Build: #{prev.get("number") or "unknown"} '
f'(commit: {(prev_commit or "unknown")[:10]})\n'
f'{range_line}'
f' Date: {trigger.get("create_time") or "unknown"}\n'
f'{test_count_line}'
f' Suite Shift: {step["m_pre"]:.2f}s -> {step["m_post"]:.2f}s '
f'({sign}{step["pct"]:.1f}%, {sign}{step["delta"]:.2f}s)\n'
f' Persisted in: ' +
', '.join(f'#{r.get("number") or "?"}' for r in future))
lines = [header]
if culprits:
csign = '+' if culprit_total_delta > 0 else ''
lines.append(
f' Culprit Tests (showing top {len(culprits)} of {culprit_total_count}, '
f'accounting for {csign}{culprit_total_delta:.2f}s of {sign}{step["delta"]:.2f}s shift):'
)
for c in culprits:
c_item_sign = '+' if c['delta'] > 0 else ''
post_str = ', '.join(f'{d:.2f}s' for d in c['post_durs'])
lines.append(
f' - {c["test_id"]}\n'
f' duration: {c["pre_dur"]:.2f}s -> ({post_str}) '
f'[{c_item_sign}{c["pct"]:.1f}%, {c_item_sign}{c["delta"]:.2f}s]')
else:
lines.append(
' Culprit Tests: None met threshold (diffuse across entire suite)')
return '\n'.join(lines)
def main():
parser = argparse.ArgumentParser(
description='Find performance step functions across CI builders.')
parser.add_argument(
'--since',
help='Start date/time in ISO-8601 format (e.g. "2026-09-14" or '
'"2026-09-14T00:00:00Z"). Defaults to 7 days ago.')
parser.add_argument(
'--until',
help='End date/time in ISO-8601 format (e.g. "2026-09-21" or '
'"2026-09-21T23:59:59Z"). Defaults to current time.')
parser.add_argument(
'--builder',
'-b',
help='Specific CI builder to inspect (e.g. "linux", or comma-separated '
'list). Defaults to "linux" if omitted and --all-builders is not set.')
parser.add_argument(
'--all-builders',
action='store_true',
help='Inspect all active CI builders in pdfium/ci.')
parser.add_argument(
'--threshold',
type=float,
default=15.0,
help='Relative threshold percentage to flag a step function '
'(default: 15.0)')
parser.add_argument(
'--min-delta',
type=float,
default=2.0,
help='Minimum absolute change in seconds to flag a step function '
'(default: 2.0)')
parser.add_argument(
'--persistence',
type=int,
default=2,
help='Number of future runs required to confirm persistence (default: 2)')
parser.add_argument(
'--top-culprits',
type=int,
default=5,
help='Number of individual culprit tests to report per step (default: 5)')
parser.add_argument(
'--test-threshold',
type=float,
default=30.0,
help='Relative percentage threshold for individual culprit tests '
'(default: 30.0)')
parser.add_argument(
'--test-min-delta',
type=float,
default=0.2,
help='Minimum absolute change in seconds for individual culprit tests '
'(default: 0.2)')
parser.add_argument(
'--suite',
'-s',
help='Filter to specific test suite(s), comma-separated or substring '
'(e.g. "unittests", "embeddertests", "javascript", "corpus", "pixel")')
parser.add_argument(
'--exact-suite',
action='store_true',
help='Require exact suite name match instead of substring matching')
parser.add_argument(
'--unittest',
'--unittests',
action='store_true',
help='Shortcut to filter for unit tests')
parser.add_argument(
'--corpus',
action='store_true',
help='Shortcut to filter for corpus tests')
parser.add_argument(
'--pixel', action='store_true', help='Shortcut to filter for pixel tests')
parser.add_argument(
'--embeddertest',
'--embeddertests',
action='store_true',
help='Shortcut to filter for embedder tests')
parser.add_argument(
'--javascript',
action='store_true',
help='Shortcut to filter for javascript tests')
parser.add_argument(
'--concurrency',
'-j',
type=int,
default=4,
help='Number of concurrent builders to process in parallel (default: 4)')
parser.add_argument(
'--no-cache',
action='store_true',
help='Disable local disk caching of ResultDB build queries')
parser.add_argument(
'--json', action='store_true', help='Output results as JSON')
parser.add_argument(
'--verbose',
'-v',
action='store_true',
help='Print verbose progress messages')
args = parser.parse_args()
if args.persistence < 1:
print('Error: --persistence must be at least 1.', file=sys.stderr)
return 1
if args.threshold <= 0.0:
print('Error: --threshold must be positive.', file=sys.stderr)
return 1
if args.min_delta < 0.0:
print('Error: --min-delta must be non-negative.', file=sys.stderr)
return 1
if args.test_threshold <= 0.0:
print('Error: --test-threshold must be positive.', file=sys.stderr)
return 1
if args.test_min_delta < 0.0:
print('Error: --test-min-delta must be non-negative.', file=sys.stderr)
return 1
now = datetime.datetime.now(datetime.timezone.utc)
if args.until:
try:
until_dt = ParseISO8601(args.until)
if len(args.until) <= 10:
until_dt = until_dt.replace(
hour=23, minute=59, second=59, microsecond=999999)
except ValueError as e:
print(f'Error: Invalid ISO-8601 format for --until: {e}', file=sys.stderr)
return 1
else:
until_dt = now
if args.since:
try:
since_dt = ParseISO8601(args.since)
except ValueError as e:
print(f'Error: Invalid ISO-8601 format for --since: {e}', file=sys.stderr)
return 1
else:
since_dt = now - datetime.timedelta(days=7)
if since_dt >= until_dt:
print(
'Error: --since date must be earlier than --until date.',
file=sys.stderr)
return 1
suite_filters = []
if args.suite:
suite_filters.extend(
[s.strip().lower() for s in args.suite.split(',') if s.strip()])
if args.unittest:
suite_filters.append('unittests')
if args.corpus:
suite_filters.append('corpus')
if args.pixel:
suite_filters.append('pixel')
if args.embeddertest:
suite_filters.append('embeddertests')
if args.javascript:
suite_filters.append('javascript')
if args.all_builders:
builders = GetCIBuilders()
elif args.builder:
builders = [b.strip() for b in args.builder.split(',') if b.strip()]
else:
builders = ['linux']
if not builders:
print('Error: No builders specified or found.', file=sys.stderr)
return 1
if not args.json:
print(f'Analyzing builds from {since_dt.isoformat()} to '
f'{until_dt.isoformat()} for builders: {", ".join(builders)}')
if suite_filters:
print(f'Suite filters: {", ".join(suite_filters)}')
print(f'Threshold: {args.threshold}%, Min Delta: {args.min_delta}s, '
f'Persistence: {args.persistence} future runs\n')
all_events = []
if len(builders) > 1 and args.concurrency > 1:
with concurrent.futures.ThreadPoolExecutor(
max_workers=min(args.concurrency, len(builders))) as executor:
futures = {
executor.submit(
AnalyzeBuilder,
builder=b,
since_dt=since_dt,
until_dt=until_dt,
threshold_pct=args.threshold,
min_delta=args.min_delta,
persistence_runs=args.persistence,
top_n_culprits=args.top_culprits,
test_threshold_pct=args.test_threshold,
test_min_delta=args.test_min_delta,
suite_filters=suite_filters,
exact_suite=args.exact_suite,
use_cache=not args.no_cache,
verbose=args.verbose):
b for b in builders
}
for future in concurrent.futures.as_completed(futures):
b = futures[future]
try:
events = future.result()
all_events.extend(events)
except Exception as e:
print(f'Error analyzing builder {b}: {e}', file=sys.stderr)
else:
for b in builders:
try:
events = AnalyzeBuilder(
builder=b,
since_dt=since_dt,
until_dt=until_dt,
threshold_pct=args.threshold,
min_delta=args.min_delta,
persistence_runs=args.persistence,
top_n_culprits=args.top_culprits,
test_threshold_pct=args.test_threshold,
test_min_delta=args.test_min_delta,
suite_filters=suite_filters,
exact_suite=args.exact_suite,
use_cache=not args.no_cache,
verbose=args.verbose)
all_events.extend(events)
except Exception as e:
print(f'Error analyzing builder {b}: {e}', file=sys.stderr)
# Sort events deterministically by builder and trigger time.
all_events.sort(key=lambda e: (e['builder'], e['step']['trigger_run'].get(
'create_time') or ''))
if args.json:
json_output = []
for event in all_events:
step = event['step']
c_pre = step.get('count_pre')
c_post = step.get('count_post')
c_delta = (c_post -
c_pre) if (c_pre is not None and c_post is not None) else None
json_output.append({
'builder':
event['builder'],
'type':
step['type'],
'suite':
step['suite'],
'trigger_build_number':
step['trigger_run'].get('number'),
'trigger_build_id':
step['trigger_run'].get('id'),
'trigger_commit':
step['trigger_run'].get('commit'),
'trigger_time':
step['trigger_run'].get('create_time'),
'pre_build_number':
step['prev_run'].get('number'),
'pre_commit':
step['prev_run'].get('commit'),
'commit_range_url':
GetCommitRangeUrl(step['prev_run'].get('commit') or '',
step['trigger_run'].get('commit') or ''),
'test_count_pre':
c_pre,
'test_count_post':
c_post,
'test_count_delta':
c_delta,
'median_pre_seconds':
step['m_pre'],
'median_post_seconds':
step['m_post'],
'delta_seconds':
step['delta'],
'percent_change':
step['pct'],
'future_build_numbers': [
r.get('number') for r in step['future_runs']
],
'culprits':
event['culprits'],
'culprit_total_count':
event.get('culprit_total_count'),
'culprit_total_delta':
event.get('culprit_total_delta'),
})
print(json.dumps(json_output, indent=2))
return 0
if not all_events:
print('No persistent performance step functions detected.')
return 0
for event in all_events:
print(FormatEventText(event))
print()
print(f'Total step events found: {len(all_events)}')
return 0
if __name__ == '__main__':
sys.exit(main())