Skip to content

feat(debugger): Add event tracing and runtime diagnostics #403

feat(debugger): Add event tracing and runtime diagnostics

feat(debugger): Add event tracing and runtime diagnostics #403

Workflow file for this run

name: Benchmark
on:
push:
branches: [main]
paths:
- 'Picea.Abies/**'
- 'Picea.Abies.UI/**'
- 'Picea.Abies.Benchmark.Wasm/**'
- 'Picea.Abies.Benchmarks/**'
- 'scripts/extract-allocations.py'
- 'scripts/merge-benchmark-results.py'
- 'scripts/compare-benchmarks.py'
- 'scripts/compare-benchmark.py'
- '.github/workflows/benchmark.yml'
pull_request:
branches: [main]
# No path filter for PRs - let the job-level conditions decide
# E2E runs on perf PRs, micro runs on Picea.Abies/** changes
workflow_dispatch:
inputs:
run_e2e_benchmark:
description: 'Run E2E js-framework-benchmark'
required: false
default: true
type: boolean
permissions:
contents: write
deployments: write
pull-requests: write
# ==============================================================================
# BENCHMARKING STRATEGY (see docs/investigations/benchmarking-strategy.md)
# ==============================================================================
# This workflow uses js-framework-benchmark as the SINGLE SOURCE OF TRUTH.
#
# Why E2E only (no micro-benchmarks in PR builds):
# - Historical evidence: PatchType enum optimization showed 11-20% improvement
# in BenchmarkDotNet but caused 2-5% REGRESSION in E2E benchmarks
# - Micro-benchmarks miss: JS interop overhead, browser rendering, GC pressure
# - E2E measures real user-perceived latency (EventDispatch → Paint)
#
# BenchmarkDotNet micro-benchmarks:
# - ONLY run on push to main for historical tracking
# - NOT run on PRs (removed to avoid misleading results)
# - For local development: dotnet run --project Picea.Abies.Benchmarks -c Release
# - Good for algorithm comparison and allocation tracking locally
#
# E2E benchmarks auto-trigger on:
# - PRs with title starting with 'perf:' or 'perf('
# - PRs with 'performance' label
# - PRs with 'ui' label
# - PRs that modify benchmark workflow or scripts
# - Pushes to main (baseline tracking)
# - Manual workflow_dispatch
#
# Benchmarks included:
# - CPU (01-09): Create, replace, update, select, swap, remove, clear rows
# - Memory (21-25): Ready memory, run memory, clear memory
# ==============================================================================
jobs:
# ============================================================================
# PATH DETECTION - Determine which jobs should run
# ============================================================================
changes:
name: Detect Changes
runs-on: ubuntu-latest
outputs:
e2e-scripts: ${{ steps.filter.outputs.e2e-scripts }}
perf-pr: ${{ steps.check-perf.outputs.is-perf }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
e2e-scripts:
- 'Picea.Abies/**'
- 'Picea.Abies.UI/**'
- 'Picea.Abies.Benchmark.Wasm/**'
- '.github/workflows/benchmark.yml'
- 'scripts/compare-benchmark.py'
- 'scripts/convert-e2e-results.py'
- 'scripts/run-benchmarks.sh'
- name: Check if performance PR
id: check-perf
run: |
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
TITLE="${{ github.event.pull_request.title }}"
if [[ "$TITLE" == perf:* ]] || [[ "$TITLE" == perf\(* ]]; then
echo "is-perf=true" >> $GITHUB_OUTPUT
else
echo "is-perf=false" >> $GITHUB_OUTPUT
fi
else
echo "is-perf=false" >> $GITHUB_OUTPUT
fi
# ============================================================================
# MICRO-BENCHMARKS (BenchmarkDotNet) - Historical Tracking Only
# ============================================================================
# ONLY runs on push to main for historical tracking.
# NOT run on PRs - micro-benchmarks have historically been misleading.
# For local development: dotnet run --project Picea.Abies.Benchmarks -c Release
# ============================================================================
benchmark:
name: Micro-Benchmarks (main only)
runs-on: ubuntu-latest
needs: changes
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Restore dependencies
run: dotnet restore Picea.Abies.Benchmarks/Picea.Abies.Benchmarks.csproj
- name: Build benchmarks
run: dotnet build Picea.Abies.Benchmarks/Picea.Abies.Benchmarks.csproj -c Release --no-restore
- name: Run DOM Diffing benchmarks
run: >
dotnet run --project Picea.Abies.Benchmarks -c Release --no-build --
--filter '*DomDiffingBenchmarks*'
--exporters json
--artifacts ./benchmark-results/diffing
- name: Run Rendering benchmarks
run: >
dotnet run --project Picea.Abies.Benchmarks -c Release --no-build --
--filter '*RenderingBenchmarks*'
--exporters json
--artifacts ./benchmark-results/rendering
- name: Run Event Handler benchmarks
run: >
dotnet run --project Picea.Abies.Benchmarks -c Release --no-build --
--filter '*EventHandlerBenchmarks*'
--exporters json
--artifacts ./benchmark-results/handlers
- name: Merge benchmark results
run: python3 scripts/merge-benchmark-results.py ./benchmark-results
# ============================================
# HISTORICAL TRACKING (gh-pages)
# ============================================
- name: Check if gh-pages exists
id: gh-pages-check
run: |
if git ls-remote --exit-code --heads origin gh-pages > /dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Store throughput baseline
uses: benchmark-action/github-action-benchmark@v1
with:
name: "2. Micro-Benchmarks: Throughput (BenchmarkDotNet)"
tool: benchmarkdotnet
output-file-path: ./benchmark-results/merged/throughput.json
github-token: ${{ secrets.GITHUB_TOKEN }}
auto-push: true
save-data-file: true
skip-fetch-gh-pages: ${{ steps.gh-pages-check.outputs.exists != 'true' }}
alert-threshold: '105%'
comment-on-alert: true
fail-on-alert: false
summary-always: true
alert-comment-cc-users: '@MCGPPeters'
- name: Store allocation baseline
uses: benchmark-action/github-action-benchmark@v1
with:
name: "2. Micro-Benchmarks: Allocations (BenchmarkDotNet)"
tool: customSmallerIsBetter
output-file-path: ./benchmark-results/merged/allocations.json
github-token: ${{ secrets.GITHUB_TOKEN }}
auto-push: true
save-data-file: true
skip-fetch-gh-pages: false
alert-threshold: '110%'
comment-on-alert: true
fail-on-alert: false
summary-always: true
alert-comment-cc-users: '@MCGPPeters'
- name: Upload benchmark artifacts
uses: actions/upload-artifact@v6
if: always()
with:
name: micro-benchmark-results
path: ./benchmark-results/
retention-days: 30
# ============================================================================
# E2E BENCHMARKS (js-framework-benchmark) - Source of Truth
# REQUIRED STATUS CHECK - Fails on >5% performance regression
# ============================================================================
# Clones the upstream js-framework-benchmark for benchmark infrastructure
# (server, webdriver-ts, CSS) and builds Picea.Abies.Benchmark.Wasm from
# the repo for the framework-under-test.
# ============================================================================
benchmark-check:
name: Benchmark (js-framework-benchmark)
# NOTE: This job provides the "Benchmark (js-framework-benchmark)" status check
# required for merging PRs. It fails if perf regressions >5% are detected.
runs-on: ubuntu-latest
needs: changes
if: >
github.event_name == 'workflow_dispatch' ||
github.event_name == 'pull_request' ||
(github.event_name == 'push' && github.ref == 'refs/heads/main')
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Decide whether to run benchmarks
id: should-run
run: |
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/main" ]]; then
echo "run=true" >> "$GITHUB_OUTPUT"
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
if [[ "${{ inputs.run_e2e_benchmark }}" == "true" ]]; then
echo "run=true" >> "$GITHUB_OUTPUT"
else
echo "run=false" >> "$GITHUB_OUTPUT"
fi
elif [[ "${{ needs.changes.outputs.perf-pr }}" == "true" || "${{ needs.changes.outputs.e2e-scripts }}" == "true" || "${{ contains(github.event.pull_request.labels.*.name, 'performance') }}" == "true" || "${{ contains(github.event.pull_request.labels.*.name, 'ui') }}" == "true" ]]; then
echo "run=true" >> "$GITHUB_OUTPUT"
else
echo "run=false" >> "$GITHUB_OUTPUT"
fi
- name: Skip benchmark execution
if: steps.should-run.outputs.run != 'true'
run: |
echo "No benchmark run needed for this PR; required check passes."
- name: Clone js-framework-benchmark
if: steps.should-run.outputs.run == 'true'
run: |
# Remove uninitialized submodule placeholder directory
rm -rf js-framework-benchmark
# Clone upstream benchmark infrastructure (shallow for speed)
git clone --depth 1 https://github.com/krausest/js-framework-benchmark.git js-framework-benchmark
- name: Setup .NET
if: steps.should-run.outputs.run == 'true'
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Install WASM workloads
if: steps.should-run.outputs.run == 'true'
run: dotnet workload install wasm-experimental wasm-tools
- name: Setup Node.js
if: steps.should-run.outputs.run == 'true'
uses: actions/setup-node@v4
with:
node-version: '22'
# Build the benchmark WASM app from the repo's own project
- name: Build Abies benchmark WASM app
if: steps.should-run.outputs.run == 'true'
run: |
dotnet publish Picea.Abies.Benchmark.Wasm/Picea.Abies.Benchmark.Wasm.csproj -c Release
# Set up the framework entry in the js-framework-benchmark directory.
# The upstream server's isFrameworkDir() requires BOTH package.json AND
# package-lock.json to exist for framework discovery via the /ls endpoint.
- name: Setup Abies framework in benchmark
if: steps.should-run.outputs.run == 'true'
run: |
# Ensure framework directory structure exists
mkdir -p js-framework-benchmark/frameworks/keyed/abies/bundled-dist/wwwroot
# Write package.json for framework registration with the benchmark server
cat > js-framework-benchmark/frameworks/keyed/abies/package.json << 'PACKAGE_EOF'
{
"name": "js-framework-benchmark-abies",
"version": "1.0.0",
"js-framework-benchmark": {
"frameworkVersionFromPackage": "",
"frameworkVersion": "2.0.0",
"customURL": "/bundled-dist/wwwroot/",
"frameworkHomeURL": "https://github.com/picea/abies"
}
}
PACKAGE_EOF
# Write package-lock.json (required by upstream isFrameworkDir() check).
# The server checks fs.existsSync(package-lock.json) before including a
# framework in the /ls response. Since we use frameworkVersion (not
# frameworkVersionFromPackage), the lock file contents are not read —
# only its existence matters.
cat > js-framework-benchmark/frameworks/keyed/abies/package-lock.json << 'LOCKFILE_EOF'
{
"name": "js-framework-benchmark-abies",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {}
}
LOCKFILE_EOF
# Find the AppBundle output directory (location varies by .NET SDK version)
APPBUNDLE=$(find Picea.Abies.Benchmark.Wasm/bin -path "*/AppBundle" -type d | head -1)
if [ -z "$APPBUNDLE" ]; then
echo "Error: AppBundle directory not found after publish"
find Picea.Abies.Benchmark.Wasm/bin -type d | head -20
exit 1
fi
echo "Found AppBundle at: $APPBUNDLE"
# Copy published WASM output to benchmark framework directory
cp -r "$APPBUNDLE"/* js-framework-benchmark/frameworks/keyed/abies/bundled-dist/wwwroot/
# Fix base href for benchmark server context
sed -i 's|<head>|<head>\n <base href="/frameworks/keyed/abies/bundled-dist/wwwroot/">|' \
js-framework-benchmark/frameworks/keyed/abies/bundled-dist/wwwroot/index.html
# Verify the framework files are in place
echo "=== Framework directory contents ==="
ls -la js-framework-benchmark/frameworks/keyed/abies/
echo "=== Bundled dist contents ==="
ls -la js-framework-benchmark/frameworks/keyed/abies/bundled-dist/wwwroot/ | head -20
# Cache npm dependencies
- name: Cache npm dependencies
if: steps.should-run.outputs.run == 'true'
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-js-framework-benchmark-${{ runner.os }}-v1
restore-keys: |
npm-js-framework-benchmark-${{ runner.os }}-
# Cache Chrome browser for Selenium
- name: Cache Chrome for Testing
if: steps.should-run.outputs.run == 'true'
uses: actions/cache@v4
with:
path: ~/.cache/selenium
key: selenium-chrome-${{ runner.os }}-v1
restore-keys: |
selenium-chrome-${{ runner.os }}-
- name: Install benchmark dependencies
if: steps.should-run.outputs.run == 'true'
run: |
cd js-framework-benchmark
npm ci
cd webdriver-ts
npm ci
# Compile TypeScript to dist/ folder
npm run compile
- name: Start benchmark server
if: steps.should-run.outputs.run == 'true'
run: |
cd js-framework-benchmark
npm start &
# Wait for server to be ready (up to 30 seconds)
echo "Waiting for benchmark server to start..."
for i in {1..30}; do
if curl -s http://localhost:8080 > /dev/null 2>&1; then
echo "Server is ready!"
break
fi
if [ $i -eq 30 ]; then
echo "Error: Server failed to start within 30 seconds"
exit 1
fi
sleep 1
done
# Verify the server discovered our framework via the /ls endpoint.
# Without this check, a missing package-lock.json or misconfigured
# package.json causes zero frameworks to match silently (exit 0,
# no results, compare script fails).
- name: Verify framework discovery
if: steps.should-run.outputs.run == 'true'
run: |
echo "=== Checking /ls endpoint for framework discovery ==="
LS_RESPONSE=$(curl -s http://localhost:8080/ls)
echo "$LS_RESPONSE" | python3 -m json.tool | head -30
if echo "$LS_RESPONSE" | grep -q '"abies"'; then
echo "✅ Framework 'abies' discovered by benchmark server"
else
echo "❌ Framework 'abies' NOT found in /ls response!"
echo "Full /ls response:"
echo "$LS_RESPONSE" | python3 -m json.tool
echo ""
echo "Checking framework directory:"
ls -la js-framework-benchmark/frameworks/keyed/abies/
exit 1
fi
- name: Run E2E benchmarks (all CPU benchmarks)
if: steps.should-run.outputs.run == 'true'
run: |
cd js-framework-benchmark/webdriver-ts
npm run bench -- --headless --framework keyed/abies --benchmark 01_run1k
npm run bench -- --headless --framework keyed/abies --benchmark 02_replace1k
npm run bench -- --headless --framework keyed/abies --benchmark 03_update10th1k
npm run bench -- --headless --framework keyed/abies --benchmark 04_select1k
npm run bench -- --headless --framework keyed/abies --benchmark 05_swap1k
npm run bench -- --headless --framework keyed/abies --benchmark 06_remove-one-1k
npm run bench -- --headless --framework keyed/abies --benchmark 07_create10k
npm run bench -- --headless --framework keyed/abies --benchmark 08_create1k-after1k_x2
npm run bench -- --headless --framework keyed/abies --benchmark 09_clear1k
- name: Run Memory benchmarks
if: steps.should-run.outputs.run == 'true'
run: |
cd js-framework-benchmark/webdriver-ts
npm run bench -- --headless --framework keyed/abies --benchmark 21_ready-memory
npm run bench -- --headless --framework keyed/abies --benchmark 22_run-memory
npm run bench -- --headless --framework keyed/abies --benchmark 25_clear-memory
- name: Refresh baseline from gh-pages
if: steps.should-run.outputs.run == 'true'
continue-on-error: true
run: |
# Try to refresh baseline from gh-pages branch.
# If unavailable, keep the repo baseline as fallback.
mkdir -p benchmark-results
if git show origin/gh-pages:data/e2e-baseline.json > /tmp/e2e-baseline.json 2>/dev/null; then
mv /tmp/e2e-baseline.json benchmark-results/baseline.json
echo "✅ Refreshed baseline from gh-pages"
else
if [ -f benchmark-results/baseline.json ]; then
echo "⚠️ Could not refresh from gh-pages, using repo baseline"
else
echo "⚠️ No baseline found in gh-pages or repo - first run will create one"
fi
fi
- name: Compare against baseline
if: steps.should-run.outputs.run == 'true'
id: regression-check
continue-on-error: true
run: |
mkdir -p benchmark-results
set +e
python3 scripts/compare-benchmark.py \
--results-dir js-framework-benchmark/webdriver-ts/results \
--baseline benchmark-results/baseline.json \
--threshold 5.0 \
--framework abies | tee benchmark-results/e2e-compare.log
EXIT_CODE=${PIPESTATUS[0]}
echo "exit_code=${EXIT_CODE}" >> "$GITHUB_OUTPUT"
exit ${EXIT_CODE}
- name: Publish benchmark comparison summary
if: always() && steps.should-run.outputs.run == 'true'
run: |
echo "## js-framework-benchmark comparison" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
if [ -f benchmark-results/e2e-compare.log ]; then
echo "Comparison command exit code: ${{ steps.regression-check.outputs.exit_code }}" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo '```text' >> "$GITHUB_STEP_SUMMARY"
tail -n 120 benchmark-results/e2e-compare.log >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
else
echo "No comparison log generated." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Report benchmark status
if: always() && steps.should-run.outputs.run == 'true'
run: |
if [[ "${{ steps.regression-check.outcome }}" == "failure" ]]; then
echo "❌ Performance regression detected (>5%)"
exit 1
else
echo "✅ Benchmark check passed"
fi
- name: Convert E2E results to benchmark format
if: steps.should-run.outputs.run == 'true'
run: |
python3 scripts/convert-e2e-results.py \
--results-dir js-framework-benchmark/webdriver-ts/results \
--output ./e2e-benchmark-results.json \
--output-memory ./e2e-benchmark-memory.json \
--framework abies
- name: Check if gh-pages exists
id: gh-pages-check
if: steps.should-run.outputs.run == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
if git ls-remote --exit-code --heads origin gh-pages > /dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Store E2E benchmark trends to gh-pages (main only)
uses: benchmark-action/github-action-benchmark@v1
if: steps.should-run.outputs.run == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.gh-pages-check.outputs.exists == 'true'
with:
name: "1. E2E Benchmark (js-framework-benchmark)"
tool: customSmallerIsBetter
output-file-path: ./e2e-benchmark-results.json
github-token: ${{ secrets.GITHUB_TOKEN }}
auto-push: true
save-data-file: true
alert-threshold: '110%'
comment-on-alert: true
fail-on-alert: false
summary-always: true
alert-comment-cc-users: '@MCGPPeters'
- name: Store E2E memory benchmark trends to gh-pages (main only)
uses: benchmark-action/github-action-benchmark@v1
if: steps.should-run.outputs.run == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.gh-pages-check.outputs.exists == 'true'
with:
name: "1. E2E Benchmark: Memory (js-framework-benchmark)"
tool: customSmallerIsBetter
output-file-path: ./e2e-benchmark-memory.json
github-token: ${{ secrets.GITHUB_TOKEN }}
auto-push: true
save-data-file: true
alert-threshold: '120%'
comment-on-alert: true
fail-on-alert: false
summary-always: true
alert-comment-cc-users: '@MCGPPeters'
# ============================================================================
# AUTO-UPDATE README BENCHMARK TABLES (main only)
# ============================================================================
# After benchmarks complete on main, regenerate the Duration and Memory
# tables in README.md using the latest results and the static Blazor baseline.
# Opens a PR instead of pushing directly to main to avoid triggering other
# on:push workflows (CD, E2E, etc.).
# ============================================================================
- name: Update README benchmark tables (main only)
if: steps.should-run.outputs.run == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
python3 scripts/update-readme-benchmarks.py \
--results-dir js-framework-benchmark/webdriver-ts/results \
--blazor-baseline benchmark-results/blazor-baseline.json \
--readme README.md \
--framework abies
- name: Create PR for README benchmark updates (main only)
if: steps.should-run.outputs.run == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: peter-evans/create-pull-request@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "docs: update benchmark results in README"
title: "docs: update benchmark results in README"
body: |
Automated update of benchmark tables in README.md based on the latest js-framework-benchmark results.
This PR was created by the Benchmark workflow after running on `main`.
branch: benchmark/readme-auto-update
base: main
add-paths: |
README.md
- name: Copy results to workspace for upload
if: always() && steps.should-run.outputs.run == 'true'
run: |
mkdir -p ./e2e-results
cp -r js-framework-benchmark/webdriver-ts/results/* ./e2e-results/ 2>/dev/null || true
cp ./e2e-benchmark-results.json ./e2e-results/ 2>/dev/null || true
cp ./e2e-benchmark-memory.json ./e2e-results/ 2>/dev/null || true
cp benchmark-results/e2e-compare.log ./e2e-results/ 2>/dev/null || true
- name: Upload E2E benchmark results
uses: actions/upload-artifact@v6
if: always() && steps.should-run.outputs.run == 'true'
with:
name: e2e-benchmark-results
path: ./e2e-results/
retention-days: 30