Skip to content

Preserve mailroom metrics privacy - #1754

Open
DanGould wants to merge 6 commits into
payjoin:masterfrom
DanGould:mailroom-metrics-privacy
Open

DanGould wants to merge 6 commits into
payjoin:masterfrom
DanGould:mailroom-metrics-privacy

Conversation

@DanGould

@DanGould DanGould commented Jul 21, 2026

Copy link
Copy Markdown
Member

Make the mailroom's exported metrics coarse enough that or anyone who sees it cannot follow an individual operator or user's traffic.

Before this change, every node with a [telemetry] section pushes precise per-request counters and live gauges to Grafana Cloud every minute, labelled with the operator's domain. Operators get no view of those metrics themselves and the only reader is the Foundation's stack. The README did not say what is sent.

What the branch does

Read the README diff first; it is the spec. Then the two tests that enforce it (export_surface_is_windowed_gauges_with_allowed_attributes_only in src/metrics.rs and export_resource_carries_only_allowlisted_attributes in src/telemetry.rs). The commits add one thing at a time.

Two things a reviewer might ask:

  • Why are the counts exact? with no rounding and no small-count floor. Either would hide at most a few units from a passive viewer, and anyone able to send traffic can add a known amount and subtract it back out. The protection is the settled-window rule plus the absence of attributes.
  • Why push an unchanging value every hour? Cheap retries. A weekly value pushed once has one chance to land; pushed hourly it lands within an hour of any outage ending, and a restart costs at most the counts since the last hourly write, because the buckets and ID sets are on disk. This could serve as a coarse uptime pulse, too.

Known limits, stated in the README: precise metrics are not observable anywhere while export is on (a local sink is a separate feature TBD); one process per operator_domain, since two would each write their own share of the week to the same series.

Out of scope

  • A local sink for precise metrics (a second OTLP block or a loopback endpoint). Follow-up if an operator asks.
  • Dashboard panels moving from the _total counters to the _weekly gauges. Grafana-side work after merge; the operator.domain label is unchanged so nothing else moves.
  • The service.instance.id branch. This PR pins the resource to two attributes, the opposite design; that branch is parked, not rebased.

Disclosure: co-authored by Claude Code

@coveralls

coveralls commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 35623740801

Coverage increased (+0.3%) to 87.037%

Details

  • Coverage increased (+0.3%) from the base build.
  • Patch coverage: 63 uncovered changes across 4 files (694 of 757 lines covered, 91.68%).
  • 2 coverage regressions across 1 file.

Uncovered Changes

File Changed Covered %
payjoin-mailroom/src/lib.rs 39 0 0.0%
payjoin-mailroom/src/metrics.rs 558 547 98.03%
payjoin-mailroom/src/main.rs 10 0 0.0%
payjoin-mailroom/src/heartbeat.rs 96 93 96.88%
Total (7 files) 757 694 91.68%

Coverage Regressions

2 previously-covered lines in 1 file lost coverage.

File Lines Losing Coverage Coverage
payjoin-mailroom/src/main.rs 2 0.0%

Coverage Stats

Coverage Status
Relevant Lines: 17326
Covered Lines: 15080
Line Coverage: 87.04%
Coverage Strength: 327.45 hits per line

💛 - Coveralls

Logging switched to structured JSON whenever a [telemetry] section was
present, so the section did two jobs: it named the OTLP endpoint and
it chose the log format. An operator who wanted JSON logs had to
configure an export, and one who configured an export got JSON logs
whether or not a collector was reading them.

Add a top-level log_format key, "text" by default or "json", and build
the subscriber from it before any telemetry is set up. The section no
longer touches logging. Operators who relied on it for JSON logs add
log_format = "json" when they upgrade.
Add settled-window gauges for the five counts eligible to leave the
operator boundary: completed requests, started requests, mailbox
writes, tunnel sheds, and distinct mailbox IDs touched. Each covers the
most recently completed Monday-to-Monday UTC week and carries no
attributes.

Releasing only completed fixed weeks means there is no live value for a
prober to watch move and no overlapping windows to difference for daily
traffic. The gauges carry exact counts. Rounding to a bin buys nothing:
a few units of noise on a counter hides nothing from someone who can
send known traffic and subtract it back out. Withholding small counts
protects nothing either: a passive viewer would lose only a handful of
small values, and the same subtraction recovers them.

The unique count is the size of the set of mailbox IDs touched in the
settled week. A short ID is already the first eight bytes of a SHA-256,
so the set holds the IDs as they are, one set for the week in progress
and one for the previous week, older sets dropped at rollover. Each set
is capped at ten million entries: past that inserts stop and the week
reports the cap, which marks a flooded week rather than counting it.
The set replaces the HyperLogLog sketches that approximated the same
quantity over hourly, daily, weekly and monthly windows, the
unique_short_ids gauge they fed, and the hyperloglogplus dependency.
Both lockfiles lose the crate.

Nothing wires the gauges to an exporter yet; the precise instruments
registered by MetricsService::new are otherwise unchanged.
@DanGould
DanGould force-pushed the mailroom-metrics-privacy branch from 7881e8c to 118bf7c Compare September 21, 2026 15:54
Route the OTLP export through the settled-window gauges. With a
[telemetry] section present, the export provider now carries only the
five coarse weekly gauges; the precise per-request counters and live
gauges are registered on a provider with no reader and never leave the
process. The export provider is no longer installed as the global meter
provider, so no instrument can reach it through the global API and
bypass the export-surface test.

The exported resource is built from an empty resource, not the SDK
default, so the environment detector cannot add hostnames or other
attributes through OTEL_RESOURCE_ATTRIBUTES. It carries exactly the
service name and operator.domain, and a test pins that key set. The
operator_domain config key is unchanged: the label is operator-chosen,
the operator's public domain is the expected value, and the
Foundation's dashboards group by it. The value is an OperatorDomain
newtype that cannot be blank, so a section that would write the same
unlabelled series as every other blank one fails at config parse and
no code path can build a provider under an empty label.

Pushes happen hourly instead of every minute. Every push in a week
carries the same frozen settled-week value, so pushing more often adds
delivery attempts, not resolution. Hourly still lands the value within
an hour of a restart or of an outage ending.

Omitting the section exports nothing; there is no separate switch.
The four counter buckets behind the exported weekly gauges lived only
in memory. A redeploy mid-week zeroed the settled week, and that week
reported as empty. Operators redeploy, so the buckets now live in
weekly_counts.txt under storage_dir: eight integers keyed by week in a
small text format with a version header, loaded at boot, written by
renaming a process-specific temporary file into place so a reader
never sees a partial file.

The buckets are written once per export collection, from the first
gauge callback, and again when the process receives SIGINT or SIGTERM.
The serve loops now return on those signals instead of dying in the
default handler; connections are dropped as before, the only added
work is the write. Writing on every request would cost a syscall per
request on a busy node for no benefit, since the export only reads the
settled week once an hour, and writing only at shutdown would lose the
week on a crash. Between the two, a crash costs at most the counts
recorded since the last hourly export.

A missing file is a fresh install. An unreadable one is logged and
ignored rather than refusing to start; the next write replaces it. Two
processes sharing a storage directory cannot corrupt the file, because
each renames its own temporary file, but the last writer wins and each
exports only its own share of the week. One process per operator_domain
is the supported deployment.

The two sets of mailbox IDs behind unique_short_ids_weekly follow the
same rules in a sibling file, unique_short_ids.bin: a version header,
then for each week its number, its entry count and its IDs as raw
bytes. A separate binary file keeps the counters file readable by eye
and its parser a line splitter, and the fixed-width framing makes the
ID reader a length check and an 8-byte chunker rather than a text
parser over what can be millions of entries. One file for both windows
means one rename per write and no listing of storage_dir, which also
holds every mailbox file. Up to two weeks of touched mailbox IDs
therefore sit on disk, at most ten million per week, next to the
mailbox files that already carry those IDs as names.
Describe in the README what leaves an operator's machine once a
[telemetry] section is configured: five settled-week counts, exact and
including zero, no attributes on the data points, and a resource of
exactly the service name and operator.domain, each rule pinned by a
test. Say plainly that operator_domain is chosen by the operator and
that their public domain is the expected value, that one process per
value is the supported deployment, that precise metrics have no local
sink in this release, that the unique count is the exact size of a
capped set of mailbox IDs and what the cap means, and what a restart
or an outage does to the weekly buckets and ID sets. State that up to
two weeks of touched mailbox IDs sit on disk under storage_dir. Record
that logs are never exported.

Update config.example.toml to match, fix the README's link to it, and
add the changelog entry.
Once a minute, log one INFO line under the target
payjoin_mailroom::heartbeat with the requests in flight, the open
bootstrap tunnels, and on Linux the process's open file descriptor
count and soft limit.

The in-flight and tunnel gauges are OpenTelemetry up-down counters,
which can only be pushed to a metrics sink, and the export path sends
none of them. A descriptor leak announces itself as long-lived tunnels
climbing toward the limit over hours, and the structured log every
operator already has is the right place to see that curve. The service
keeps atomic copies of both counts, updated in the same calls as the
instruments, so the heartbeat reads them without touching the meter.

The interval is a constant and the line is not gated on the telemetry
feature or config: local logs are always there. The descriptor fields
come from /proc/self and are omitted on other platforms.
@DanGould
DanGould force-pushed the mailroom-metrics-privacy branch from 118bf7c to 05217e8 Compare September 21, 2026 16:09
@DanGould
DanGould marked this pull request as ready for review September 21, 2026 16:22
@DanGould

Copy link
Copy Markdown
Member Author

CI failing because the .#release devShell cannot build & the Docker job fails only because it waits on that one.

NixOS/nixpkgs#563137 has the fix

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants