///|
/// Aggregated result of semantic validation.
pub(all) struct ValidationReport {
issues : Array[ValidationIssue]
error_count : Int
warning_count : Int
} derive(Eq)
///|
/// Return whether the document passed without semantic errors.
pub fn ValidationReport::is_valid(self : ValidationReport) -> Bool {
self.error_count == 0
}
///|
fn is_valid_unit(unit : StringView) -> Bool {
if unit.is_empty() {
return false
}
for char in unit.iter() {
if !(char.is_ascii_alphabetic() ||
char.is_ascii_digit() ||
char == '_' ||
char == ':') {
return false
}
}
true
}
///|
fn labels_have_name(labels : Array[Label], name : String) -> Bool {
for label in labels {
if label.name == name {
return true
}
}
false
}
///|
fn same_label_set(left : Array[Label], right : Array[Label]) -> Bool {
if left.length() != right.length() {
return false
}
for label in left {
let mut found = false
for candidate in right {
if candidate == label {
found = true
break
}
}
if !found {
return false
}
}
true
}
///|
fn same_label_group_ignoring(
left : Array[Label],
right : Array[Label],
ignored : String,
) -> Bool {
let mut left_count = 0
let mut right_count = 0
for label in left {
if label.name != ignored {
left_count += 1
}
}
for label in right {
if label.name != ignored {
right_count += 1
}
}
if left_count != right_count {
return false
}
for label in left {
if label.name == ignored {
continue
}
let mut found = false
for candidate in right {
if candidate.name != ignored && candidate == label {
found = true
break
}
}
if !found {
return false
}
}
true
}
///|
fn metric_special_label(family : MetricFamily) -> String? {
match family.metric_type {
Histogram | GaugeHistogram => Some("le")
Summary => Some("quantile")
StateSet => Some(family.name)
Unknown | Counter | Gauge | Info => None
}
}
///|
fn same_metric_identity(
family : MetricFamily,
left : Sample,
right : Sample,
) -> Bool {
match metric_special_label(family) {
Some(label) => same_label_group_ignoring(left.labels, right.labels, label)
None => same_label_set(left.labels, right.labels)
}
}
///|
fn label_number(sample : Sample, name : StringView) -> Double? {
match sample.label(name) {
None => None
Some(value) =>
match parse_number(value) {
Ok(number) => Some(number)
Err(_) => None
}
}
}
///|
fn is_finite_number(value : Double) -> Bool {
!value.is_nan() && value != @double.infinity && value != -@double.infinity
}
///|
fn is_nonnegative_integer(value : Double) -> Bool {
is_finite_number(value) && value >= 0.0 && value.trunc() == value
}
///|
fn same_point_timestamp(left : Sample, right : Sample) -> Bool {
match (left.timestamp, right.timestamp) {
(None, None) => true
(Some(left), Some(right)) => left == right
_ => false
}
}
///|
fn validate_label_array(
labels : Array[Label],
family : String,
sample : String,
location : String,
issues : Array[ValidationIssue],
) -> Unit {
for index in 0.. Unit {
validate_label_array(
exemplar.labels,
family.name,
sample.name,
"exemplar",
issues,
)
let mut exemplar_size = 0
for label in exemplar.labels {
exemplar_size += label.name.char_length() + label.value.char_length()
}
if exemplar_size > 128 {
issues.push(
ValidationIssue::error(
"exemplar_label_limit",
family.name,
"combined exemplar label names and values exceed 128 characters",
sample=sample.name,
),
)
}
let placement_ok = match family.metric_type {
Counter => sample.name == "\{family.name}_total"
Histogram | GaugeHistogram => sample.name == "\{family.name}_bucket"
_ => false
}
if !placement_ok {
issues.push(
ValidationIssue::error(
"exemplar_metric_type",
family.name,
"exemplars are only accepted on counter totals or histogram buckets",
sample=sample.name,
),
)
}
if sample.name == "\{family.name}_bucket" {
match label_number(sample, "le") {
Some(bound) =>
if exemplar.value.is_nan() ||
(!bound.is_nan() && exemplar.value > bound) {
issues.push(
ValidationIssue::error(
"exemplar_outside_bucket",
family.name,
"exemplar value must be non-NaN and within its bucket upper bound",
sample=sample.name,
),
)
}
None => ()
}
}
match exemplar.timestamp {
Some(timestamp) =>
if !is_finite_number(timestamp) {
issues.push(
ValidationIssue::error(
"invalid_exemplar_timestamp",
family.name,
"exemplar timestamp must be a finite Unix-second value",
sample=sample.name,
),
)
}
None => ()
}
}
///|
fn validate_histogram_order(
family : MetricFamily,
issues : Array[ValidationIssue],
) -> Unit {
let bucket_name = "\{family.name}_bucket"
let count_name = if family.metric_type == GaugeHistogram {
"\{family.name}_gcount"
} else {
"\{family.name}_count"
}
for index in 0.. value
None => continue
}
for other_index in (index + 1).. value
None => continue
}
if bound > other_bound {
issues.push(
ValidationIssue::error(
"unsorted_buckets",
family.name,
"histogram buckets must appear in increasing bound order",
sample=bucket.name,
),
)
}
if bound == other_bound {
issues.push(
ValidationIssue::error(
"duplicate_bucket_bound",
family.name,
"one histogram label group repeats bucket bound \{format_number(bound)}",
sample=bucket.name,
),
)
} else if (bound < other_bound && bucket.value > other.value) ||
(other_bound < bound && other.value > bucket.value) {
issues.push(
ValidationIssue::error(
"nonmonotonic_buckets",
family.name,
"cumulative histogram buckets decrease as their bounds increase",
sample=bucket.name,
),
)
}
}
if bound == @double.infinity {
let mut matching_count : Sample? = None
for candidate in family.samples {
if candidate.name == count_name &&
same_point_timestamp(bucket, candidate) &&
same_label_group_ignoring(bucket.labels, candidate.labels, "le") {
matching_count = Some(candidate)
break
}
}
match matching_count {
Some(count) =>
if !bucket.value.is_nan() &&
!count.value.is_nan() &&
bucket.value != count.value {
issues.push(
ValidationIssue::error(
"histogram_count_mismatch",
family.name,
"positive-infinity bucket must equal the family count",
sample=bucket.name,
),
)
}
None => ()
}
}
}
}
///|
fn validate_histogram_components(
family : MetricFamily,
issues : Array[ValidationIssue],
) -> Unit {
let bucket_name = "\{family.name}_bucket"
let sum_name = if family.metric_type == GaugeHistogram {
"\{family.name}_gsum"
} else {
"\{family.name}_sum"
}
let count_name = if family.metric_type == GaugeHistogram {
"\{family.name}_gcount"
} else {
"\{family.name}_count"
}
for index in 0.. {
if bound == @double.infinity {
has_positive_infinity_bucket = true
}
if bound < 0.0 {
has_negative_bucket = true
}
}
None => ()
}
} else if candidate.name == sum_name {
has_sum = true
} else if candidate.name == count_name {
has_count = true
}
}
if !has_bucket {
let code = if family.metric_type == GaugeHistogram {
"incomplete_gaugehistogram"
} else {
"missing_histogram_bucket"
}
issues.push(
ValidationIssue::error(
code,
family.name,
"histogram metric point has no bucket samples",
sample=anchor.name,
),
)
} else if !has_positive_infinity_bucket {
issues.push(
ValidationIssue::error(
"missing_infinity_bucket",
family.name,
"histogram metric point requires an le=\"+Inf\" bucket",
sample=anchor.name,
),
)
}
if has_sum != has_count {
let code = if family.metric_type == GaugeHistogram {
"incomplete_gaugehistogram"
} else {
"incomplete_histogram"
}
let suffixes = if family.metric_type == GaugeHistogram {
"_gsum and _gcount"
} else {
"_sum and _count"
}
issues.push(
ValidationIssue::error(
code,
family.name,
"\{suffixes} must either both be present or both be absent in one metric point",
sample=anchor.name,
),
)
}
if family.metric_type == Histogram && has_negative_bucket && has_sum {
issues.push(
ValidationIssue::error(
"sum_with_negative_bucket",
family.name,
"histograms with negative bucket thresholds must not expose _sum",
sample=anchor.name,
),
)
}
}
}
///|
fn validate_summary_order(
family : MetricFamily,
issues : Array[ValidationIssue],
) -> Unit {
for index in 0.. value
None => continue
}
for other_index in (index + 1).. number
None => continue
}
if value == other_value {
issues.push(
ValidationIssue::error(
"duplicate_quantile",
family.name,
"one summary label group repeats quantile \{format_number(value)}",
sample=quantile.name,
),
)
} else if (value < other_value && quantile.value > other.value) ||
(other_value < value && other.value > quantile.value) {
issues.push(
ValidationIssue::error(
"nonmonotonic_quantiles",
family.name,
"summary values decrease as quantiles increase",
sample=quantile.name,
),
)
}
}
}
}
///|
fn validate_summary_points(
family : MetricFamily,
issues : Array[ValidationIssue],
) -> Unit {
let count_name = "\{family.name}_count"
for count in family.samples {
if count.name != count_name || count.value != 0.0 {
continue
}
for quantile in family.samples {
if quantile.name == family.name &&
same_point_timestamp(count, quantile) &&
same_metric_identity(family, count, quantile) &&
!quantile.value.is_nan() {
issues.push(
ValidationIssue::error(
"quantile_without_observations",
family.name,
"summary quantiles must be NaN when the matching count is zero",
sample=quantile.name,
),
)
}
}
}
}
///|
fn validate_counter_points(
family : MetricFamily,
issues : Array[ValidationIssue],
) -> Unit {
let total_name = "\{family.name}_total"
let created_name = "\{family.name}_created"
for index in 0..
match previous_time {
None => {
previous_time = Some(candidate_time)
previous_total = Some(candidate)
}
Some(latest) =>
if candidate_time > latest {
previous_time = Some(candidate_time)
previous_total = Some(candidate)
}
}
None => ()
}
}
match previous_total {
Some(previous) =>
if sample.value < previous.value {
let mut reset_ok = sample.value == 0.0
if reset_ok {
reset_ok = false
let previous_point_time = match previous.timestamp {
Some(value) => value
None => current_time
}
for candidate in family.samples {
if candidate.name == created_name &&
same_label_set(candidate.labels, sample.labels) &&
same_point_timestamp(candidate, sample) &&
candidate.value > previous_point_time &&
candidate.value <= current_time {
reset_ok = true
break
}
}
}
if !reset_ok {
issues.push(
ValidationIssue::error(
"counter_decrease",
family.name,
"counter totals must not decrease without a zero reset and a created time within the sampling interval",
sample=sample.name,
),
)
}
}
None => ()
}
}
}
///|
fn validate_created_values(
family : MetricFamily,
issues : Array[ValidationIssue],
) -> Unit {
match family.metric_type {
Counter | Histogram | Summary => {
let created_name = "\{family.name}_created"
for sample in family.samples {
if sample.name == created_name && !is_finite_number(sample.value) {
issues.push(
ValidationIssue::error(
"invalid_created",
family.name,
"created values must be finite Unix-second timestamps",
sample=sample.name,
),
)
}
}
}
Unknown | Gauge | GaugeHistogram | Info | StateSet => ()
}
}
///|
fn validate_metric_sequence(
family : MetricFamily,
issues : Array[ValidationIssue],
) -> Unit {
for index in 0.. {
issues.push(
ValidationIssue::error(
"mixed_metric_point_timestamps",
family.name,
"multiple points for one metric must all have explicit timestamps",
sample=sample.name,
),
)
break
}
_ => ()
}
}
}
if index > 0 {
let previous = family.samples[index - 1]
if !same_metric_identity(family, previous, sample) {
for earlier in 0..<(index - 1) {
if same_metric_identity(family, family.samples[earlier], sample) {
issues.push(
ValidationIssue::error(
"interleaved_metrics",
family.name,
"samples from one metric must remain contiguous",
sample=sample.name,
),
)
break
}
}
} else if !same_point_timestamp(previous, sample) {
for earlier in 0..<(index - 1) {
if same_metric_identity(family, family.samples[earlier], sample) &&
same_point_timestamp(family.samples[earlier], sample) {
issues.push(
ValidationIssue::error(
"interleaved_metric_points",
family.name,
"samples from one metric point must remain contiguous",
sample=sample.name,
),
)
break
}
}
}
}
match sample.timestamp {
Some(current_time) =>
for previous in 0..
if current_time < previous_time {
issues.push(
ValidationIssue::error(
"nonmonotonic_metric_points",
family.name,
"metric points require monotonically increasing timestamps",
sample=sample.name,
),
)
break
}
None => ()
}
}
None => ()
}
}
}
///|
fn validate_family_shape(
family : MetricFamily,
issues : Array[ValidationIssue],
) -> Unit {
let mut has_counter_total = false
for sample in family.samples {
if (family.metric_type == Histogram || family.metric_type == GaugeHistogram) &&
sample.name != "\{family.name}_bucket" &&
labels_have_name(sample.labels, "le") {
issues.push(
ValidationIssue::error(
"reserved_component_label",
family.name,
"histogram metric labels must not contain the reserved 'le' label",
sample=sample.name,
),
)
}
if family.metric_type == Summary &&
sample.name != family.name &&
labels_have_name(sample.labels, "quantile") {
issues.push(
ValidationIssue::error(
"reserved_component_label",
family.name,
"summary metric labels must not contain the reserved 'quantile' label",
sample=sample.name,
),
)
}
match family.metric_type {
Counter =>
if sample.name == "\{family.name}_total" {
has_counter_total = true
if sample.value.is_nan() || sample.value < 0.0 {
issues.push(
ValidationIssue::error(
"negative_counter",
family.name,
"counter totals must be non-NaN and non-negative",
sample=sample.name,
),
)
}
}
Histogram | GaugeHistogram => {
if sample.name == "\{family.name}_bucket" {
match sample.label("le") {
None =>
issues.push(
ValidationIssue::error(
"missing_bucket_bound",
family.name,
"histogram buckets require an 'le' label",
sample=sample.name,
),
)
Some(bound) =>
match parse_number(bound) {
Err(_) =>
issues.push(
ValidationIssue::error(
"invalid_bucket_bound",
family.name,
"bucket bound '\{bound}' is not numeric",
sample=sample.name,
),
)
Ok(value) =>
if value.is_nan() {
issues.push(
ValidationIssue::error(
"invalid_bucket_bound",
family.name,
"bucket bounds must not be NaN",
sample=sample.name,
),
)
}
}
}
if !is_nonnegative_integer(sample.value) {
issues.push(
ValidationIssue::error(
"invalid_bucket_count",
family.name,
"bucket counts must be non-negative integers",
sample=sample.name,
),
)
}
}
if sample.name == "\{family.name}_sum" {
if sample.value.is_nan() || sample.value < 0.0 {
issues.push(
ValidationIssue::error(
"invalid_histogram_sum",
family.name,
"histogram sum must be non-NaN and non-negative",
sample=sample.name,
),
)
}
}
if sample.name == "\{family.name}_count" {
if !is_nonnegative_integer(sample.value) {
issues.push(
ValidationIssue::error(
"invalid_histogram_count",
family.name,
"histogram count must be a non-negative integer",
sample=sample.name,
),
)
}
}
if sample.name == "\{family.name}_gsum" {
if sample.value.is_nan() {
issues.push(
ValidationIssue::error(
"invalid_gaugehistogram_sum",
family.name,
"gauge-histogram sum must not be NaN",
sample=sample.name,
),
)
}
}
if sample.name == "\{family.name}_gcount" {
if !is_nonnegative_integer(sample.value) {
issues.push(
ValidationIssue::error(
"invalid_gaugehistogram_count",
family.name,
"gauge-histogram count must be a non-negative integer",
sample=sample.name,
),
)
}
}
}
Summary =>
if sample.name == "\{family.name}_sum" {
if sample.value.is_nan() || sample.value < 0.0 {
issues.push(
ValidationIssue::error(
"invalid_summary_sum",
family.name,
"summary sum must be non-NaN and non-negative",
sample=sample.name,
),
)
}
} else if sample.name == "\{family.name}_count" {
if !is_nonnegative_integer(sample.value) {
issues.push(
ValidationIssue::error(
"invalid_summary_count",
family.name,
"summary count must be a non-negative integer",
sample=sample.name,
),
)
}
} else if sample.name == family.name {
match sample.label("quantile") {
None =>
issues.push(
ValidationIssue::error(
"missing_quantile",
family.name,
"summary observations require a 'quantile' label",
sample=sample.name,
),
)
Some(quantile) =>
match parse_number(quantile) {
Ok(value) =>
if value.is_nan() || value < 0.0 || value > 1.0 {
issues.push(
ValidationIssue::error(
"invalid_quantile",
family.name,
"quantile must be between 0 and 1",
sample=sample.name,
),
)
}
Err(_) =>
issues.push(
ValidationIssue::error(
"invalid_quantile",
family.name,
"quantile '\{quantile}' is not numeric",
sample=sample.name,
),
)
}
}
if sample.value < 0.0 {
issues.push(
ValidationIssue::error(
"negative_quantile_value",
family.name,
"summary quantile values must not be negative",
sample=sample.name,
),
)
}
}
Info =>
if sample.name == "\{family.name}_info" && sample.value != 1.0 {
issues.push(
ValidationIssue::error(
"invalid_info_value",
family.name,
"info samples must have value 1",
sample=sample.name,
),
)
}
StateSet => {
if !labels_have_name(sample.labels, family.name) {
issues.push(
ValidationIssue::error(
"missing_state_label",
family.name,
"stateset samples require a label named after the metric family",
sample=sample.name,
),
)
}
if sample.value != 0.0 && sample.value != 1.0 {
issues.push(
ValidationIssue::error(
"invalid_state_value",
family.name,
"stateset sample values must be 0 or 1",
sample=sample.name,
),
)
}
}
Unknown | Gauge => ()
}
}
match family.metric_type {
Counter =>
if !family.samples.is_empty() && !has_counter_total {
issues.push(
ValidationIssue::error(
"missing_counter_total",
family.name,
"counter family has no _total sample",
),
)
}
Unknown | Gauge | Histogram | GaugeHistogram | Summary | Info | StateSet =>
()
}
match family.metric_type {
Histogram | GaugeHistogram => {
validate_histogram_components(family, issues)
validate_histogram_order(family, issues)
}
Summary => {
validate_summary_order(family, issues)
validate_summary_points(family, issues)
}
Counter => validate_counter_points(family, issues)
Unknown | Gauge | Info | StateSet => ()
}
validate_created_values(family, issues)
validate_metric_sequence(family, issues)
}
///|
fn validate_family(
family : MetricFamily,
issues : Array[ValidationIssue],
) -> Unit {
if !is_valid_metric_name(family.name) {
issues.push(
ValidationIssue::error(
"invalid_family_name",
family.name,
"family name is not a valid metric identifier",
),
)
}
if family.name.has_prefix("_") {
issues.push(
ValidationIssue::error(
"reserved_family_name",
family.name,
"metric family names beginning with '_' are reserved",
),
)
}
if family.help is None || family.help == Some("") {
issues.push(
ValidationIssue::warning(
"missing_help",
family.name,
"metric family has no HELP directive",
),
)
}
if family.metric_type == Unknown {
issues.push(
ValidationIssue::warning(
"unknown_type",
family.name,
"metric family has no concrete TYPE",
),
)
}
match family.unit {
Some(unit) =>
if !unit.is_empty() {
if !is_valid_unit(unit) {
issues.push(
ValidationIssue::error(
"invalid_unit",
family.name,
"unit '\{unit}' is not a valid unit identifier",
),
)
} else if !family.name.has_suffix("_\{unit}") {
issues.push(
ValidationIssue::error(
"unit_suffix",
family.name,
"family name must end with '_\{unit}'",
),
)
}
match family.metric_type {
Info | StateSet =>
issues.push(
ValidationIssue::error(
"unit_for_unitless_type",
family.name,
"info and stateset families must not declare a unit",
),
)
_ => ()
}
}
None => ()
}
for index in 0.. validate_exemplar(family, sample, exemplar, issues)
None => ()
}
match sample.timestamp {
Some(timestamp) =>
if !is_finite_number(timestamp) {
issues.push(
ValidationIssue::error(
"invalid_timestamp",
family.name,
"sample timestamp must be a finite Unix-second value",
sample=sample.name,
),
)
}
None => ()
}
let mut saw_same_series = false
let mut saw_untimestamped_point = false
let mut latest_timestamp : Double? = None
for previous in 0.. saw_untimestamped_point = true
Some(previous_time) =>
match latest_timestamp {
None => latest_timestamp = Some(previous_time)
Some(latest) =>
if previous_time > latest {
latest_timestamp = Some(previous_time)
}
}
}
}
}
if saw_same_series {
match sample.timestamp {
None =>
issues.push(
ValidationIssue::error(
"duplicate_series",
family.name,
"sample repeats an existing name and label set without distinct timestamps",
sample=sample.name,
),
)
Some(current_time) =>
if saw_untimestamped_point {
issues.push(
ValidationIssue::error(
"duplicate_series",
family.name,
"sample repeats an untimestamped name and label set",
sample=sample.name,
),
)
} else {
match latest_timestamp {
Some(latest) =>
if current_time <= latest {
issues.push(
ValidationIssue::error(
"nonmonotonic_timestamps",
family.name,
"repeated metric points require increasing timestamps",
sample=sample.name,
),
)
}
None => ()
}
}
}
}
}
validate_family_shape(family, issues)
}
///|
fn family_wire_names(family : MetricFamily) -> Array[String] {
match family.metric_type {
Counter => ["\{family.name}_total", "\{family.name}_created"]
Histogram =>
[
"\{family.name}_bucket",
"\{family.name}_sum",
"\{family.name}_count",
"\{family.name}_created",
]
GaugeHistogram =>
["\{family.name}_bucket", "\{family.name}_gsum", "\{family.name}_gcount"]
Summary =>
[
family.name,
"\{family.name}_sum",
"\{family.name}_count",
"\{family.name}_created",
]
Info => ["\{family.name}_info"]
Unknown | Gauge | StateSet => [family.name]
}
}
///|
fn families_have_wire_collision(
left : MetricFamily,
right : MetricFamily,
) -> Bool {
for left_name in family_wire_names(left) {
for right_name in family_wire_names(right) {
if left_name == right_name {
return true
}
}
}
false
}
///|
/// Validate document-level and metric-family semantics.
pub fn validate(document : Document) -> ValidationReport {
let issues : Array[ValidationIssue] = []
if !document.has_eof {
issues.push(
ValidationIssue::error(
"missing_eof", "", "OpenMetrics 1.0 documents must end with # EOF",
),
)
}
for index in 0.. error_count += 1
Warning => warning_count += 1
}
}
{ issues, error_count, warning_count }
}
///|
/// Return true when semantic validation finds no errors.
pub fn is_valid(document : Document) -> Bool {
validate(document).is_valid()
}