diff mbox series

[1/1] build_perf: add commit annotations

Message ID 20260128125745.58440-2-albaherreriasdev@gmail.com
State Under Review
Headers show
Series build_perf: add commit annotations | expand

Commit Message

Alba Herrerías Jan. 28, 2026, 12:57 p.m. UTC
From: Alex Feyerke <alex@neighbourhood.ie>

Also: refactoring and simplification of html rendering, esp. wrt. charts.
Signed-off-by: Alex Feyerke <alex@neighbourhood.ie>
---
 .../build_perf/html/measurement_chart.html    | 168 -------------
 scripts/lib/build_perf/html/report.html       | 222 +++++++++++++++---
 scripts/oe-build-perf-report                  | 153 +++++++++++-
 3 files changed, 339 insertions(+), 204 deletions(-)
 delete mode 100644 scripts/lib/build_perf/html/measurement_chart.html
diff mbox series

Patch

diff --git a/scripts/lib/build_perf/html/measurement_chart.html b/scripts/lib/build_perf/html/measurement_chart.html
deleted file mode 100644
index 86435273cf..0000000000
--- a/scripts/lib/build_perf/html/measurement_chart.html
+++ /dev/null
@@ -1,168 +0,0 @@ 
-<script type="module">
-  // Get raw data
-  const rawData = [
-    {% for sample in measurement.samples %}
-      [{{ sample.commit_num }}, {{ sample.mean.gv_value() }}, {{ sample.start_time }}, '{{sample.commit}}'],
-    {% endfor %}
-  ];
-
-  const convertToMinute = (time) => {
-    return time[0]*60 + time[1] + time[2]/60 + time[3]/3600;
-  }
-
-  // Update value format to either minutes or leave as size value
-  const updateValue = (value) => {
-    // Assuming the array values are duration in the format [hours, minutes, seconds, milliseconds]
-    return Array.isArray(value) ? convertToMinute(value) : value
-  }
-
-  // Convert raw data to the format: [time, value]
-  const data = rawData.map(([commit, value, time]) => {
-    return [
-      // The Date object takes values in milliseconds rather than seconds. So to use a Unix timestamp we have to multiply it by 1000.
-      new Date(time * 1000).getTime(),
-      // Assuming the array values are duration in the format [hours, minutes, seconds, milliseconds]
-      updateValue(value)
-    ]
-  });
-
-  const commitCountList = rawData.map(([commit, value, time]) => {
-    return commit
-  });
-
-  const commitCountData = rawData.map(([commit, value, time]) => {
-    return updateValue(value)
-  });
-
-  // Set chart options
-  const option_start_time = {
-    tooltip: {
-      trigger: 'axis',
-      enterable: true,
-      position: function (point, params, dom, rect, size) {
-        return [point[0], '0%'];
-      },
-      formatter: function (param) {
-        const value = param[0].value[1]
-        const sample  = rawData.filter(([commit, dataValue]) => updateValue(dataValue) === value)
-        const formattedDate = new Date(sample[0][2] * 1000).toString().replace(/GMT[+-]\d{4}/, '').replace(/\(.*\)/, '(CEST)');
-
-        // Add commit hash to the tooltip as a link
-        const commitLink = `https://git.yoctoproject.org/poky/commit/?id=${sample[0][3]}`
-        if ('{{ measurement.value_type.quantity }}' == 'time') {
-          const hours = Math.floor(value/60)
-          const minutes = Math.floor(value % 60)
-          const seconds = Math.floor((value * 60) % 60)
-          return `<strong>Duration:</strong> ${hours}:${minutes}:${seconds}, <strong>Commit number:</strong> <a href="${commitLink}" target="_blank" rel="noreferrer noopener">${sample[0][0]}</a>, <br/> <strong>Start time:</strong> ${formattedDate}`
-        }
-        return `<strong>Size:</strong> ${value.toFixed(2)} MB, <strong>Commit number:</strong> <a href="${commitLink}" target="_blank" rel="noreferrer noopener">${sample[0][0]}</a>, <br/> <strong>Start time:</strong> ${formattedDate}`
-      ;}
-    },
-    xAxis: {
-      type: 'time',
-    },
-    yAxis: {
-      name: '{{ measurement.value_type.quantity }}' == 'time' ? 'Duration in minutes' : 'Disk size in MB',
-      type: 'value',
-      min: function(value) {
-        return Math.round(value.min - 0.5);
-      },
-      max: function(value) {
-        return Math.round(value.max + 0.5);
-      }
-    },
-    dataZoom: [
-      {
-        type: 'slider',
-        xAxisIndex: 0,
-        filterMode: 'none'
-      },
-    ],
-    series: [
-      {
-        name: '{{ measurement.value_type.quantity }}',
-        type: 'line',
-        symbol: 'none',
-        data: data
-      }
-    ]
-  };
-
-  const option_commit_count = {
-    tooltip: {
-      trigger: 'axis',
-      enterable: true,
-      position: function (point, params, dom, rect, size) {
-        return [point[0], '0%'];
-      },
-      formatter: function (param) {
-        const value = param[0].value
-        const sample  = rawData.filter(([commit, dataValue]) => updateValue(dataValue) === value)
-        const formattedDate = new Date(sample[0][2] * 1000).toString().replace(/GMT[+-]\d{4}/, '').replace(/\(.*\)/, '(CEST)');
-        // Add commit hash to the tooltip as a link
-        const commitLink = `https://git.yoctoproject.org/poky/commit/?id=${sample[0][3]}`
-        if ('{{ measurement.value_type.quantity }}' == 'time') {
-          const hours = Math.floor(value/60)
-          const minutes = Math.floor(value % 60)
-          const seconds = Math.floor((value * 60) % 60)
-          return `<strong>Duration:</strong> ${hours}:${minutes}:${seconds}, <strong>Commit number:</strong> <a href="${commitLink}" target="_blank" rel="noreferrer noopener">${sample[0][0]}</a>, <br/> <strong>Start time:</strong> ${formattedDate}`
-        }
-        return `<strong>Size:</strong> ${value.toFixed(2)} MB, <strong>Commit number:</strong> <a href="${commitLink}" target="_blank" rel="noreferrer noopener">${sample[0][0]}</a>, <br/> <strong>Start time:</strong> ${formattedDate}`
-      ;}
-    },
-    xAxis: {
-      name: 'Commit count',
-      type: 'category',
-      data: commitCountList
-    },
-    yAxis: {
-      name: '{{ measurement.value_type.quantity }}' == 'time' ? 'Duration in minutes' : 'Disk size in MB',
-      type: 'value',
-      min: function(value) {
-        return Math.round(value.min - 0.5);
-      },
-      max: function(value) {
-        return Math.round(value.max + 0.5);
-      }
-    },
-    dataZoom: [
-      {
-        type: 'slider',
-        xAxisIndex: 0,
-        filterMode: 'none'
-      },
-    ],
-    series: [
-      {
-        name: '{{ measurement.value_type.quantity }}',
-        type: 'line',
-        symbol: 'none',
-        data: commitCountData
-      }
-    ]
-  };
-
-  // Draw chart
-  const draw_chart = (chart_id, option) => {
-    let chart_name
-    const chart_div = document.getElementById(chart_id);
-    // Set dark mode
-    if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
-        chart_name= echarts.init(chart_div, 'dark', {
-        height: 320
-      });
-    } else {
-        chart_name= echarts.init(chart_div, null, {
-        height: 320
-      });
-    }
-    // Change chart size with browser resize
-    window.addEventListener('resize', function() {
-      chart_name.resize();
-    });
-    return chart_name.setOption(option);
-  }
-
-  draw_chart('{{ chart_elem_start_time_id }}', option_start_time)
-  draw_chart('{{ chart_elem_commit_count_id }}', option_commit_count)
-</script>
diff --git a/scripts/lib/build_perf/html/report.html b/scripts/lib/build_perf/html/report.html
index 28cd80e738..4b37893cd0 100644
--- a/scripts/lib/build_perf/html/report.html
+++ b/scripts/lib/build_perf/html/report.html
@@ -1,24 +1,8 @@ 
 <!DOCTYPE html>
 <html lang="en">
 <head>
-{# Scripts, for visualization#}
-<!--START-OF-SCRIPTS-->
 <script src=" https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js "></script>
 
-{# Render measurement result charts #}
-{% for test in test_data %}
-  {% if test.status == 'SUCCESS' %}
-    {% for measurement in test.measurements %}
-      {% set chart_elem_start_time_id = test.name + '_' + measurement.name + '_chart_start_time' %}
-      {% set chart_elem_commit_count_id = test.name + '_' + measurement.name + '_chart_commit_count' %}
-      {% include 'measurement_chart.html' %}
-    {% endfor %}
-  {% endif %}
-{% endfor %}
-
-<!--END-OF-SCRIPTS-->
-
-{# Styles #}
 <style>
 :root {
   --text: #000;
@@ -103,12 +87,22 @@  table {
 tr {
   border-bottom: 1px solid var(--trborder);
 }
-tr:first-child {
+thead {
   border-bottom: 1px solid var(--trtopborder);
 }
 tr:last-child {
   border-bottom: none;
 }
+
+table.meta-table tbody td, table.meta-table tbody th {
+  vertical-align: top;
+  line-height: 1.5em;
+}
+
+.fixed-table-header-width {
+  width: 40%;
+}
+
 a {
   text-decoration: none;
   font-weight: bold;
@@ -133,6 +127,19 @@  button:hover {
 .tab button.active {
   background-color: #d6d9e0;
 }
+
+.chart-tooltip {
+  max-width: 70vw;
+}
+
+.chart-tooltip ul {
+  padding-left: 1.5em;
+}
+
+.chart-tooltip li {
+  max-width: 30em;
+  text-wrap: auto;
+}
 @media (prefers-color-scheme: dark) {
   :root {
     --text: #e9e8fa;
@@ -154,6 +161,16 @@  button:hover {
     background-color: #545a69;
   }
 }
+
+@media (max-width: 1024px) {
+  body {
+    margin-inline: 0.5em;
+  }
+  .card-container {
+    padding-inline: 0;
+  }
+}
+
 </style>
 
 <title>{{ title }}</title>
@@ -169,23 +186,28 @@  button:hover {
   <h2>General</h2>
   <h4>The table provides an overview of the comparison between two selected commits from the same branch.</h4>
   <table class="meta-table" style="width: 100%">
-    <tr>
-      <th></th>
-      <th>Current commit</th>
-      <th>Comparing with</th>
-    </tr>
-    {% for key, item in metadata.items() %}
-    <tr>
-      <th>{{ item.title }}</th>
-      {%if key == 'commit' %}
-        <td>{{ poky_link(item.value) }}</td>
-        <td>{{ poky_link(item.value_old) }}</td>
-      {% else %}
-        <td>{{ item.value }}</td>
-        <td>{{ item.value_old }}</td>
-      {% endif %}
-    </tr>
-    {% endfor %}
+    <thead>
+      <tr>
+        <th></th>
+        <th class="fixed-table-header-width">Current commit</th>
+        <th class="fixed-table-header-width">Comparing with</th>
+      </tr>
+    </thead>
+    <tbody>
+      {% for key, item in metadata.items() %}
+      <tr>
+        <th>{{ item.title }}</th>
+        {%if key == 'commit' %}
+          <td>{{ poky_link(item.value) }}{%if metadata.commit_annotation and metadata.commit_annotation.value %}<br>{{metadata.commit_annotation.value}}{% endif %}</td>
+          <td>{{ poky_link(item.value_old) }}{%if metadata.commit_annotation and metadata.commit_annotation.value %}<br>{{metadata.commit_annotation.value_old}}{% endif %}</td>
+        {% elif key == 'commit_annotation' %}
+        {% else %}
+          <td>{{ item.value }}</td>
+          <td>{{ item.value_old }}</td>
+        {% endif %}
+      </tr>
+      {% endfor %}
+    </tbody>
   </table>
 
   {# Test result summary #}
@@ -380,7 +402,137 @@  button:hover {
   {% endfor %}
 </div>
 
-<script>
+<script type="text/javascript">
+
+const getCommonChartConfig = (measurement) => {
+  return {
+    tooltip: {
+      trigger: 'axis',
+      enterable: true,
+      className: 'chart-tooltip',
+      position: function (point, params, dom, rect, size) {
+        return [point[0], '0%'];
+      },
+    },
+    xAxis: {
+      type: 'time',
+    },
+    yAxis: {
+      name: measurement.value_type == 'time' ? 'Duration in minutes' : 'Disk size in MB',
+      type: 'value',
+      min: function(value) {
+        return Math.round(value.min - 0.5);
+      },
+      max: function(value) {
+        return Math.round(value.max + 0.5);
+      }
+    },
+    dataZoom: [
+      {
+        type: 'slider',
+        xAxisIndex: 0,
+        filterMode: 'none'
+      },
+    ]
+  }
+}
+
+const drawChart = (chart_id, chartOptions) => {
+  const chart_div = document.getElementById(chart_id);
+  const theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'default'
+
+  const chart = echarts.init(chart_div, theme, {height: 320});
+  chart.setOption(chartOptions);
+  window.addEventListener('resize', () => {
+    chart.resize();
+  });
+  requestAnimationFrame(() => chart.resize());
+}
+
+const generateTooltip = (param, sample, type) => {
+  // Data might be an array of arrays (for startTime charts) or an arraay of commit numbers (for commit number charts)
+  const value = Array.isArray(param[0].value) ? param[0].value[1] : param[0].value
+  const formattedDate = new Date(sample[0][0]).toString().replace(/GMT[+-]\d{4}/, '').replace(/\(.*\)/, '(CEST)');
+
+  // Add commit hash to the tooltip as a link
+  const commitLink = `https://git.yoctoproject.org/yocto-buildstats/commit/?id=${sample[0][2]}`
+  const commitAnnotation = commitAnnotations[sample[0][2]]
+  console.log('commitAnnotation', commitAnnotation)
+  if (type == 'time') {
+    const hours = Math.floor(value/60)
+    const minutes = Math.floor(value % 60)
+    const seconds = Math.floor((value * 60) % 60)
+    return `<ul>
+  <li><strong>Duration:</strong> ${hours}:${minutes}:${seconds}</li>
+  <li><strong>Commit number:</strong> <a href="${commitLink}" target="_blank" rel="noreferrer noopener">${sample[0][1]}</a></li>
+  ${commitAnnotation ? `<li><strong>Commit annotation:</strong> ${commitAnnotation}</li>` : ''}
+  <li><strong>Start time:</strong> ${formattedDate}</li>
+</ul>
+`
+  }
+  return `<strong>Size:</strong> ${value.toFixed(2)} MB, <strong>Commit number:</strong> <a href="${commitLink}" target="_blank" rel="noreferrer noopener">${sample[0][1]}</a>, <br/> <strong>Start time:</strong> ${formattedDate}`;
+}
+
+chartData.forEach(test => {
+  // sample array is:
+  // [start_time_in_ms, commit_num, commit_hash, duration_in_minutes_or_size]
+  test.measurements.forEach(measurement => {
+    const sharedChartConfig = getCommonChartConfig(measurement)
+    const startTimeChartConfig = {
+      ...sharedChartConfig,
+      tooltip: {
+        ...sharedChartConfig.tooltip,
+        formatter: function (param) {
+          const sample = measurement.combo_samples.filter(([start_time_in_ms, commit_num, commit_hash, duration_in_minutes_or_size]) => param[0].axisValue === start_time_in_ms)
+          return generateTooltip(param, sample, measurement.value_type)
+        }
+      },
+      series: [
+        {
+          name: measurement.value_type,
+          type: 'line',
+          symbol: 'none',
+          data: measurement.combo_samples.map((sample) => {
+            return [sample[0], sample[3]]
+          })
+        }
+      ]
+    }
+    const commitCountChartConfig = {
+      ...sharedChartConfig,
+      // Custom xAxis that displays the commit numbers
+      xAxis: {
+        name: 'Commit count',
+        type: 'category',
+        data: measurement.combo_samples.map((sample) => {
+            return sample[1]
+          })
+      },
+      tooltip: {
+        ...sharedChartConfig.tooltip,
+        formatter: function (param) {
+          const sample = measurement.combo_samples.filter(([start_time_in_ms, commit_num, commit_hash, duration_in_minutes_or_size]) => param[0].axisValue == commit_num)
+          return generateTooltip(param, sample, measurement.value_type)
+        }
+      },
+      series: [
+        {
+          name: measurement.value_type,
+          type: 'line',
+          symbol: 'none',
+          data: measurement.combo_samples.map((sample) => {
+            return sample[3]
+          })
+        }
+      ]
+    }
+    drawChart(measurement.chart_elem_start_time_id, startTimeChartConfig)
+    drawChart(measurement.chart_elem_commit_count_id, commitCountChartConfig)
+  })
+})
+
+
+
 function openChart(event, chartType, chartName) {
   let i, tabcontents, tablinks
   tabcontents = document.querySelectorAll(`.${chartName}_tabcontent > .tabcontent`);
diff --git a/scripts/oe-build-perf-report b/scripts/oe-build-perf-report
index a36f3c1bca..f9bdef2712 100755
--- a/scripts/oe-build-perf-report
+++ b/scripts/oe-build-perf-report
@@ -9,10 +9,15 @@ 
 
 import argparse
 import json
+import math
+import copy
 import logging
 import os
 import re
 import sys
+from urllib.request import urlopen
+from urllib import error
+
 from collections import namedtuple, OrderedDict
 from operator import attrgetter
 from xml.etree import ElementTree as ET
@@ -24,7 +29,7 @@  import scriptpath
 from build_perf import print_table
 from build_perf.report import (metadata_xml_to_json, results_xml_to_json,
                                aggregate_data, aggregate_metadata, measurement_stats,
-                               AggregateTestData)
+                               AggregateTestData, TimeVal, SizeVal)
 from build_perf import html
 from buildstats import BuildStats, diff_buildstats, BSVerDiff
 
@@ -292,6 +297,49 @@  class BSSummary(object):
             if ver_diff.rchanged:
                 self.ver_diff['Revision changed'] = [(n, "{} &rarr; {}".format(r.left.evr, r.right.evr)) for n, r in ver_diff.rchanged.items()]
 
+# Helpers for generating chart JSON
+
+CLASS_MAP = {
+    TimeVal: "time",
+    SizeVal: "size",
+}
+
+# Sanitize inf and nan, because JSON doesn’t support those
+def sanitize(obj):
+    if isinstance(obj, float):
+        if not math.isfinite(obj):
+            return None
+        return obj
+
+    if isinstance(obj, dict):
+        return {k: sanitize(v) for k, v in obj.items()}
+
+    if isinstance(obj, list):
+        return [sanitize(v) for v in obj]
+
+    if isinstance(obj, tuple):
+        return [sanitize(v) for v in obj]  # JSON has no tuples
+
+    # leave everything else alone
+    return obj
+
+# Default json.dump handlers for unknown data types 
+def json_default(obj):
+    # Class objects (only TimeVal and SizeVal)
+    if obj in CLASS_MAP:
+        return CLASS_MAP[obj]
+    
+    # Custom object instances (BSSummary)
+    if hasattr(obj, "__dict__"):
+        # This happens _after_ sanitze(tests) in json.dump,
+        # so we need to sanitize again…
+        return sanitize(obj.__dict__)
+
+    # NaN / Infinity (JSON does not like these)
+    if isinstance(obj, float) and not math.isfinite(obj):
+        return None
+
+    raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
 
 def print_html_report(data, id_comp, buildstats):
     """Print report in html format"""
@@ -375,6 +423,109 @@  def print_html_report(data, id_comp, buildstats):
                             'max': get_data_item(data[-1][0], 'layers.meta.commit_count')}
                  }
 
+    # Get commit annotations from Yocto’s git,
+    # mush them into the metadata object,
+    # and also output them as a POJO so eCharts can use them
+
+    commitAnnotationsURL = 'https://git.yoctoproject.org/yocto-buildstats/plain/annotations.json'
+    commitAnnotationsJSON = {}
+    try:
+        response = urlopen(commitAnnotationsURL)
+        commitAnnotationsJSON = json.loads(response.read())
+        # Splice the annotations into the metadata
+        commit = metadata.get('commit', {})
+        annotations_out = {}
+
+        if (h := commit.get('value')) in commitAnnotationsJSON:
+            annotations_out['value'] = commitAnnotationsJSON[h]
+
+        if (h := commit.get('value_old')) in commitAnnotationsJSON:
+            annotations_out['value_old'] = commitAnnotationsJSON[h]
+
+        if annotations_out:
+            metadata['commit_annotation'] = annotations_out
+            metadata['commit_annotation']['title'] = "Commit annotation"
+
+    except error.URLError as e:
+        logging.debug(f"Couldn't find any commit annotations at {commitAnnotationsURL}, reason: {e.reason}.")
+    except json.decoder.JSONDecodeError as e:
+        logging.error(f"Invalid JSON received from {commitAnnotationsURL}, error: {e}")
+    except Exception as e:
+        logging.exception(f"Unexpected error while loading annotations: {e}")
+
+    ### JSONifying data for consumption by Apache eCharts
+    
+    # Make a copy of tests to pare down into what we need as JSON
+    tests_for_JSON = copy.deepcopy(tests)
+
+    # Some transformation pipeline functions, because we don't want to output the
+    # entire JSON to the html file, that would be wasteful.
+    def skip_failed_tests(test):
+        if test.get('status') != 'SUCCESS':
+            return None
+        return test
+
+    # Adds the div ids so each chart knows where to render itself into
+    def add_chart_ids(test):
+        for measurement in test.get('measurements', []):
+            measurement['chart_elem_start_time_id'] = f"{test.get('name', '')}_{measurement.get('name', '')}_chart_start_time"
+            measurement['chart_elem_commit_count_id'] = f"{test.get('name', '')}_{measurement.get('name', '')}_chart_commit_count"
+        return test
+
+    # Compose sample series data for the charts
+    def parse_samples(test):
+        for measurement in test.get('measurements', []):
+            start_time_samples = []
+            commit_count_samples = []
+            combo_samples = []
+            for sample in measurement.get('samples', []):
+                # Mean is either a TimeVal or a SizeVal
+                mean = sample.get('mean')
+                # One chart shows duration in minutes/size over start time
+                # The other shows duration in minutes/size over commit count
+                duration_in_minutes = None
+                if isinstance(mean, TimeVal):
+                    hh, mm, ss = mean.hms()
+                    duration_in_minutes = hh*60 + mm + int(ss)/60 + int(ss*1000) % 1000/3600;
+                # Charts either need the time in minutes (from TimeVal) as their value, or size (SizeVal)
+                start_time_samples.append([int(sample.get('start_time')) * 1000, duration_in_minutes if duration_in_minutes is not None else mean])
+                commit_count_samples.append([sample.get('commit_num'), duration_in_minutes if duration_in_minutes is not None else mean])
+                combo_samples.append([int(sample.get('start_time')) * 1000, sample.get('commit_num'), sample.get('commit'), duration_in_minutes if duration_in_minutes is not None else mean])
+            # For echarts consumption, all we need is samples in the format [time_in_milliseconds, build_length_in_minutes],
+            # eg [1760544944000, 59.42388888888889]
+            measurement['combo_samples'] = combo_samples
+        return test
+
+    # Throw away all the large things we don't need
+    def clean_measurements(test):
+        for m in test.get('measurements', []):
+            m.pop('buildstats', None)
+            m.pop('value', None)
+            m.pop('samples', None)
+        return test
+
+    # Apply pipeline
+    for i, test in enumerate(tests_for_JSON):
+        for func in (skip_failed_tests, add_chart_ids, parse_samples, clean_measurements):
+            test = func(test)
+            if test is None:  # skip this test entirely
+                break
+        if test is not None:
+            tests_for_JSON[i] = test
+    
+    json_str = json.dumps(
+        sanitize(tests_for_JSON),
+        indent=2,
+        default=json_default,
+        allow_nan=False,
+    )
+    
+    # Output all the JSON to the head of the report html
+    print(f"""<script type="text/javascript">
+    const commitAnnotations = {commitAnnotationsJSON}
+    const chartData = {json_str}
+</script>""")
+
     print(html.template.render(title="Build Perf Test Report",
                                metadata=metadata, test_data=tests,
                                chart_opts=chart_opts))