Skip to content
Open
6 changes: 6 additions & 0 deletions .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ jobs:
go-version: "1.25"
cache: false

- uses: actions/setup-go@v5
if: startsWith(inputs.app, 'go/')
with:
go-version: "1.25"
cache: false
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate setup-go step accidentally added to workflow

Low Severity

The actions/setup-go@v5 step (lines 53–57) is an exact duplicate of the step already present at lines 47–51. Both have the same if condition, go-version, and cache setting. This appears to be an accidental copy-paste that causes Go to be set up twice for every Go benchmark run.

Fix in Cursor Fix in Web


- uses: actions/setup-python@v5
with:
python-version: "3.12"
Expand Down
48 changes: 48 additions & 0 deletions .github/workflows/e2e-go.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: "E2E: Go benchmarks"

on:
pull_request:
branches: [main, feat/github-actions]
paths:
- "apps/go/**"
- "configs/go-*.yaml"
- "lib/**"
- "templates/**"
- ".github/workflows/e2e-go.yml"
- ".github/workflows/benchmark.yml"
workflow_dispatch:
inputs:
sdk-version:
description: "sentry-go version (e.g. 0.42.0 or latest)"
default: "latest"

jobs:
resolve-version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.sdk.outputs.version }}
steps:
- name: Resolve SDK version
id: sdk
env:
INPUT_VERSION: ${{ inputs.sdk-version }}
run: |
VERSION="${INPUT_VERSION:-latest}"
if [ "$VERSION" = "latest" ]; then
VERSION=$(curl -sf 'https://proxy.golang.org/github.com/getsentry/sentry-go/@latest' | python3 -c "import json,sys; print(json.load(sys.stdin)['Version'].lstrip('v'))")
echo "Resolved latest sentry-go version: $VERSION"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"

bench:
needs: resolve-version
strategy:
fail-fast: false
matrix:
app: [go/net-http, go/gin, go/echo]
uses: ./.github/workflows/benchmark.yml
with:
app: ${{ matrix.app }}
sdk-version: ${{ needs.resolve-version.outputs.version }}
iterations: 1
post-comment: true
110 changes: 110 additions & 0 deletions lib/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import json
import logging
import math
import os
import subprocess
import threading
Expand Down Expand Up @@ -222,6 +223,13 @@ def run_benchmark(
result = run_variant(config, variant, i, results_base)
all_results["iterations"].append(result)

# Compute summary with overhead metrics
all_results["summary"] = _compute_summary(all_results["iterations"])
all_results["load"] = {
"rps": config.get("load", {}).get("rps"),
"duration": config.get("load", {}).get("duration"),
}

# Write aggregated results
results_path = os.path.join(results_base, "results.json")
with open(results_path, "w") as f:
Expand All @@ -231,9 +239,67 @@ def run_benchmark(
return all_results


def _percentile(sorted_values: list[float], p: float) -> float:
"""Compute the p-th percentile from a sorted list of values."""
if not sorted_values:
return 0.0
k = (len(sorted_values) - 1) * (p / 100.0)
f = math.floor(k)
c = math.ceil(k)
if f == c:
return sorted_values[int(k)]
return sorted_values[f] * (c - k) + sorted_values[c] * (k - f)


def _extract_latencies(iterations: list[dict], variant: str) -> list[float]:
"""Extract all latency values (in nanoseconds) for a given variant."""
latencies = []
for it in iterations:
if it.get("variant") != variant:
continue
for req in it.get("vegeta_results") or []:
latencies.append(req["latency"])
return latencies


def _compute_summary(iterations: list[dict]) -> dict:
"""Compute overhead summary by comparing baseline vs instrumented latencies."""
baseline_latencies = sorted(_extract_latencies(iterations, "baseline"))
instrumented_latencies = sorted(_extract_latencies(iterations, "instrumented"))

if not baseline_latencies or not instrumented_latencies:
logger.warning("Missing baseline or instrumented data for summary computation")
return {"overhead": {}, "regression": False}

overhead = {}
regression = False
for name, p in [("p50", 50), ("p90", 90), ("p95", 95), ("p99", 99)]:
base_val = _percentile(baseline_latencies, p)
inst_val = _percentile(instrumented_latencies, p)
if base_val > 0:
pct = (inst_val - base_val) / base_val * 100.0
overhead[name] = round(pct, 2)

base_mean = sum(baseline_latencies) / len(baseline_latencies)
inst_mean = sum(instrumented_latencies) / len(instrumented_latencies)
if base_mean > 0:
overhead["mean"] = round((inst_mean - base_mean) / base_mean * 100.0, 2)

# Flag regression if p99 overhead exceeds 10%
if overhead.get("p99", 0) > 10:
regression = True

return {"overhead": overhead, "regression": regression}


def _prepare_sdk_version(config: dict, sdk_version: str) -> None:
"""Template the SDK version into requirements files."""
app_dir = PROJECT_ROOT / config["app_dir"]
language = config.get("language", "")

if language == "go":
_prepare_go_sdk_version(app_dir, sdk_version)
return

# Python: render requirements-sentry.txt from template
tmpl_path = app_dir / "requirements-sentry.txt.tmpl"
Expand All @@ -245,3 +311,47 @@ def _prepare_sdk_version(config: dict, sdk_version: str) -> None:
with open(output_path, "w") as f:
f.write(rendered)
logger.info("Rendered %s with sdk_version=%s", output_path, sdk_version)


def _prepare_go_sdk_version(app_dir: Path, sdk_version: str) -> None:
"""Update sentry-go module versions in a Go app's go.mod."""
go_mod = app_dir / "go.mod"
if not go_mod.exists():
return

# Find all sentry-go modules referenced in go.mod
with open(go_mod) as f:
content = f.read()

sentry_modules = []
for line in content.splitlines():
line = line.strip()
# Match lines like: github.com/getsentry/sentry-go v0.42.0
# or: github.com/getsentry/sentry-go/gin v0.42.0
if "github.com/getsentry/sentry-go" in line and not line.startswith("module"):
parts = line.split()
if parts:
mod = parts[0]
if mod.startswith("github.com/getsentry/sentry-go"):
sentry_modules.append(mod)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Go.mod parser misses single-line require directives

High Severity

The go.mod parser in _prepare_go_sdk_version takes parts[0] to get the module name, but this fails for single-line require directives like require github.com/getsentry/sentry-go v0.42.0 where parts[0] is "require", not the module path. The go/net-http app uses exactly this format, so its sentry-go dependency will never be updated — the function will log a warning and return early without calling go get.

Fix in Cursor Fix in Web


if not sentry_modules:
logger.warning("No sentry-go modules found in %s", go_mod)
return

# Use go get to update each sentry-go module to the target version
version_spec = f"@v{sdk_version}" if sdk_version != "latest" and not sdk_version.startswith("v") else f"@{sdk_version}"
get_args = [f"{mod}{version_spec}" for mod in sentry_modules]

logger.info("Updating sentry-go modules in %s: %s", app_dir, get_args)
subprocess.run(
["go", "get"] + get_args,
cwd=str(app_dir),
check=True,
)
subprocess.run(
["go", "mod", "tidy"],
cwd=str(app_dir),
check=True,
)
logger.info("Updated go.mod in %s to sentry-go %s", app_dir, sdk_version)