// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// 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.
#derive(Eq, Debug)
pub(all) enum Temporality {
Delta
Cumulative
}
///|
pub extend Temporality with Eq::{equal, not_equal}
///|
pub extend Temporality with Debug::{to_repr}
///|
/// 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.
#derive(Eq, Debug)
pub(all) enum Point {
AsDouble(Double)
AsInt(Int64)
/// A histogram: the finite upper bounds of its buckets, one more count than
/// there are bounds — the last count is everything above the last bound, which is
/// how OTLP states the `+Inf` bucket.
Buckets(
bounds~ : Array[Double],
counts~ : Array[Int64],
sum~ : Double,
count~ : Int64,
min~ : Double?,
max~ : Double?
)
/// An exponential histogram: buckets whose width grows with the value, so a
/// measurement near zero can be told apart as finely as one near a million. A
/// bucket at index `i` covers `(2^(i/2^scale), 2^((i+1)/2^scale)]`, and only the
/// buckets in use are carried — `offset` is the index of the first one. The
/// negative side is mirrored, and measurements of exactly zero are counted
/// separately.
ExponentialBuckets(
scale~ : Int32,
zero_count~ : Int64,
positive_offset~ : Int32,
positive_counts~ : Array[Int64],
negative_offset~ : Int32,
negative_counts~ : Array[Int64],
sum~ : Double,
count~ : Int64,
min~ : Double?,
max~ : Double?
)
Quantiles(quantiles~ : Array[(Double, Double)], sum~ : Double, count~ : Int64)
}
///|
pub extend Point with Eq::{equal, not_equal}
///|
pub extend Point with Debug::{to_repr}
///|
/// 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.
#derive(Eq, Debug)
pub(all) struct Exemplar {
time : Int64
value : Double
attributes : Array[(String, @trace.Value)]
trace_id : Bytes?
span_id : Bytes?
}
///|
pub extend Exemplar with Eq::{equal, not_equal}
///|
pub extend Exemplar with Debug::{to_repr}
///|
/// A metric named `value`, taken at `time`, carrying the attributes that say which
/// series it belongs to.
#derive(Eq, Debug)
pub(all) struct DataPoint {
attributes : Array[(String, @trace.Value)]
/// When the accumulation began, when the producer knows. A delta point has no
/// start of its own, and OTLP leaves the field out rather than inventing one.
start : Int64?
time : Int64
point : Point
exemplars : Array[Exemplar]
flags : Int32
}
///|
pub extend DataPoint with Eq::{equal, not_equal}
///|
pub extend DataPoint with Debug::{to_repr}
///|
/// 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.
#derive(Eq, Debug)
pub(all) enum MetricKind {
Gauge
Sum(temporality~ : Temporality, monotonic~ : Bool)
Histogram(temporality~ : Temporality)
ExponentialHistogram(temporality~ : Temporality)
Summary
}
///|
pub extend MetricKind with Eq::{equal, not_equal}
///|
pub extend MetricKind with Debug::{to_repr}
///|
/// One metric and the points it was measured at.
#derive(Eq, Debug)
pub(all) struct Metric {
name : String
description : String
unit : String
kind : MetricKind
points : Array[DataPoint]
}
///|
pub extend Metric with Eq::{equal, not_equal}
///|
pub extend Metric with Debug::{to_repr}
///|
/// Encode metrics as an OTLP/JSON `ExportMetricsServiceRequest`, the body
/// `POST /v1/metrics` takes.
pub fn encode_metrics(
metrics : Array[Metric],
resource~ : Resource = unknown_service,
scope~ : Scope = moonbit_scope,
) -> String {
let w = JsonWriter::new()
w.open_object()
w.name("resourceMetrics")
w.open_array()
w.open_object()
w.name("resource")
write_resource(w, resource)
w.name("scopeMetrics")
w.open_array()
w.open_object()
w.name("scope")
write_scope(w, scope)
w.name("metrics")
w.open_array()
for metric in metrics {
write_metric(w, metric)
}
w.close_array()
w.close_object()
w.close_array()
w.close_object()
w.close_array()
w.close_object()
w.text()
}
///|
fn write_metric(w : JsonWriter, metric : Metric) -> Unit {
w.open_object()
w.name("name")
w.string(metric.name)
if metric.description != "" {
w.name("description")
w.string(metric.description)
}
if metric.unit != "" {
w.name("unit")
w.string(metric.unit)
}
match metric.kind {
Gauge => {
w.name("gauge")
write_points(w, metric.points)
}
Sum(temporality~, monotonic~) => {
w.name("sum")
w.open_object()
w.name("dataPoints")
write_data_points(w, metric.points)
w.name("aggregationTemporality")
w.raw(temporality_code(temporality).to_string())
if monotonic {
w.name("isMonotonic")
w.boolean(true)
}
w.close_object()
}
Histogram(temporality~) => {
w.name("histogram")
w.open_object()
w.name("dataPoints")
write_data_points(w, metric.points)
w.name("aggregationTemporality")
w.raw(temporality_code(temporality).to_string())
w.close_object()
}
ExponentialHistogram(temporality~) => {
w.name("exponentialHistogram")
w.open_object()
w.name("dataPoints")
write_data_points(w, metric.points)
w.name("aggregationTemporality")
w.raw(temporality_code(temporality).to_string())
w.close_object()
}
Summary => {
w.name("summary")
write_points(w, metric.points)
}
}
w.close_object()
}
///|
/// A gauge or a summary body, which is a data-point list and nothing else.
fn write_points(w : JsonWriter, points : Array[DataPoint]) -> Unit {
w.open_object()
w.name("dataPoints")
write_data_points(w, points)
w.close_object()
}
///|
fn write_data_points(w : JsonWriter, points : Array[DataPoint]) -> Unit {
w.open_array()
for point in points {
write_data_point(w, point)
}
w.close_array()
}
///|
fn write_data_point(w : JsonWriter, data : DataPoint) -> Unit {
w.open_object()
if !data.attributes.is_empty() {
w.name("attributes")
write_attributes(w, data.attributes)
}
match data.start {
Some(start) => {
w.name("startTimeUnixNano")
w.int64(start)
}
None => ()
}
w.name("timeUnixNano")
w.int64(data.time)
match data.point {
AsDouble(value) => {
w.name("asDouble")
w.double(value)
}
AsInt(value) => {
w.name("asInt")
w.int64(value)
}
Buckets(bounds~, counts~, sum~, count~, min~, max~) => {
w.name("count")
w.int64(count)
w.name("sum")
w.double(sum)
w.name("bucketCounts")
w.open_array()
for bucket in counts {
w.int64(bucket)
}
w.close_array()
w.name("explicitBounds")
w.open_array()
for bound in bounds {
w.double(bound)
}
w.close_array()
match min {
Some(value) => {
w.name("min")
w.double(value)
}
None => ()
}
match max {
Some(value) => {
w.name("max")
w.double(value)
}
None => ()
}
}
ExponentialBuckets(
scale~,
zero_count~,
positive_offset~,
positive_counts~,
negative_offset~,
negative_counts~,
sum~,
count~,
min~,
max~
) => {
w.name("scale")
w.raw(scale.to_string())
w.name("zeroCount")
w.int64(zero_count)
w.name("positive")
write_exponential_buckets(w, positive_offset, positive_counts)
w.name("negative")
write_exponential_buckets(w, negative_offset, negative_counts)
w.name("count")
w.int64(count)
w.name("sum")
w.double(sum)
match min {
Some(value) => {
w.name("min")
w.double(value)
}
None => ()
}
match max {
Some(value) => {
w.name("max")
w.double(value)
}
None => ()
}
}
Quantiles(quantiles~, sum~, count~) => {
w.name("count")
w.int64(count)
w.name("sum")
w.double(sum)
w.name("quantileValues")
w.open_array()
for quantile in quantiles {
w.open_object()
w.name("quantile")
w.double(quantile.0)
w.name("value")
w.double(quantile.1)
w.close_object()
}
w.close_array()
}
}
if !data.exemplars.is_empty() {
w.name("exemplars")
w.open_array()
for exemplar in data.exemplars {
write_exemplar(w, exemplar)
}
w.close_array()
}
if data.flags != 0 {
w.name("flags")
w.raw(data.flags.to_string())
}
w.close_object()
}
///|
/// One side of an exponential histogram: the index of its first bucket and one count
/// per bucket in use.
///
/// A side with nothing in it still goes out as an empty pair, because the two sides
/// are what a reader adds up and an absent one would have to be guessed as empty.
fn write_exponential_buckets(
w : JsonWriter,
offset : Int32,
counts : Array[Int64],
) -> Unit {
w.open_object()
w.name("offset")
w.raw(offset.to_string())
w.name("bucketCounts")
w.open_array()
for count in counts {
w.int64(count)
}
w.close_array()
w.close_object()
}
///|
fn write_exemplar(w : JsonWriter, exemplar : Exemplar) -> Unit {
w.open_object()
if !exemplar.attributes.is_empty() {
w.name("filteredAttributes")
write_attributes(w, exemplar.attributes)
}
w.name("timeUnixNano")
w.int64(exemplar.time)
w.name("asDouble")
w.double(exemplar.value)
match (exemplar.trace_id, exemplar.span_id) {
(Some(trace_id), Some(span_id)) => {
w.name("traceId")
w.string(@base16.encode(trace_id[:]))
w.name("spanId")
w.string(@base16.encode(span_id[:]))
}
_ => ()
}
w.close_object()
}
///|
pub fn temporality_code(temporality : Temporality) -> Int32 {
match temporality {
Delta => 1
Cumulative => 2
}
}