open-standards/openpay/docs/deploy/monitoring.md
Transaction Science 70ecd6caa0 Initial import — three open standards under one shelf
Transaction Science open standards monorepo. Each subdirectory is a
self-contained reference protocol with its own README and licence.

- openpay/    Apache-2.0  payment-acceptance stack (Rust, 26 crates)
- smart-byte/ CC-BY-4.0   content-addressed value carrier (spec)
- eoc/        CC-BY-4.0   energy-optimised compute (spec)

The wire format and the right to fork are public. The optional hosted
services are the offer; the protocols are not.
2026-05-23 07:58:09 -04:00

160 lines
5.8 KiB
Markdown

# OpenPay — observability
`op-server` uses `tracing` end-to-end. Every interesting operation
is wrapped in an instrumented span; spans carry structured fields
(amount, currency, driver, idempotency key, batch id, ...). What
you do with that output is a configuration choice.
## Log levels
Set via `RUST_LOG`, parsed by `tracing-subscriber::EnvFilter` in
`crates/op-server/src/main.rs`. Default when unset:
```
RUST_LOG=info,op_server=debug
```
Per-crate overrides work as you'd expect:
```
RUST_LOG=warn,op_server=debug,op_orchestrator=trace,op_webhook=debug
```
## JSON output for ingestion
For journald → loki / Datadog / Honeycomb / OpenTelemetry
collectors, you want newline-delimited JSON. The shipped `main.rs`
uses the default pretty subscriber and **does not** branch on
`OP_LOG_FORMAT`. Patch `main.rs` to read it:
```rust
// crates/op-server/src/main.rs
let want_json = std::env::var("OP_LOG_FORMAT")
.map(|v| v.eq_ignore_ascii_case("json"))
.unwrap_or(false);
let base = tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info,op_server=debug")),
);
if want_json {
base.json().init();
} else {
base.init();
}
```
(Add `tracing-subscriber` with the `json` feature in
`crates/op-server/Cargo.toml` if it isn't already pulled in.)
With `OP_LOG_FORMAT=json`, every line on stdout is a parseable JSON
object: timestamp, level, target, span path, fields. journald
preserves these; promtail / vector / fluent-bit picks them up
unchanged.
## Spans worth dashboarding
These are the `tracing::instrument` spans the workspace currently
emits. Grep `crates/op-*/src/` for `tracing::instrument` to find
them in source.
| Span | Where | Key fields |
|---|---|---|
| `orchestrator.run` | `op-orchestrator/src/engine.rs` | `idempotency_key`, `amount_minor`, `currency` |
| `orchestrator.resume` | `op-orchestrator/src/engine.rs` | `idempotency_key`, `driver`, `rail` |
| `webhook.dispatch` | `op-webhook/src/dispatcher.rs` | `event_id`, `event_type`, `payload_bytes` |
| `ledger.post_transaction` | `op-ledger/src/store.rs` | `tx_id`, `ledger_id`, `external_id` |
| `settlement.open_batch` | `op-settlement/src/engine.rs` | `currency`, `rail` |
| `settlement.add_entry` | `op-settlement/src/engine.rs` | `batch_id` |
| `settlement.close_batch` | `op-settlement/src/engine.rs` | `batch_id`, `dispute_adjustment_bps` |
| `reconciliation.reconcile` | `op-reconciliation/src/engine.rs` | `window_start`, `window_end`, `tx_count` |
Useful dashboards to build:
- **Orchestrator latency** — p50/p95/p99 duration of `orchestrator.run`, broken out by `currency`. Spikes correlate with rail issues.
- **Webhook delivery health** — rate of `webhook.dispatch` events grouped by `event_type` and outcome. Failures (visible at `WARN` or `ERROR` level inside the span) feed dunning on receiver outages.
- **Ledger throughput** — count of `ledger.post_transaction` per minute. The system-of-record write rate. A sudden drop while `orchestrator.run` keeps rolling means writes are stuck.
- **Settlement cycle time** — gap between `settlement.open_batch` and the matching `settlement.close_batch` (correlate on `batch_id`).
- **Reconciliation lag** — frequency of `reconciliation.reconcile` and the `tx_count` per run.
## Request tracing
`tower_http::trace::TraceLayer` is layered in `main.rs`. Every
HTTP request gets a span with `method`, `uri`, `status`, `latency`.
If you're running behind Caddy, correlate Caddy's access-log
`request_id` field with the request-scope span fields by adding a
header-extracting middleware — Axum's `tower-http::request_id`
will do it in a few lines.
## journald + Loki pipeline
If you're on systemd (the recommended deploy mode in
`openpay.service`):
```bash
journalctl -u openpay -f # tail live
journalctl -u openpay --since "1h ago" # last hour
journalctl -u openpay -p err # errors only
```
To ship to Loki, run `promtail` reading the systemd journal:
```yaml
# /etc/promtail/config.yml (excerpt)
scrape_configs:
- job_name: openpay
journal:
max_age: 12h
labels:
job: openpay
relabel_configs:
- source_labels: ['__journal__systemd_unit']
target_label: 'unit'
pipeline_stages:
- json:
expressions:
level: level
target: target
span: 'spans[0].name'
```
With `OP_LOG_FORMAT=json` set, the `json` pipeline stage parses
each line and exposes `level`, `target`, span names as Loki
labels — query by `{job="openpay", level="error"}` etc.
## Datadog / Honeycomb / generic OTLP
For OpenTelemetry export, drop `tracing-opentelemetry` into your
patched `main.rs`. The instrumented spans translate directly into
OTLP spans with attributes; correlation IDs (`idempotency_key`,
`batch_id`, `tx_id`) ride through as span attributes for free.
This is operator-side glue; the workspace deliberately doesn't
ship an opinionated OTLP setup.
## Health and readiness
Two endpoints, both ungated by auth (or behind `with_bypass_paths`
if you turned on `ApiKeyAuthLayer`):
```
GET /health → 200 always (liveness; "is the process up?")
GET /readiness → 200 if stores answer; 503 otherwise
```
Wire both into your load balancer / orchestrator. `/health` for
restart decisions, `/readiness` for "should we route traffic
here?" Caddy's `health_uri /health` in `Caddyfile.sample` uses the
first.
## Metrics?
There is no Prometheus / StatsD endpoint shipped today. The
`tracing` event stream is the source of truth — derive metrics
from it via `tracing-subscriber`'s metrics layer, or scrape your
log aggregator. If you need a `/metrics` endpoint badly enough to
add it, the right place is a new route in
`crates/op-server/src/routes.rs` backed by `metrics-exporter-prometheus`
and a few `metrics::counter!` calls sprinkled where the
`tracing::instrument` spans already sit.