moonbitstack/moontel/otlp does not have a README file

    Encoding

    pub(open) trait Encoding {
    fn format(Self) -> BodyFormat
    fn traces(Self, Array[
    SpanData
    ], Resource, Scope) -> Bytes
    fn logs(Self, Array[
    LogRecord
    ], Resource, Scope) -> Bytes
    fn metrics(Self, Array[Metric], Resource, Scope) -> Bytes
    fn rejected(Self, Bytes) -> Int?
    }

    How one request body is written.

    An exporter needs four things from an encoding and they belong together: the encoder, the Content-Type the body is sent under, and the way a reply's count of refused records is read. All three move with the encoding, so a transport cannot end up announcing one and carrying the other.

    This package writes the JSON mapping and nothing else. Binary protobuf is its own package, otlp/binary, because it needs the protocol's schema and a runtime to walk it; a program that sends JSON should not compile either.

    MetricSink

    pub(open) trait MetricSink {
    fn accept(Self, Metric) -> Unit
    }

    Where finished metrics go.

    The trace and log packages leave the same seam open, and for the same reason: printing a point, keeping it in memory and posting it to a collector are the caller's business. A metric is a whole family — a name, its kind and every series under it — so one call carries all of it, and an implementation that talks to the network should queue rather than block.

    Transport

    pub(open) trait Transport {
    async fn send(Self, Request) -> Reply raise Failed
    }

    Sends one request and hands back the reply.

    The seam that keeps this package free of a transport: an implementation over sockets is otlp/http, and a test supplies one that answers from a script. A transport raises Failed only when the request produced no reply; an HTTP error status is a reply and belongs in Reply::status.

    Failed

    pub(all) suberror Failed {
    Unreachable(reason~ : String)
    } derive(
    Debug
    )

    The one way an export can fail: no reply at all.

    A reply that says no is not an error here — it is a status code the retry rules read. What has no status is a connection that never answered, which is why this type carries a reason string rather than a number.

    Failed::to_repr

    Backoff

    pub(all) struct Backoff {
    initial : Int64
    multiplier : Double
    maximum : Int64
    jitter : Double
    } derive(Eq,
    Debug
    )

    How long to wait before trying a request again.

    The specification requires an exponential backoff with jitter so that a fleet of exporters does not return to a recovering collector in step. The numbers are the ones the reference implementations settled on: five seconds at first, growing by half each time, never more than thirty, and a fifth of the wait spread either way by the caller's own randomness.

    Backoff::delay

    fn Backoff::delay(self : Backoff, attempt : Int, rand : (Int) -> Bytes) -> Int64

    The wait before attempt number attempt, counting the first retry as one.

    rand is the caller's randomness: four octets are read for the jitter, and a generator that returns anything else leaves the wait unjittered rather than making the exporter fail. A delay is a scheduling decision, not a reason to drop telemetry.

    Backoff::equal

    fn Backoff::equal(Backoff, Backoff) -> Bool

    Backoff::new

    fn Backoff::new(initial? : Int64, multiplier? : Double, maximum? : Int64, jitter? : Double) -> Backoff

    A backoff with any of the four values changed.

    Backoff::not_equal

    fn Backoff::not_equal(x : Backoff, y : Backoff) -> Bool

    Backoff::to_repr

    Batch

    pub struct Batch[T] {
    items :
    Ref
    [Array[T]]
    since :
    Ref
    [Int64]
    policy : BatchPolicy
    dropped :
    Ref
    [Int]
    }

    Records waiting to be sent, and how many were refused because the queue was full.

    A record is pushed on the task that produced it and taken by the task that exports, so the two never block each other: a full queue drops rather than waits, because telemetry that costs a request its latency is worse than telemetry that is missing.

    Batch::dropped

    fn[T] Batch::dropped(self : Batch[T]) -> Int

    How many records the queue refused since it was made.

    Batch::due

    fn[T] Batch::due(self : Batch[T], at~ : Int64) -> Array[T]

    What is ready to send at at: a full batch whatever the clock says, otherwise everything that has waited the whole delay, and nothing before either.

    Batch::len

    fn[T] Batch::len(self : Batch[T]) -> Int

    How many records are waiting.

    Batch::new

    fn[T] Batch::new(policy? : BatchPolicy) -> Batch[T]

    An empty queue following policy.

    Batch::push

    fn[T] Batch::push(self : Batch[T], item : T, at~ : Int64) -> Unit

    Add one record, arriving at at. A full queue refuses it and counts it.

    Batch::take_all

    fn[T] Batch::take_all(self : Batch[T], at~ : Int64) -> Array[T]

    Everything waiting, however recently it arrived. This is what a forced flush sends.

    BatchPolicy

    pub(all) struct BatchPolicy {
    queue_limit : Int
    batch : Int
    delay : Int64
    timeout : Int64
    } derive(Eq,
    Debug
    )

    How much telemetry one request carries and how long a record may wait.

    The numbers are the OpenTelemetry SDK's batch processor defaults: a queue of 2048, at most 512 records per request, a request every five seconds, and thirty seconds for one request to complete. They are sized for a service that would rather lose telemetry than memory, which is why an overfull queue drops the newest record and counts it.

    BatchPolicy::equal

    fn BatchPolicy::equal(BatchPolicy, BatchPolicy) -> Bool

    BatchPolicy::new

    fn BatchPolicy::new(queue_limit? : Int, batch? : Int, delay? : Int64, timeout? : Int64) -> BatchPolicy

    A policy with any of the four values changed.

    BatchPolicy::not_equal

    fn BatchPolicy::not_equal(x : BatchPolicy, y : BatchPolicy) -> Bool

    BodyFormat

    pub(all) enum BodyFormat {
    Json
    Protobuf
    }

    Which of the two OTLP encodings a request body uses.

    The protocol defines binary protobuf and the JSON mapping of it, and says an exporter must be able to send protobuf and may send JSON. Both are here; the JSON one is what this library sent first, and it is still the default because a body a person can read in a log is worth something while a library is young.

    BodyFormat::media_type

    fn BodyFormat::media_type(self : BodyFormat) -> String

    The Content-Type a body of this format is sent under.

    BodyLimits

    pub(all) struct BodyLimits {
    request : Int
    response : Int
    }

    How much body this side will send in one request, and how much of a reply it will read.

    Both bounds are the protocol's. OTLP's "OTLP/HTTP Request" section says a client SHOULD limit the body it sends — 64 MiB is the recommended default — and MUST NOT make the request when the limit is exceeded. Its "OTLP/HTTP Response" section says a client MUST limit what it parses back, recommends 4 MiB, and calls a reply over the limit a failure that retrying cannot fix.

    A program that sends large attribute values or long log bodies raises request; one that talks to a collector which answers with a page of diagnostics lowers response.

    DataPoint

    pub(all) struct DataPoint {
    attributes : Array[(String,
    Value
    )]
    start : Int64?
    time : Int64
    point : Point
    exemplars : Array[Exemplar]
    flags : Int
    } derive(Eq,
    Debug
    )

    A metric named value, taken at time, carrying the attributes that say which series it belongs to.

    DataPoint::equal

    fn DataPoint::equal(DataPoint, DataPoint) -> Bool

    DataPoint::not_equal

    fn DataPoint::not_equal(x : DataPoint, y : DataPoint) -> Bool

    Exemplar

    pub(all) struct Exemplar {
    time : Int64
    value : Double
    attributes : Array[(String,
    Value
    )]
    trace_id : Bytes?
    span_id : Bytes?
    } derive(Eq,
    Debug
    )

    A sample kept beside an aggregate: one measurement, when it was taken, and the trace it was taken in, so a reader can jump from a slow bucket to the request that filled it.

    Exemplar::equal

    fn Exemplar::equal(Exemplar, Exemplar) -> Bool

    Exemplar::not_equal

    fn Exemplar::not_equal(x : Exemplar, y : Exemplar) -> Bool

    Exemplar::to_repr

    JsonEncoding

    pub struct JsonEncoding {
    }

    The protobuf JSON mapping, which is what this package writes on its own.

    LogsExporter

    pub struct LogsExporter {
    outbox : Outbox[
    LogRecord
    ]
    }

    Log export: a @log.Sink that queues records and posts them to /v1/logs.

    LogsExporter::accept

    LogsExporter::flush

    async fn LogsExporter::flush(self : LogsExporter) -> Report

    Send everything queued.

    LogsExporter::new

    fn LogsExporter::new(transport~ : &Transport, endpoint? : String, headers? : Array[(String, String)], resource? : Resource, scope? : Scope, policy? : BatchPolicy, encoding? : &Encoding, limits? : BodyLimits, backoff? : Backoff, attempts? : Int, now~ : () -> Int64, rand~ : (Int) -> Bytes, pause? : async (Int64) -> Unit) -> LogsExporter

    An exporter posting log records to endpoint.

    LogsExporter::run

    async fn LogsExporter::run(self : LogsExporter) -> Unit

    Keep the exporter running, waking every policy.delay.

    LogsExporter::stats

    fn LogsExporter::stats(self : LogsExporter) -> Stats

    What this exporter has done so far.

    LogsExporter::tick

    async fn LogsExporter::tick(self : LogsExporter) -> Report

    Send what is due now.

    LogsExporter::waiting

    fn LogsExporter::waiting(self : LogsExporter) -> Int

    How many records are waiting.

    Metric

    pub(all) struct Metric {
    name : String
    description : String
    unit : String
    kind : MetricKind
    points : Array[DataPoint]
    } derive(Eq,
    Debug
    )

    One metric and the points it was measured at.

    Metric::equal

    fn Metric::equal(Metric, Metric) -> Bool

    Metric::not_equal

    fn Metric::not_equal(x : Metric, y : Metric) -> Bool

    Metric::to_repr

    MetricKind

    pub(all) enum MetricKind {
    Gauge
    Sum(temporality~ : Temporality, monotonic~ : Bool)
    Histogram(temporality~ : Temporality)
    ExponentialHistogram(temporality~ : Temporality)
    Summary
    } derive(Eq,
    Debug
    )

    Which OTLP instrument a metric is exported as.

    A counter and a monotonic sum are the same thing to a reader — a number that only goes up — so one variant carries both the temporality and the monotonic bit rather than four variants spelling out the combinations.

    MetricKind::equal

    fn MetricKind::equal(MetricKind, MetricKind) -> Bool

    MetricKind::not_equal

    fn MetricKind::not_equal(x : MetricKind, y : MetricKind) -> Bool

    MetricRecorder

    pub struct MetricRecorder {
    metrics :
    Ref
    [Array[Metric]]
    }

    Keeps metrics in memory, in the order they arrived.

    For tests and for a program that wants to inspect a collection before it decides where to send it. A service wants a sink that ships the points off the process, because this one grows without bound.

    MetricRecorder::accept

    fn MetricRecorder::accept(self : MetricRecorder, metric : Metric) -> Unit

    MetricRecorder::len

    fn MetricRecorder::len(self : MetricRecorder) -> Int

    How many metrics are kept.

    MetricRecorder::metrics

    fn MetricRecorder::metrics(self : MetricRecorder) -> Array[Metric]

    The metrics kept so far, oldest first.

    MetricRecorder::new

    An empty recorder.

    MetricRecorder::take

    The metrics kept so far, and an empty recorder afterwards.

    MetricsExporter

    pub struct MetricsExporter {
    outbox : Outbox[Metric]
    }

    Metric export: posts what a collector hands it to /v1/metrics.

    A producer can be either the meter package, which accumulates the instruments the specification defines and observes, or otlp/p8s, which reads the snapshot of a registry a program already keeps. Both hand this exporter the same Metric shape, so the wire format, the batching and the resource are one implementation no matter who counted.

    MetricsExporter::accept

    fn MetricsExporter::accept(self : MetricsExporter, metric : Metric) -> Unit

    MetricsExporter::flush

    async fn MetricsExporter::flush(self : MetricsExporter) -> Report

    Send everything queued.

    MetricsExporter::new

    fn MetricsExporter::new(transport~ : &Transport, endpoint? : String, headers? : Array[(String, String)], resource? : Resource, scope? : Scope, policy? : BatchPolicy, encoding? : &Encoding, limits? : BodyLimits, backoff? : Backoff, attempts? : Int, now~ : () -> Int64, rand~ : (Int) -> Bytes, pause? : async (Int64) -> Unit) -> MetricsExporter

    An exporter posting metrics to endpoint.

    MetricsExporter::record

    fn MetricsExporter::record(self : MetricsExporter, metric : Metric) -> Unit

    Queue one metric. A metric is a whole family — a name, its kind and all of its points — so one call carries every series that shares a name.

    MetricsExporter::run

    async fn MetricsExporter::run(self : MetricsExporter) -> Unit

    Keep the exporter running, waking every policy.delay.

    MetricsExporter::stats

    What this exporter has done so far.

    MetricsExporter::tick

    async fn MetricsExporter::tick(self : MetricsExporter) -> Report

    Send what is due now.

    MetricsExporter::waiting

    fn MetricsExporter::waiting(self : MetricsExporter) -> Int

    How many metrics are waiting.

    Outbox

    pub struct Outbox[T] {
    transport : &Transport
    url : String
    headers : Array[(String, String)]
    encode : (Array[T]) -> Bytes
    encoding : &Encoding
    policy : BatchPolicy
    limits : BodyLimits
    backoff : Backoff
    attempts : Int
    pause : async (Int64) -> Unit
    now : () -> Int64
    rand : (Int) -> Bytes
    batch : Batch[T]
    lock :
    Mutex

    stats :
    Ref
    [Stats]
    }

    The part of an exporter that does not depend on which signal it carries: the queue, the request, the retry rules and the clock.

    One engine for the three signals, because they differ only in their path and their body: three copies of the retry loop would be three places for the Retry-After rule to drift.

    Point

    pub(all) enum Point {
    AsDouble(Double)
    AsInt(Int64)
    Buckets(bounds~ : Array[Double], counts~ : Array[Int64], sum~ : Double, count~ : Int64, min~ : Double?, max~ : Double?)
    ExponentialBuckets(scale~ : Int, zero_count~ : Int64, positive_offset~ : Int, positive_counts~ : Array[Int64], negative_offset~ : Int, negative_counts~ : Array[Int64], sum~ : Double, count~ : Int64, min~ : Double?, max~ : Double?)
    Quantiles(quantiles~ : Array[(Double, Double)], sum~ : Double, count~ : Int64)
    } derive(Eq,
    Debug
    )

    One measurement of one point.

    The four shapes are the four ways OTLP carries a value: a double for a gauge or a sum, an integer for a counter a producer keeps whole, a set of buckets for a histogram, and quantiles for a summary. Which one a metric uses is fixed by its kind, not by the point.

    Point::equal

    fn Point::equal(Point, Point) -> Bool

    Point::not_equal

    fn Point::not_equal(x : Point, y : Point) -> Bool

    Point::to_repr

    Reply

    pub(all) struct Reply {
    status : Int
    headers : Array[(String, String)]
    body : Bytes
    }

    What the server said back. The body is bytes for the same reason: a reply to a protobuf request is protobuf.

    Reply::header

    fn Reply::header(self : Reply, name : String) -> String?

    One header of the reply, matched without regard to case, or None.

    Reply::rejected

    fn Reply::rejected(self : Reply) -> Int?

    How many records of this signal the server said it refused, from the partialSuccess field of the reply body.

    A partial success is an accepted request: the specification says the client MUST NOT retry it, and it says the count is what tells an operator that some data was turned away. A body that is not JSON, or carries no partialSuccess, answers None rather than zero, because "said nothing" and "refused none" differ. Inside one, a count left out is zero: the JSON mapping omits zeros.

    Reply::retry_after

    fn Reply::retry_after(self : Reply, now~ : Int64) -> Int64?

    How long the server asked to be left alone, in milliseconds, from the Retry-After header.

    The header carries either a number of seconds or an HTTP date, and both are read here: a server that says "2026-11-05T10:00:00Z" means the same thing as one that says "30", just less often. A date that has already passed is zero.

    Reply::text

    fn Reply::text(self : Reply) -> String

    The reply body read as text, which is what a JSON reply is. Bytes that are not UTF-8 come back with the bad sequences replaced rather than as an error: a body that cannot be read is a body with nothing to say, and the status code already said the important part.

    Report

    pub(all) struct Report {
    delivered : Int
    rejected : Int
    dropped : Int
    attempts : Int
    } derive(Eq,
    Debug
    )

    What one flush did, counted in records.

    delivered and rejected are what the server took and what it said it would not; dropped is what this side gave up on, because the queue was full or the attempts ran out; attempts is how many requests were made, which is the only way a test can see that a retry happened at all.

    Report::equal

    fn Report::equal(Report, Report) -> Bool

    Report::not_equal

    fn Report::not_equal(x : Report, y : Report) -> Bool

    Report::to_repr

    Request

    pub(all) struct Request {
    url : String
    headers : Array[(String, String)]
    body : Bytes
    }

    One OTLP request: where it goes, what it carries, and the headers that authenticate it.

    The body is bytes because OTLP has two encodings and only one of them is text: the exporter picks the encoding and the transport carries what it was given.

    Resource

    pub(all) struct Resource {
    attributes : Array[(String,
    Value
    )]
    } derive(Eq,
    Debug
    )

    What produced the telemetry: the service, the host, the deployment.

    Every signal carries the same resource, because it is what makes a span, a metric point and a log line from one process recognisable as coming from one place. The specification requires service.name to be present, so the default here is the one it names for a program that never set one.

    Resource::equal

    fn Resource::equal(Resource, Resource) -> Bool

    Resource::get

    fn Resource::get(self : Resource, key : String) ->
    Value
    ?

    The value under key, or None when the resource does not carry it.

    Resource::new

    fn Resource::new(attributes~ : Array[(String,
    Value
    )]) -> Resource

    A resource with exactly these attributes.

    Resource::not_equal

    fn Resource::not_equal(x : Resource, y : Resource) -> Bool

    Resource::of_service

    fn Resource::of_service(name : String) -> Resource

    A resource with service.name set to name and nothing else.

    Resource::to_repr

    Resource::with_attribute

    fn Resource::with_attribute(self : Resource, key : String, value :
    Value
    ) -> Resource

    The same resource with one more attribute, replacing a value under the same key.

    Scope

    pub(all) struct Scope {
    name : String
    version : String?
    attributes : Array[(String,
    Value
    )]
    } derive(Eq,
    Debug
    )

    The instrumentation scope: which library in the program produced the telemetry, and at which version. A backend groups by it, which is how a reader can tell the spans this library made from the ones a framework made.

    Scope::equal

    fn Scope::equal(Scope, Scope) -> Bool

    Scope::new

    fn Scope::new(name~ : String, version? : String, attributes? : Array[(String,
    Value
    )]) -> Scope

    A scope with this name, and a version when the caller knows one.

    Scope::not_equal

    fn Scope::not_equal(x : Scope, y : Scope) -> Bool

    Scope::to_repr

    Stats

    pub(all) struct Stats {
    delivered : Int64
    rejected : Int64
    dropped : Int64
    refused : Int64
    requests : Int64
    } derive(Eq,
    Debug
    )

    Everything one exporter has done since it was made.

    Stats::equal

    fn Stats::equal(Stats, Stats) -> Bool

    Stats::not_equal

    fn Stats::not_equal(x : Stats, y : Stats) -> Bool

    Stats::to_repr

    Temporality

    pub(all) enum Temporality {
    Delta
    Cumulative
    } derive(Eq,
    Debug
    )

    Whether a data point counts from the start of the process or since the last export. OTLP names the two AGGREGATION_TEMPORALITY_DELTA (1) and AGGREGATION_TEMPORALITY_CUMULATIVE (2), and a reader has to know which one it is looking at before it can add two points together.

    Temporality::equal

    fn Temporality::equal(Temporality, Temporality) -> Bool

    Temporality::not_equal

    fn Temporality::not_equal(x : Temporality, y : Temporality) -> Bool

    TraceExporter

    pub struct TraceExporter {
    outbox : Outbox[
    SpanData
    ]
    }

    Span export: a @trace.Sink that queues what the tracer finishes and posts it to /v1/traces.

    The sink answers immediately, so a request never waits for a collector; the request itself happens on whichever task calls flush or run.

    TraceExporter::accept

    TraceExporter::flush

    async fn TraceExporter::flush(self : TraceExporter) -> Report

    Send everything queued and wait for the requests to finish.

    TraceExporter::new

    fn TraceExporter::new(transport~ : &Transport, endpoint? : String, headers? : Array[(String, String)], resource? : Resource, scope? : Scope, policy? : BatchPolicy, encoding? : &Encoding, limits? : BodyLimits, backoff? : Backoff, attempts? : Int, now~ : () -> Int64, rand~ : (Int) -> Bytes, pause? : async (Int64) -> Unit) -> TraceExporter

    An exporter posting spans to endpoint (http://localhost:4318 is the protocol's default) through transport.

    headers is where authentication goes: an API key for a hosted backend, an organisation and a project for one that wants them. resource describes the program and is sent with every request.

    TraceExporter::run

    async fn TraceExporter::run(self : TraceExporter) -> Unit

    Keep the exporter running: wake every policy.delay, send what is due, and never return.

    A program starts this in its own task and lets its task group cancel it on the way out; the last flush belongs to the shutdown path, where the caller can wait for it.

    TraceExporter::stats

    fn TraceExporter::stats(self : TraceExporter) -> Stats

    What this exporter has done so far.

    TraceExporter::tick

    async fn TraceExporter::tick(self : TraceExporter) -> Report

    Send what is due now.

    TraceExporter::waiting

    fn TraceExporter::waiting(self : TraceExporter) -> Int

    How many spans are waiting.

    Verdict

    pub(all) enum Verdict {
    Accepted
    Rejected
    Retry
    } derive(Eq,
    Debug
    )

    What a reply means for the request that produced it.

    The three answers are the OTLP protocol's own: an acceptance, a refusal that retrying cannot fix, and a refusal that only time can. Getting the third one wrong is how an exporter either gives up on a busy collector or hammers a broken one.

    Verdict::equal

    fn Verdict::equal(Verdict, Verdict) -> Bool

    Verdict::not_equal

    fn Verdict::not_equal(x : Verdict, y : Verdict) -> Bool

    Verdict::to_repr

    encode_logs

    fn encode_logs(records : Array[
    LogRecord
    ], resource? : Resource, scope? : Scope) -> String

    Encode log records as an OTLP/JSON ExportLogsServiceRequest, the body POST /v1/logs takes.

    Records are grouped by the scope that wrote them, because a scope belongs to the request rather than to a record: one exporter can carry the lines of several loggers and the backend still sees which one wrote what.

    encode_metrics

    fn encode_metrics(metrics : Array[Metric], resource? : Resource, scope? : Scope) -> String

    Encode metrics as an OTLP/JSON ExportMetricsServiceRequest, the body POST /v1/metrics takes.

    encode_traces

    fn encode_traces(spans : Array[
    SpanData
    ], resource? : Resource, scope? : Scope) -> String

    Encode finished spans as an OTLP/JSON ExportTraceServiceRequest.

    The body is what POST /v1/traces takes: one resource, one scope, and the spans. Field names are the lowerCamelCase of the protobuf schema, ids are hexadecimal and timestamps are decimal strings, which is the mapping the OTLP specification fixes for JSON.

    json

    let json : &Encoding

    The JSON encoding, ready to hand to an exporter.

    kind_code

    The number OTLP gives a span kind: SPAN_KIND_INTERNAL is 1, and the four kinds that describe a position in a distributed call follow it.

    moonbit_scope

    let moonbit_scope : Scope

    The scope this library exports under.

    no_stats

    let no_stats : Stats

    Nothing sent yet.

    otlp_backoff

    let otlp_backoff : Backoff

    The default: 5s, times 1.5, capped at 30s, with 20% jitter.

    otlp_batch

    let otlp_batch : BatchPolicy

    The defaults, from the SDK specification's batch processor table.
    let recommended_body_limits : BodyLimits

    The bounds the specification recommends: 64 MiB out, 4 MiB back.

    retry_after_milliseconds

    fn retry_after_milliseconds(value : StringView, now~ : Int64) -> Int64?

    The wait a Retry-After value asks for, in milliseconds, or None when the value is neither a number of seconds nor an HTTP date.

    now is milliseconds since the Unix epoch, which is what a date has to be compared against. A date in the past answers zero rather than a negative wait: the server is telling this client to come back now.

    temporality_code

    fn temporality_code(temporality : Temporality) -> Int

    unknown_service

    let unknown_service : Resource

    The resource a program that has not described itself exports under: service.name is unknown_service, which is what the specification asks for rather than a guess at the binary's name.

    verdict

    fn verdict(status : Int) -> Verdict

    The verdict for a status code.

    The four retryable codes are the specification's table and nothing else: every other 4xx and 5xx MUST NOT be retried, and a 3xx that reached this point came from a client that was told not to follow it.