github robintra/perf-sentinel v0.9.24

latest release: chart-v0.9.24
3 hours ago

What's new in v0.9.24

v0.9.24 closes the gap between a test suite and the trace file batch mode needs. perf-sentinel has always been able to receive OTLP, on 4317 and 4318, and it has always been able to analyze a file. It could not turn one into the other, so every CI pipeline had to find something else to write that file, and for most runtimes there was nothing to find. This release adds capture, a subcommand that listens for OTLP and writes the file, and nothing else.

The file most runtimes cannot produce

The assumption behind analyze --ci --input traces.json was that a test suite can export its traces to a path. That assumption is wrong far more often than it looks. Of the six languages tracked by the OpenTelemetry configuration project, only C++ and PHP implement an exporter that writes to a chosen path. Java, Go, JavaScript and Python do not, and the previously documented Java recipe named an otlp_file exporter and an OTEL_EXPORTER_OTLP_FILE_PATH variable that do not exist in any OpenTelemetry release.

The obvious fallback, capturing the agent's stdout, does not survive a forked test runner. Surefire and Failsafe carry their command protocol on the forked JVM's standard output, and the OpenTelemetry agent initialises in premain and captures that stream before Surefire installs the wrapper redirectTestOutputToFile acts on. Every export is then classified as channel corruption and diverted to a .dumpstream, which reproduces on Failsafe 3.5.0, 3.2.5 and 2.22.2 alike. Piping the build does not help either, because the fork's stdout is the channel rather than the console.

What every runtime does do the same way is export over the network. So the answer is not another exporter, it is a listener.

Two shapes, one subcommand

capture --output traces.json -- mvn verify wraps the test step. The ports are bound and the trace file opened before the command starts, so no export is lost to a start-up race and a capture that cannot listen never leaves a test suite running detached. The command inherits stdout and stderr untouched, which matters because a CI script may be parsing that stream, and its exit code is propagated, so a failing test run stays a failing job.

Without a trailing command, capture runs until SIGINT or SIGTERM. That is the drop-in replacement for a Collector sitting beside a test step in a pipeline that already exists and whose test command cannot be prefixed. Prefixing the existing step is the documented shape in both cases, since adding a stage next to it would run the whole integration suite twice.

The application needs no perf-sentinel-specific setting, only OTEL_EXPORTER_OTLP_ENDPOINT and, deliberately, OTEL_EXPORTER_OTLP_PROTOCOL. Agent 2.0 changed its default from grpc to http/protobuf, so the same endpoint means different ports depending on the agent version, and an endpoint pointed at the wrong one exports nothing while warning only in the agent's own log.

The file describes what was sent

Requests are written as received, one NDJSON line each, with no conversion. That is a deliberate choice over reusing the ingest path: conversion drops non-I/O spans and masks fields, and the file would then describe what the pipeline kept rather than what the application sent. The result is canonical OTLP/JSON with hex trace ids, the shape the Collector file exporter produces, which format auto-detection reads with no flag. It stays a build artifact that can be kept, replayed against different thresholds, or compared to a baseline with diff.

An incomplete measurement is not a passing build

The failure mode worth designing against here is not a crash, it is a trace file that is quietly short of the run. Every way that can happen exits non-zero: the size cap at --max-file-size, and requests refused because the writer could not keep up. Refusals are counted apart from unusable requests, wrong content type or undecodable body, which point at a misconfigured exporter rather than a slow writer and get their own message naming the setting to fix and the two ports.

The same reasoning covers the wrapped command. A command killed by a signal reports 128 + signal the way a shell would, never zero, because an OOM-killed test suite that reports success would then pass a gate on the half of the run it managed to record. The wrapped command runs in its own process group and a cancelled job stops that whole group, since mvn forks a test JVM and signalling only the direct child would leave that fork holding its port and its database connection on an agent that believes the step is over.

An empty trace file is now rejected with a message that names the cause instead of EOF while parsing a value at line 1 column 0. It stays an error rather than becoming an empty report, for the same reason: a gate that passes on zero measured spans is a false green.

The same listeners as the daemon

Both transports feed the daemon's own OTLP listeners through a new OtlpSink, whose Raw arm hands the request over unconverted. The payload caps, gzip decompression, concurrency limit and memory admission gate are therefore the same code rather than a second implementation that drifts. The daemon's arm is the existing path moved behind a two-branch match, with the same rejection statuses and the same order of guards.

One attribute no longer costs every trace

An OTLP attribute carrying no value, {"key":"k","value":{}}, no longer fails the whole ingest. AnyValue is a flattened oneof that an empty object matches no variant of, so the derived deserializer rejected the entire document and a single such attribute cost every trace in the file. The shape is emitted by the opentelemetry-java stdout exporter, which is exactly what a CI capture would have fed to analyze. The lenient retry that already repaired empty lists now covers it, and a document it cannot repair still fails with the strict pass's positioned error rather than an error carrying no line or column.

Upgrade impact

Nothing changes for an existing pipeline that already produces its trace file. No detector, threshold, score or output format moves, and no flag on an existing subcommand changes meaning.

Two inputs that used to fail can now succeed. A trace file containing a valueless attribute previously failed to ingest in full and now analyzes normally, so a pipeline that was reporting a tooling error may start reporting findings. That is the fix working, and the findings were always there.

For downstream crates, ingest::otlp gains a public OtlpSink enum and two constructors, OtlpGrpcService::new_raw and otlp_http_router_with_sink. The existing OtlpGrpcService::new and otlp_http_router signatures are unchanged. A new capture module is public behind the daemon feature.

Why this is a patch and not a minor

A subcommand is added, which is a widening of the surface rather than a change to it. Nothing existing is removed, renamed, or given a different meaning, and a pipeline that ignores capture behaves exactly as it did under 0.9.23. The two ingest changes accept input that previously errored, which is a loosening rather than a break.

Validation

Validated in the external simulation lab across three rounds, two of which returned FAIL. The first rejected the documented approach outright rather than the code: capturing the agent's stdout under a forked runner is impossible, which is what motivated a listener instead of another documentation fix. The second found three defects in the first implementation, each a way for the tool to mislead rather than to crash, a published recipe that exported nothing, refused requests reported as backpressure when the exporter was simply misconfigured, and a grandchild process surviving a cancelled job.

The final round is a full pass on the 61-scenario gate, java-ci-capture having been promoted out of quarantine into it. The Java recipe runs end to end with the fork intact and yields a census identical to a live Collector file exporter run of the same test, 15 occurrences for 15 queries, with all 16 spans of the trace present. The daemon's ten k6 legs are unchanged and shedding under saturation is unchanged, both re-measured on the released tree, and two memory scenarios that failed on a loaded cluster were resolved by A/B against the published 0.9.23 image rather than by argument. One scenario stays red on the lab manifest's 256 MiB container limit and fails identically on 0.9.23, so it is an environment bound this release does not move.

The full Rust suite passes green, clippy is clean at -D warnings, and the default and --no-default-features builds both pass.

Verifying this release

# Binary integrity via SLSA Build L3 attestation
gh attestation verify perf-sentinel-linux-amd64 \
  --repo robintra/perf-sentinel

# A periodic disclosure produced by this binary
perf-sentinel verify-hash --report perf-sentinel-report.json \
  --expected-identity "https://github.com/robintra/perf-sentinel/.github/workflows/release.yml@refs/tags/v0.9.24" \
  --expected-issuer "https://token.actions.githubusercontent.com" \
  --verify-binary ./perf-sentinel-linux-amd64

gh CLI 2.49 or newer required for gh attestation verify.

Full Changelog: v0.9.23...v0.9.24

Don't miss a new perf-sentinel release

NewReleases is sending notifications on new releases.