open-standards/openpay/crates/op-orchestrator/src/telemetry.rs
Transaction Science a437feb8ab Parallel ingest: 9 issues' worth of work across openpay / smart-byte / eoc
openpay/
  compliance/                     PCI-DSS readiness package (scope-map,
                                  pci-zero-architecture, hsm-kms-guidance,
                                  qsa-checklist), citing PCI-DSS v4.0.1
                                  requirements. (#1)
  examples/pci-zero/              Runnable example demonstrating a deployment
                                  where merchant code never touches a PAN. (#1)
  crates/op-core/                 Tokenized<Card> typestate + NetworkToken
                                  + CardNetwork + Cryptogram. (#2)
  crates/op-rails-card/network_token/
                                  NetworkTokenProvider trait + VTS adapter
                                  + MDES adapter, with deterministic stubs
                                  behind a 'live' feature flag. (#2)
  crates/op-driver-sdk/conformance/network_token.rs
                                  Conformance harness for third-party
                                  NetworkTokenProvider implementations. (#2)
  crates/op-cli/src/ledger/       'openpay ledger as-of' subcommand for
                                  bi-temporal queries. (#3)
  crates/op-orchestrator/src/telemetry.rs
                                  OpenTelemetry span emission per
                                  Payment<S> transition with semantic-
                                  conventions tags. (#3)
  docs/observability/             Grafana dashboard JSON + wiring guide
                                  for the bi-temporal ledger lens. (#3)

smart-byte/
  smart-byte-rs/                  Rust reference implementation: envelope
                                  encode/decode (canonical CBOR + BLAKE3
                                  content addressing), Ed25519 sign +
                                  verify, lockstep BFT commit MVP, Iroh
                                  transport scaffold, CLI. (#4)
  spec/identity_and_key_rotation.md
                                  Adopts KERI SAIDs + key-event log
                                  format; explicitly does not adopt ACDC
                                  chaining wholesale. (#5)
  conformance/v1/                 21 test vectors covering canonicalisation,
                                  SAID computation, signature verification,
                                  lockstep commit, gossip propagation.
                                  CI gate enforces spec/vector coupling. (#6)

eoc/
  eoc-rs/                         Rust + WASM reference implementation:
                                  4-stage cascade (cache -> kv -> graph ->
                                  neural), joule counters via RAPL / NVML /
                                  powermetrics, CLI, wasm-bindgen surface.
                                  18 tests pass. (#7)
  eoc-bench/                      Benchmark harness, MT-Bench and Router
                                  case sets, 'joules per MT-Bench point'
                                  metric, 'eoc-bench run | compare' CLI. (#8)
  standards/sci-for-ai-submission.md
                                  Submission proposal mapping EOC's
                                  receipt schema to GSF SCI-for-AI. (#9)
  cookbook/                       HuggingFace-style cookbook (markdown +
                                  runnable Python) demonstrating EOC's
                                  cascade with HF AI Energy Score. (#9)
  docs/bench-v0.md                Reproducibility doc for eoc-bench.

Workspace state:
- openpay clippy -- -D warnings: clean across all touched crates
- smart-byte-rs cargo test --workspace + clippy: clean
- eoc-rs cargo test --workspace + clippy: clean (18 tests)
- eoc-bench cargo check --workspace + clippy + 15 tests: clean

Rust 1.95.0 edition 2024 pinned at the open-standards root via
rust-toolchain.toml; every workspace inherits.

Closes #1 #2 #3 #4 #5 #6 #7 #8 #9.
2026-05-23 13:24:05 -04:00

203 lines
7.9 KiB
Rust

//! OpenTelemetry instrumentation for the typestate machine.
//!
//! This module is the **lens**, not the machine. It exposes:
//!
//! - [`instrument_transition`] — emit a `tracing::Span` tagged with
//! the semantic-conventions attributes operators expect on every
//! `Payment<S>` state transition: `payment.id`, `payment.state.from`,
//! `payment.state.to`, `payment.rail`, `payment.amount.minor`,
//! `payment.amount.currency`. Callers wrap their transition body in
//! `let _g = instrument_transition(...).entered();`.
//! - [`init_telemetry`] — opt-in initializer that wires the
//! `tracing` subscriber to an OTLP exporter when the `telemetry`
//! feature is enabled. Without the feature, it returns `Ok(())`
//! silently so the same call site compiles in both modes.
//!
//! ## Why a helper instead of inline `tracing::span!`?
//!
//! The orchestrator's state machine lives across multiple files
//! (`engine.rs`, `circuit_breaker.rs`, the rail adapters). Centralising
//! the attribute names here means:
//!
//! 1. Every transition span carries the same schema — Grafana
//! dashboards built on this schema don't break when a new
//! transition is added.
//! 2. Semantic-conventions are documented in one place and reviewed
//! once.
//! 3. The follow-up issue that wires this in to every state
//! transition has a single function to call.
//!
//! ## Append-only by issue contract
//!
//! Per the spec, this module **does not modify any existing
//! orchestrator code**. It is additive: a sibling module exposing a
//! helper. The follow-up issue threads the helper through
//! `engine::Orchestrator::run` and the typestate-transition methods.
//!
//! ## Semantic conventions
//!
//! The attribute namespace `payment.*` follows the OpenTelemetry
//! semantic-conventions pattern (`http.*`, `db.*`, `messaging.*`).
//! Amounts are recorded as **minor units + currency** rather than
//! a formatted string so dashboards can aggregate without parsing.
use op_core::Money;
use tracing::{Level, Span, span};
/// Emit a structured span describing a single `Payment<S>` state
/// transition.
///
/// The span uses [`Level::INFO`] (transitions are first-class events,
/// not debug noise) and carries the following attributes:
///
/// | Attribute | Type | Meaning |
/// | -------------------------- | --------- | -------------------------------------------- |
/// | `payment.id` | string | Idempotency key / opaque payment id |
/// | `payment.state.from` | string | Source typestate (e.g. `Authorized`) |
/// | `payment.state.to` | string | Destination typestate (e.g. `Captured`) |
/// | `payment.rail` | string | Rail name (`card`, `a2a`, `crypto`) |
/// | `payment.amount.minor` | i64 | Amount in minor units (cents for USD) |
/// | `payment.amount.currency` | string | ISO 4217 alpha-3 currency code |
///
/// Callers entered the span around the transition body:
///
/// ```ignore
/// use op_core::Money;
/// use op_orchestrator::telemetry::instrument_transition;
///
/// let amount = Money::from_minor(1_999, op_core::Currency::USD);
/// let span = instrument_transition("pay_123", "Authorized", "Captured", "card", &amount);
/// let _guard = span.enter();
/// // ... do the transition ...
/// ```
///
/// When no subscriber is registered the span is a no-op (zero cost
/// modulo a few atomic loads). When a subscriber is registered the
/// attributes appear on every emitted event inside the guard's scope.
#[must_use]
pub fn instrument_transition(
payment_id: &str,
from_state: &str,
to_state: &str,
rail: &str,
amount: &Money,
) -> Span {
span!(
Level::INFO,
"payment.transition",
payment.id = payment_id,
payment.state.from = from_state,
payment.state.to = to_state,
payment.rail = rail,
payment.amount.minor = amount.minor_units,
payment.amount.currency = amount.currency.code(),
)
}
/// Errors that [`init_telemetry`] can surface.
#[derive(Debug, thiserror::Error)]
pub enum TelemetryError {
/// OTLP exporter construction failed (bad endpoint, missing
/// tonic features, etc.).
#[error("failed to build OTLP exporter: {0}")]
ExporterBuild(String),
/// Subscriber registration failed (another global subscriber is
/// already installed).
#[error("failed to install global tracing subscriber: {0}")]
SubscriberInstall(String),
}
/// Result alias for telemetry initialization.
pub type TelemetryResult<T> = std::result::Result<T, TelemetryError>;
// ============================================================
// `telemetry` feature ON — wire `tracing` to an OTLP exporter.
// ============================================================
/// Initialize the OpenTelemetry pipeline.
///
/// When the `telemetry` feature is **enabled**, this:
///
/// 1. Builds an OTLP gRPC exporter pointed at `otlp_endpoint`
/// (defaults to `http://127.0.0.1:4317` if `None`).
/// 2. Wraps it in a batch span processor.
/// 3. Installs a `tracing-subscriber` layer that forwards every
/// `tracing` span (including the ones emitted by
/// [`instrument_transition`]) to the exporter.
///
/// When the feature is **disabled**, this is a `Ok(())` no-op so
/// the same call site compiles in both modes.
///
/// # Errors
/// Returns [`TelemetryError`] if the exporter or subscriber install
/// fails.
#[cfg(feature = "telemetry")]
pub fn init_telemetry(otlp_endpoint: Option<&str>) -> TelemetryResult<()> {
use opentelemetry::trace::TracerProvider as _;
use opentelemetry_otlp::WithExportConfig;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
let endpoint = otlp_endpoint.unwrap_or("http://127.0.0.1:4317");
let exporter = opentelemetry_otlp::SpanExporter::builder()
.with_tonic()
.with_endpoint(endpoint)
.build()
.map_err(|e| TelemetryError::ExporterBuild(e.to_string()))?;
let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder()
.with_batch_exporter(exporter)
.build();
let tracer = provider.tracer("op-orchestrator");
let otel_layer = tracing_opentelemetry::layer().with_tracer(tracer);
tracing_subscriber::registry()
.with(otel_layer)
.try_init()
.map_err(|e| TelemetryError::SubscriberInstall(e.to_string()))?;
Ok(())
}
// ============================================================
// `telemetry` feature OFF — no-op stub.
// ============================================================
/// Initialize the OpenTelemetry pipeline.
///
/// See the feature-enabled version above for the real documentation;
/// this is the stub used when the `telemetry` feature is **off**.
#[cfg(not(feature = "telemetry"))]
#[allow(unused_variables)]
pub fn init_telemetry(otlp_endpoint: Option<&str>) -> TelemetryResult<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use op_core::{Currency, Money};
#[test]
fn instrument_transition_emits_named_span() {
let amount = Money::from_minor(1_999, Currency::USD);
let span = instrument_transition("pay_abc", "Pending", "Authorized", "card", &amount);
// The span has the canonical name; subscribers downstream
// will see the attributes. We only assert the name here
// because attribute introspection is not stable in
// `tracing::Span`.
assert_eq!(span.metadata().map(|m| m.name()), Some("payment.transition"));
}
#[test]
fn init_telemetry_no_feature_is_ok() {
// With the `telemetry` feature off, init is a no-op and
// never fails. With the feature on, this test would only
// succeed if an OTLP collector is reachable, so we just
// assert the no-op contract.
#[cfg(not(feature = "telemetry"))]
{
assert!(init_telemetry(None).is_ok());
}
}
}