Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions payjoin-mailroom/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
- Enable HTTP/2 multiplexing on relay-directory hop (#1655)
- Bound OHTTP bootstrap tunnel resource usage and export its metrics (#1610)
- Add per-request metrics middleware (#1674)
- Export only settled weekly windows with small-count suppression, quantization, and an attribute allowlist; precise metrics stay local

## 0.1.1

Expand Down
38 changes: 37 additions & 1 deletion payjoin-mailroom/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,44 @@ systemctl enable --now payjoin-mailroom
## Telemetry

payjoin-mailroom supports **optional** OpenTelemetry-based telemetry (metrics).
Build with `--features telemetry` and configure via the [`[telemetry]`](config.example.com) config section.
Build with `--features telemetry` and configure via the [`[telemetry]`](config.example.toml) config section.
When no telemetry configuration is present, it falls back to local-only console tracing.
Metrics are local-only by default: nothing is exported unless a `[telemetry]` section is configured.

### What leaves the operator boundary

Precise metrics (per-request counters, live in-flight and tunnel gauges) never
leave the process. When export is configured, the only metrics pushed to the
OTLP endpoint are unlabelled coarse weekly gauges, designed to support
ecosystem traction measurement without providing per-request telemetry:

- **Settled weekly windows.** Each exported count covers one completed
Monday-to-Monday UTC week (`EXPORT_WINDOW_DAYS` in `src/metrics.rs`). The
in-progress week is never exported, preventing live probing and daily
differencing of overlapping windows.
- **Small-count suppression.** Windows whose raw count is below
`suppression_threshold` (default 10) are dropped entirely, not rounded: a
small operator's weekly count of 1-2 could be tied to a known real-world
event.
- **Quantization.** Surviving counts are rounded to the nearest
`quantization_bin` (default 5), so exported values carry no small-integer
precision to subtract against.
- **Attribute minimization.** Exported metric points carry no attributes. The
resource carries only `service.name` and a Foundation-issued opaque
`reporter.id`; its operator mapping belongs outside Grafana. Tests fail if
any other metric or resource attribute appears.
- **Reliable coarse delivery.** The exporter pushes daily, but every push in a
reporting week carries the same frozen aggregate for the preceding completed
week. This provides retry opportunities without exposing daily traffic
volume; daily delivery time remains liveness metadata.

The Foundation should restrict raw reporter-labelled series to its telemetry
operators, retain them briefly, and publish aggregate dashboards only. A future
collector can aggregate across reporters and remove `reporter.id` before data
reaches Grafana.

`export_enabled = false` keeps the `[telemetry]` section's structured JSON
logging while disabling the metrics export entirely.

## Access Control

Expand Down
22 changes: 18 additions & 4 deletions payjoin-mailroom/config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
#
# Configuration can also be set via environment variables with the `PJ_`
# prefix. Nested values use double underscores as separators, e.g.
# PJ_TELEMETRY__OPERATOR_DOMAIN="your-domain.example.com"
# PJ_TELEMETRY__REPORTER_ID="foundation-issued-opaque-id"

# Address and port to listen on
# listener = "[::]:8080"
Expand All @@ -22,9 +22,23 @@
# Authentication token for the OTLP endpoint
# auth_token = "<base64 instanceID:token>"

# The domain you are running the payjoin-mailroom from.
# This serves as an identifier for metrics collection.
# operator_domain = "your-domain.example.com"
# Foundation-issued opaque reporting ID. Do not use a domain, hostname, or
# operator name. Its mapping to an operator is kept outside Grafana.
# reporter_id = "foundation-issued-opaque-id"

# Master switch for the metrics export. When false, the [telemetry] section's
# structured JSON logging is kept but no metrics leave this machine.
# Omitting the whole [telemetry] section is also local-only.
# export_enabled = true

# Exported counts cover one completed Monday-to-Monday UTC week and are coarsened
# before export so aggregates cannot deanonymize users or fingerprint this
# operator. Windows whose raw count is below this threshold are not exported
# at all.
# suppression_threshold = 10

# Exported counts are rounded to the nearest multiple of this bin.
# quantization_bin = 5

# --- Access-control (requires `access-control` feature) ---
# [access_control]
Expand Down
37 changes: 36 additions & 1 deletion payjoin-mailroom/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,42 @@ pub struct V1Config {
pub struct TelemetryConfig {
pub endpoint: String,
pub auth_token: String,
pub operator_domain: String,
/// Foundation-issued opaque identifier for this reporting installation.
/// Never use a domain, hostname, or operator name here.
pub reporter_id: String,
/// Master switch for the metrics export. When `false` the mailroom keeps
/// the structured log format from this section but exports no metrics at
/// all; metrics stay local to the operator. Omitting the whole
/// `[telemetry]` section (the default) is also local-only.
#[serde(default = "default_export_enabled")]
pub export_enabled: bool,
/// Exported windows whose raw count is below this are dropped entirely.
/// See [`crate::metrics::ExportPolicy`].
#[serde(default = "default_suppression_threshold")]
pub suppression_threshold: u64,
/// Exported counts are rounded to the nearest multiple of this bin.
/// See [`crate::metrics::ExportPolicy`].
#[serde(default = "default_quantization_bin")]
pub quantization_bin: u64,
}

#[cfg(feature = "telemetry")]
fn default_export_enabled() -> bool { true }

#[cfg(feature = "telemetry")]
fn default_suppression_threshold() -> u64 { crate::metrics::DEFAULT_SUPPRESSION_THRESHOLD }

#[cfg(feature = "telemetry")]
fn default_quantization_bin() -> u64 { crate::metrics::DEFAULT_QUANTIZATION_BIN }

#[cfg(feature = "telemetry")]
impl TelemetryConfig {
pub fn export_policy(&self) -> crate::metrics::ExportPolicy {
crate::metrics::ExportPolicy {
suppression_threshold: self.suppression_threshold,
quantization_bin: self.quantization_bin,
}
}
}

#[cfg(feature = "acme")]
Expand Down
29 changes: 26 additions & 3 deletions payjoin-mailroom/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ pub mod ohttp_relay;
#[cfg(feature = "telemetry")]
pub mod telemetry;

use crate::metrics::MetricsService;
use crate::metrics::{ExportPolicy, MetricsService};
use crate::middleware::{track_connections, track_metrics};

type DirectoryService =
Expand All @@ -46,7 +46,7 @@ struct Services {

pub async fn serve(config: Config, meter_provider: Option<SdkMeterProvider>) -> anyhow::Result<()> {
let sentinel_tag = generate_sentinel_tag();
let metrics = MetricsService::new(meter_provider);
let metrics = build_metrics(&config, meter_provider);

#[cfg(feature = "access-control")]
let geoip = init_geoip(&config).await?;
Expand Down Expand Up @@ -169,7 +169,7 @@ pub async fn serve_acme(
.ok_or_else(|| anyhow::anyhow!("ACME configuration is required for serve_acme"))?;

let sentinel_tag = generate_sentinel_tag();
let metrics = MetricsService::new(meter_provider);
let metrics = build_metrics(&config, meter_provider);

#[cfg(feature = "access-control")]
let geoip = init_geoip(&config).await?;
Expand Down Expand Up @@ -229,6 +229,29 @@ pub async fn serve_acme(
/// at detecting self loops.
fn generate_sentinel_tag() -> SentinelTag { SentinelTag::new(rand::thread_rng().gen()) }

/// Builds the metrics service for a server process.
///
/// Precise instruments always stay in-process. When an export provider is
/// present, the only instruments registered on it are the coarse
/// settled-window gauges filtered through the operator's export policy:
/// nothing precise, live, or below-threshold leaves the operator boundary.
fn build_metrics(config: &Config, export_provider: Option<SdkMeterProvider>) -> MetricsService {
match export_provider {
Some(provider) => MetricsService::with_export(&provider, export_policy(config)),
None => MetricsService::new(None),
}
}

fn export_policy(config: &Config) -> ExportPolicy {
#[cfg(feature = "telemetry")]
if let Some(telemetry) = &config.telemetry {
return telemetry.export_policy();
}
#[cfg(not(feature = "telemetry"))]
let _ = config;
ExportPolicy::default()
}

#[cfg(feature = "access-control")]
impl Connected<IncomingStream<'_, Listener>> for middleware::MaybePeerIp {
fn connect_info(stream: IncomingStream<'_, Listener>) -> Self {
Expand Down
22 changes: 14 additions & 8 deletions payjoin-mailroom/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ async fn main() -> anyhow::Result<()> {

#[cfg(feature = "telemetry")]
let meter_provider = match &config.telemetry {
Some(telemetry) => Some(init_tracing_with_telemetry(telemetry)),
Some(telemetry) => init_tracing_with_telemetry(telemetry),
None => init_tracing(),
};
#[cfg(not(feature = "telemetry"))]
Expand All @@ -35,19 +35,25 @@ fn init_tracing() -> Option<SdkMeterProvider> {
}

#[cfg(feature = "telemetry")]
fn init_tracing_with_telemetry(telemetry: &config::TelemetryConfig) -> SdkMeterProvider {
let meter_provider = payjoin_mailroom::telemetry::build_otlp_meter_provider(
&telemetry.endpoint,
&telemetry.auth_token,
&telemetry.operator_domain,
);
fn init_tracing_with_telemetry(telemetry: &config::TelemetryConfig) -> Option<SdkMeterProvider> {
// export_enabled = false keeps this section's structured logging but
// builds no exporter at all: metrics stay local to the operator.
let meter_provider = telemetry.export_enabled.then(|| {
payjoin_mailroom::telemetry::build_otlp_meter_provider(
&telemetry.endpoint,
&telemetry.auth_token,
&telemetry.reporter_id,
)
});

let env_filter =
EnvFilter::builder().with_default_directive(LevelFilter::INFO.into()).from_env_lossy();

tracing_subscriber::fmt().json().with_target(true).with_env_filter(env_filter).init();

opentelemetry::global::set_meter_provider(meter_provider.clone());
if let Some(meter_provider) = &meter_provider {
opentelemetry::global::set_meter_provider(meter_provider.clone());
}

meter_provider
}
Loading
Loading