bext-plugin-api 0.2.0

Plugin trait definitions and shared types for bext — the public ABI for plugin authors
Documentation
//! Tracer capability trait for distributed-tracing exporters.
//!
//! A `Tracer` plugin turns span events produced by the bext runtime (and
//! other plugins) into whatever wire format its backend expects — OTLP,
//! Datadog, Honeycomb, line-oriented stdout for development, and so on.
//! See `plan/ecosystem/02-capabilities.md` for the design rationale and
//! the list of reference implementations landing in E1.
//!
//! # Design rules
//!
//! - **No `opentelemetry` crate types in the trait.** Trace IDs, span IDs,
//!   attribute values, kinds, and status codes are all plain data. A non-otel
//!   implementation (like `@bext/tracer-stdout`) must be able to satisfy
//!   the trait without pulling in `opentelemetry` as a transitive dep. An
//!   OTLP-based implementation maps these types 1:1 into the otel SDK.
//! - **Aligned with OpenTelemetry semantic conventions.** IDs are the same
//!   widths as the OTel wire format (16 bytes trace id, 8 bytes span id),
//!   status codes match OTel's `Unset`/`Ok`/`Error`, span kinds match
//!   OTel's `Internal`/`Server`/`Client`/`Producer`/`Consumer`, and
//!   attribute value types cover the OTel semantic-convention allowed set
//!   (string / bool / int / float / arrays). Plugin authors are expected
//!   to use the OTel semconv attribute keys (e.g. `http.request.method`,
//!   `http.response.status_code`); the trait does not enforce this.
//! - **Explicit parent context, not thread-local.** A span is started with
//!   an explicit `Option<SpanHandle>` parent, so callers control propagation
//!   and the trait stays async-runtime-agnostic. Thread-local propagation,
//!   if wanted, is a concern for a helper layer above this trait, not the
//!   trait itself.
//! - **Infallible recording, fallible flush.** `start_span` / `set_attribute`
//!   / `set_status` / `add_event` / `end_span` never return errors — a
//!   tracing failure must never break the caller. Only `flush` (which does
//!   real I/O) returns `Result`. Implementations that hit transient errors
//!   record them internally and surface them at flush time or via their
//!   own metrics.
//! - **Sync and object-safe.** Matches the rest of the plugin API: all
//!   methods are synchronous, there are no generic parameters on trait
//!   methods, and `SpanHandle` is a plain `Copy` value — so `dyn Tracer`
//!   works across the WASM ABI and the in-process host-function table
//!   alike.

/// An opaque handle returned by [`TracerPlugin::start_span`]. Passing a
/// `SpanHandle` back to the same tracer is how the runtime attaches
/// attributes, events, and status to an active span. Handles are cheap
/// `Copy` values — they are just the id pair the tracer already emits
/// on the wire.
///
/// A handle is only valid for the tracer that created it. Passing a
/// handle produced by one tracer to another tracer is a programmer
/// error; implementations should treat unknown handles as a no-op
/// rather than panicking.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SpanHandle {
    /// Trace id (W3C trace-context `trace-id`, 16 bytes big-endian).
    pub trace_id: [u8; 16],
    /// Span id (W3C trace-context `parent-id` / `span-id`, 8 bytes).
    pub span_id: [u8; 8],
}

impl SpanHandle {
    /// Invalid / "not sampled" handle. Tracer implementations return this
    /// from `start_span` when a sampling decision rejects the span; the
    /// runtime treats it as a no-op but still threads it through as the
    /// parent of any child spans so the shape of the caller's code does
    /// not change between sampled and non-sampled runs.
    pub const INVALID: SpanHandle = SpanHandle {
        trace_id: [0u8; 16],
        span_id: [0u8; 8],
    };

    /// Returns `true` if the handle is the invalid/no-op sentinel.
    pub fn is_invalid(&self) -> bool {
        self.trace_id == [0u8; 16] && self.span_id == [0u8; 8]
    }
}

/// OpenTelemetry-aligned span kind. Mirrors `opentelemetry::trace::SpanKind`
/// without depending on it. Used by backends to colour spans in the UI
/// and to apply kind-specific semantic conventions.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SpanKind {
    /// Default. Internal operation within an application.
    Internal,
    /// An incoming request the application is handling.
    Server,
    /// An outgoing request to another service.
    Client,
    /// Async producer (e.g. enqueue a message).
    Producer,
    /// Async consumer (e.g. process a message from a queue).
    Consumer,
}

impl Default for SpanKind {
    fn default() -> Self {
        SpanKind::Internal
    }
}

/// OpenTelemetry-aligned span status. Mirrors `opentelemetry::trace::Status`
/// without depending on it.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "code", rename_all = "snake_case")]
pub enum SpanStatus {
    /// The span has not yet been given an explicit status.
    Unset,
    /// The operation the span represents completed successfully.
    Ok,
    /// The operation failed. `description` is a human-readable explanation
    /// (per OTel, this field is only set when the status is `Error`).
    Error { description: String },
}

impl Default for SpanStatus {
    fn default() -> Self {
        SpanStatus::Unset
    }
}

/// Attribute value for spans and events. Matches the value types allowed
/// by the OpenTelemetry semantic-convention specification: string, bool,
/// signed 64-bit int, 64-bit float, and homogeneous arrays of each.
///
/// Kept as a plain enum (no `opentelemetry::Value` indirection) so that
/// non-otel backends can encode it directly without a mapping layer.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(untagged)]
pub enum AttrValue {
    String(String),
    Bool(bool),
    I64(i64),
    F64(f64),
    StringArray(Vec<String>),
    BoolArray(Vec<bool>),
    I64Array(Vec<i64>),
    F64Array(Vec<f64>),
}

impl From<&str> for AttrValue {
    fn from(s: &str) -> Self {
        AttrValue::String(s.to_owned())
    }
}

impl From<String> for AttrValue {
    fn from(s: String) -> Self {
        AttrValue::String(s)
    }
}

impl From<bool> for AttrValue {
    fn from(b: bool) -> Self {
        AttrValue::Bool(b)
    }
}

impl From<i64> for AttrValue {
    fn from(i: i64) -> Self {
        AttrValue::I64(i)
    }
}

impl From<u32> for AttrValue {
    fn from(i: u32) -> Self {
        AttrValue::I64(i as i64)
    }
}

impl From<f64> for AttrValue {
    fn from(f: f64) -> Self {
        AttrValue::F64(f)
    }
}

/// Arguments for [`TracerPlugin::start_span`]. A struct rather than a long
/// positional parameter list so the trait stays forward-compatible: new
/// optional fields can be added without breaking existing implementations
/// that use `..Default::default()`.
#[derive(Debug, Clone, Default)]
pub struct SpanStart<'a> {
    /// Span name. Should follow OTel semantic conventions (e.g., for a
    /// server span handling `GET /users/:id`, use `"GET /users/:id"`).
    pub name: &'a str,
    /// Span kind. Defaults to [`SpanKind::Internal`].
    pub kind: SpanKind,
    /// Parent span handle, or `None` for a root span. Non-`None` parents
    /// belonging to a different tracer are treated as unknown and the new
    /// span is rooted.
    pub parent: Option<SpanHandle>,
    /// Initial attributes set atomically with span creation. Equivalent
    /// to calling [`TracerPlugin::set_attribute`] for each pair after
    /// `start_span`, but gives implementations a chance to include them
    /// in the initial emission without a buffer flush.
    pub attributes: Vec<(String, AttrValue)>,
    /// Start time override, in nanoseconds since the Unix epoch. `None`
    /// means "use the tracer's current clock" — this is the common case.
    /// Set this when recording historical or externally-sourced spans.
    pub start_time_unix_nanos: Option<u64>,
}

/// A point-in-time event recorded inside a span. Events do not have
/// durations; they are the OTel equivalent of a structured log line
/// scoped to a span.
#[derive(Debug, Clone)]
pub struct SpanEvent<'a> {
    /// Event name (e.g. `"exception"`, `"cache.miss"`).
    pub name: &'a str,
    /// Event attributes. Semantic-convention keys apply here too; for
    /// example, `exception.message` / `exception.type` on an `"exception"`
    /// event.
    pub attributes: Vec<(String, AttrValue)>,
    /// Event time override, nanoseconds since the Unix epoch. `None` means
    /// "use the tracer's current clock".
    pub time_unix_nanos: Option<u64>,
}

/// A distributed-tracing exporter plugin.
///
/// Bext's observability hooks (and any plugin that wants to emit custom
/// spans) call into the active `TracerPlugin` through host functions.
/// There is at most one active `TracerPlugin` per site; multiple backends
/// are achieved by chaining (a fan-out tracer that forwards to several
/// children) rather than by running several implementations side-by-side.
///
/// Implementations must be cheap on the hot path. `start_span`,
/// `set_attribute`, `set_status`, `add_event`, and `end_span` run inline
/// with the operation they describe; they should buffer rather than
/// perform network I/O. Actual export happens during [`TracerPlugin::flush`],
/// which the runtime calls on an interval and at shutdown.
pub trait TracerPlugin: Send + Sync {
    /// Unique plugin identifier (e.g. `"tracer-otlp"`, `"tracer-stdout"`).
    fn name(&self) -> &str;

    /// Begin a new span and return a handle the caller can use to record
    /// further state on it. A handle equal to [`SpanHandle::INVALID`]
    /// means the span was not sampled; the caller should still thread it
    /// through as the parent of any children so that sampled/non-sampled
    /// codepaths stay structurally identical.
    fn start_span(&self, span: SpanStart<'_>) -> SpanHandle;

    /// Attach or overwrite a single attribute on a live span. Keys should
    /// follow the OTel semantic conventions (e.g. `"http.request.method"`,
    /// `"db.system"`). Calls against an unknown or invalid handle are a
    /// no-op.
    fn set_attribute(&self, span: SpanHandle, key: &str, value: AttrValue);

    /// Set the span's final status. Most backends treat the last call
    /// before [`TracerPlugin::end_span`] as authoritative. Calls against an
    /// unknown or invalid handle are a no-op.
    fn set_status(&self, span: SpanHandle, status: SpanStatus);

    /// Record a point-in-time event on a live span. Calls against an
    /// unknown or invalid handle are a no-op.
    fn add_event(&self, span: SpanHandle, event: SpanEvent<'_>);

    /// End a live span. `end_time_unix_nanos` is an optional override —
    /// pass `None` to use the tracer's current clock. After this call
    /// the handle must not be used again; implementations are free to
    /// recycle its id space.
    fn end_span(&self, span: SpanHandle, end_time_unix_nanos: Option<u64>);

    /// Flush any buffered spans to the backend. The runtime calls this
    /// on a periodic timer and once during graceful shutdown. A flush
    /// that cannot reach its backend should return an error string so
    /// the host can surface it through the observability layer; buffered
    /// spans remain the plugin's responsibility (drop, retry, or spill
    /// to disk, at the plugin's discretion).
    fn flush(&self) -> Result<(), String>;
}