What's new in v0.9.22
v0.9.22 makes findings name where they come from. source_endpoint used to be read from the direct parent span alone, so on any layered stack, where the route sits on the SERVER span two or more levels above the leaf, a finding triggered by a real endpoint reported the literal "unknown". Entry points reached outside an inbound HTTP request, scheduled jobs and message consumers, reported it too. That string names no origin, and since the acknowledgment signature is type : service : endpoint : hash(template), every finding wearing it shared one signature per service and statement, so acknowledging one silently hid the others. This release resolves the endpoint by walking the ancestor chain, falls back to the calling code frame when no HTTP attribute exists anywhere on it, and does so identically on the three ingestion paths.
An ancestor walk for the inbound route
source_endpoint now walks up the parent chain instead of inspecting the direct parent, bounded at eight hops, the depth an auto-instrumented Java stack reaches (HTTP server, filter, dispatcher, controller, service, repository, Hibernate, JDBC). It takes http.route from any span kind, and http.url or url.full from any kind except CLIENT, since an outbound call's URL names the third party the caller reached rather than the route being served. Blank attribute values are skipped rather than accepted as an answer.
The parent index is built per service.name and spans every ResourceSpans block that service owns, so a trace the collector's batch processor split across blocks still resolves, while a walk never crosses into another service, whose route names a different entry point and whose code frame lives in another repository.
A code frame when there is no route at all
When no HTTP attribute is found anywhere up the chain, the endpoint falls back to the calling code frame, built from code.namespace plus code.function, or from the qualified code.function.name. The two are joined with the separator the namespace already uses, :: for PHP and Rust, . elsewhere, so one origin spells itself the same way whether the agent emits the legacy attribute pair or the stable qualified name, and an agent upgrade does not re-key acknowledgments.
The frame is used only when it names an origin. A blank value, one holding control characters, a bare unqualified function name such as execute, and a framework kernel frame (Symfony\Component\HttpKernel\HttpKernel::handle, Illuminate\Foundation\Http\Kernel::handle, servlet-container internals) all keep "unknown", which is honest, rather than producing a plausible-looking endpoint that collides exactly as "unknown" did. On a PHP stack the outermost frame is the framework's HTTP kernel, so without that rule thousands of findings would gather under one name. Frames holding ? or @ are refused for a different reason: the endpoint sanitizer truncates there, and Order.valid? would reach the signature as Order.valid and quietly share it with the real Order.valid.
The same resolution on OTLP, Jaeger and Zipkin
All three ingestion paths now walk the parent chain with the same rules and the same bound, OTLP through parent_span_id, Jaeger through the CHILD_OF reference, Zipkin through parentId, so one trace resolves to one endpoint whichever way it arrives and a single acknowledgment covers all three. Jaeger and Zipkin also learned the stable code.function.name, code.file.path and code.line.number spellings, and their empty-string fallback becomes the documented "unknown", an empty string used to leave an empty component in the signature. Zipkin reports both halves of a shared RPC span under one id, so the index keeps the SERVER half, the one carrying the route.
A warning when sampling makes the counts a sample
The daemon's tuning advisor gains a rule on [daemon] sampling_rate, its eighth. Below 1.0 the daemon drops whole traces, so the absolute counts, findings, occurrences, the perf_sentinel_* totals, describe a fraction of the traffic and a rare pattern can be missed entirely, and nothing in /api/export/report said so. Ratios are deliberately left out of the message: the drop is a uniform hash over trace ids, so the I/O waste ratio samples numerator and denominator alike and stays readable, and rescaling it would produce a wrong number rather than a corrected one. A rate of exactly 0.0, which config validation accepts, gets its own message saying no trace is analyzed at all, and it rides the cold-start envelope, since at that rate the daemon never leaves cold start.
Upgrade impact
One contract changes, and it is the acknowledgment signature. Findings whose endpoint was "unknown" now name an origin, so their signature changes and any entry in .perf-sentinel-acknowledgments.toml recorded against such an endpoint stops matching and must be re-recorded. This is wider than scheduled jobs alone, it includes HTTP findings whose route lived two or more levels above the leaf span. Validation across twelve language ecosystems measured roughly 4% of acknowledgment keys breaking, concentrated on PHP and on services that instrument both sides of their own HTTP calls. Findings whose endpoint was already a route are unaffected. Run analyze once and copy the new signatures from the report. On the Jaeger and Zipkin paths the move is wider still, findings whose endpoint was the empty string re-key as well.
Baselines that persist findings are affected the same way and only those. report --before baseline.json, the documented CI-template path, compares stored old endpoints against fresh ones, so every finding that moves off "unknown" shows once as resolved and once as new on the first run after upgrade, with no application change behind it, and a job uploading SARIF raises a fresh code-scanning alert for each. Re-capture such baselines against this release. A pipeline whose baseline is a trace corpus fed to diff --before sees no churn, both sides are re-analyzed by the current binary.
Why this is a patch and not a minor
No subcommand, flag, output shape or file format changes. One field, source_endpoint, carries a better value than before on findings that previously had none, which moves the acknowledgment signature derived from it. That is a behavior change on data, with one documented migration step, not a change to the surface the tool exposes.
Known limitation
Resolution happens when a span is converted, inside the OTLP export request that carried it. The collector's batch processor can flush one trace as two exports, and a span whose route travelled in the other export resolves to "unknown". Measured at 414 findings on a 245 MB capture, concentrated on services that instrument both sides of their own HTTP calls. Raising send_batch_size so a whole trace tends to fit in one export reduces it, a groupbytrace processor ahead of the exporter removes it. See docs/LIMITATIONS.md.
Validation
Validated in the external simulation lab against seventeen services across twelve language ecosystems, on real agent output replayed through this binary and through v0.9.17 so the code is the only variable. Cross-format resolution agrees on every finding common to the three paths, with no empty-string endpoint anywhere. Two scheduled jobs in one service issuing the same statement now carry distinct endpoints, the defect this release exists to fix, confirmed on real Java agent output. No endpoint is lost to a resource-block boundary, and the adversarial replay corpus censuses identically to the previous release. 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.22" \
--expected-issuer "https://token.actions.githubusercontent.com" \
--verify-binary ./perf-sentinel-linux-amd64gh CLI 2.49 or newer required for gh attestation verify.
Full Changelog: v0.9.17...v0.9.22