Skip to content

v0.9.23

Latest

Choose a tag to compare

@github-actions github-actions released this 29 Jul 22:06
v0.9.23

What's new in v0.9.23

v0.9.23 teaches perf-sentinel to see the message bus. Until now a span carrying messaging.system matched none of the three admission branches, a SQL statement, an outbound URL or an RPC callee, so it was dropped under not_io with no per-span signal. An architecture built on Kafka or SQS read as fully analyzed while half of its business path was silent, and nothing in the output said a whole protocol had been skipped. This release admits publish spans as I/O, gives them two dedicated finding types, follows the producer-to-consumer chain across the broker, and attributes broker energy to the waste figure through a path that works even on a managed broker where no agent can run.

Publish spans are I/O

A span carrying messaging.system and messaging.destination.name, or the pre-1.21 messaging.destination, is admitted as an outbound call whose target is the destination, falling back to the span name when the destination is absent. One semantic convention covers the whole family, so Kafka, RabbitMQ, Pulsar, SQS, NATS and JMS arrive at once rather than one integration at a time.

Only SpanKind::Producer is admitted. A CONSUMER span describes work done on a message that was delivered, not a call the service chose to make, and a polling consumer would flood the occurrence detectors with publishes it never issued. Only the OTLP path is covered, which also covers OTLP/JSON and Tempo. Jaeger and Zipkin imports are unchanged, and their gates are now typed so that admitting messaging there cannot happen by accident.

Two finding types, and no third

n_plus_one_messaging and slow_messaging are backed by a new EventType::Messaging. There is deliberately no redundant counterpart: redundancy is decided on parameter equality, a publish carries no parameters, and two messages are never the same message. A publish carrying no bind parameters is also why the messaging N+1 rule is the occurrence count alone, over a single empty parameter set.

The dedicated event type is what keeps destinations away from the HTTP path normalizer, which would mask an SQS queue ARN's account id as {id} and merge two AWS accounts into one template. A destination is already a template, so nothing is extracted from it. The one transformation applied strips userinfo and query from a destination that carries a scheme, since unlike SQL and HTTP this arm has no parser between an operator-supplied string and the finding template. A scheme-less destination is left byte for byte alone, because @ and # are legal in queue names.

chatty_service and pool_saturation are unaffected, they stay gated on HTTP and SQL respectively.

Suggested fixes keyed by broker, not by framework

Both messaging types carry a suggested_fix, and it is keyed by broker technology rather than by application framework. The remediation for a publish anti-pattern lives in the broker client's batching API, linger.ms and batch.size on Kafka, SendMessageBatch on SQS, a transacted session on JMS, and the application framework does not name it. Kafka, RabbitMQ, SQS, Pulsar, NATS and JMS are covered, activemq maps to the JMS advice since that is the client API involved, and an unlisted system keeps the generic suggestion. Every recommendation carries a vendor documentation URL, except the NATS pair where no stable per-page path exists.

The chain survives the broker

SpanEvent gains link_trace_id, the trace of the producer whose message triggered this span. A broker joins producer to consumer with an OpenTelemetry span link rather than a parent-child edge, and the consumer usually starts its own trace, so the chain was simply cut at the broker.

Ingestion reads the first link of the nearest CONSUMER span, and it handles two topologies, because instrumentations disagree on where the receive span goes. It can sit on the ancestor chain, and it can be a sibling under a shared parent, which is the shape real OTel Java and .NET Kafka instrumentation emits and which an ancestor-only walk misses entirely. Among several sibling receive spans under one parent, the one that started last before the work did wins, so a consumer loop attributes each unit of work to the message that actually triggered it, and the answer does not depend on the order the exporter serialized the spans. A handler that began before the message arrived shields its whole subtree, however late its own I/O runs.

explain renders it as a triggered by trace <id> line in the CLI, in /api/explain/{trace_id} and in the TUI. The two traces are deliberately not merged, the reasoning is in docs/LIMITATIONS.md. The CONSUMER gate matters: batch span processors and follows-from relations emit links too, and a link pointing back into the span's own trace is dropped.

Broker energy, measured or declared

Broker energy can now be attributed to the messaging findings, through the same shape the database figure already used. [green.alumet.broker] measures a declared broker cgroup with Alumet. [green.broker_static] needs no agent at all: an operator declares nodes and an instance type, and the embedded SPECpower table turns that into watts. That second path is the only one available on a managed broker, Confluent Cloud, MSK, SQS or managed Pulsar, which is precisely the deployment that raised the question.

Either energy is multiplied by the messaging-only waste ratio and reported as green_summary.messaging_waste, plus aggregate.messaging_waste in the disclosure. It is informational like database_waste, never folded into energy_kwh, co2 or the waste tiers. Two new counters back the ratio, total_messaging_io_ops and avoidable_messaging_io_ops, mirroring the SQL pair.

Read the model before reading the number

The three provenance tags do not err in the same direction, so the tag is not decoration. alumet_rapl measures the cgroup through RAPL, CPU and DRAM only, so it under-counts. broker_specpower bounds the declared vCPUs at full load from a table that excludes storage, network and PSU, so it is a ceiling on compute rather than the cluster's wall power, and it does not move when an application starts batching its publishes. estimated re-presents a share of the totals.

The disclosure keeps the three apart, with declared_windows and declared_energy_kwh beside the measured pair, so an operator's statement about provisioned hardware is never published as a reading of the broker. A measured cgroup always outranks a declaration when both are configured, and when the measurement goes stale the declaration bills only the stretch the measurement missed, never a stretch already paid for.

A fourth quality-gate rule

[thresholds] n_plus_one_messaging_warning_max defaults to 3 and counts warning-or-higher findings. It mirrors the HTTP rule rather than the SQL one on purpose: a Kafka client may already batch the publishes it buffers, so the occurrence count is an upper bound there and a zero tolerance would fire on work already done. Rule labels are now shared between the terminal, the TUI and the dashboard, and the TUI's detail panel renders the Suggested fix line it previously omitted for every finding type, not only the messaging ones.

Periodic disclosure v1.5

The schema moves to perf-sentinel-report/v1.5, adding the two messaging pattern names to the PatternName enum and the messaging waste block with its three provenance buckets. Every new field is additive via serde defaults, so re-hashing an existing v1.4 report still yields its original content_hash, since the hashed form carries the report's own schema_version. A report generated by this build declares v1.5, so it is not byte-identical to one generated by 0.9.22.

Upgrade impact

Publish spans now count as I/O operations. On any service that emits them total_io_ops grows, the I/O intensity score of the endpoints involved rises, and so do their rank in the top offenders and the denominator of the waste ratio. The same traffic scored by 0.9.22 and by this release gives different figures. This is a widening of the measurement perimeter, not an application regression, and it matters when comparing disclosure periods across the upgrade. SQL-only and HTTP-only services are unaffected.

analyze --ci can start failing on a bus-heavy service with no configuration change, and not necessarily on a messaging finding. A service carrying four or more warning-or-higher N+1 messaging findings trips the new rule, raise the threshold or acknowledge the findings. But the effect measured on a real corpus arrived through serialized_calls instead: a service publishing one message per request grows no messaging anti-pattern at all, it simply adds I/O spans to its traces, and the detectors that count spans per trace cross their threshold more often. Expect the new findings to land on the topological detectors rather than on the messaging ones.

For downstream crates, EventType gains a third variant and FindingType two more, and neither enum is #[non_exhaustive], so an exhaustive match will not compile until it handles them. SpanEvent and explain::SpanNode each gain a public field and neither derives Default, so struct literals built outside this crate need the new field. Deserialization is unaffected, link_trace_id defaults when absent, so stored JSON traces keep parsing.

Why this is a patch and not a minor

No subcommand or flag is added or removed, and no file format changes shape. A protocol the tool previously ignored is now measured, which moves numbers on services that use it, and one gate rule is added with a permissive default. That is a widening of coverage on data, with the migration steps above, rather than a change to the surface the tool exposes.

Validation

Validated in the external simulation lab across four rounds, two of which returned FAIL and found defects the test suite had missed. Both had the same cause: the tests chose comfortable conditions, a fixture reproducing the topology the code already handled, then a clock running two orders of magnitude above the accrual floor. The final round re-validated the released tree after an adversarial review found two further defects in the sibling link resolution, and confirmed producer-link coverage unchanged on the real capture, seven topology probes including a payload-order invariance gate, and every replay corpus census identical. The cluster gate passes on a from-zero deployment, k6 10/10 and 59 of 60 scenarios, the single failure being a documented memory bound of the lab manifest that the previous release carries too.

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.23" \
  --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.22...v0.9.23