Skip to main content

servers/otlp/
metrics.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use ahash::HashSet;
16use api::greptime_proto::io::prometheus::write::v2::histogram::{
17    Count as PromCount, ResetHint, ZeroCount as PromZeroCount,
18};
19use api::greptime_proto::io::prometheus::write::v2::{BucketSpan, Histogram as PromHistogram};
20use api::v1::value::ValueData;
21use api::v1::{ColumnSchema, RowInsertRequests, SemanticType, Value};
22use common_grpc::precision::Precision;
23use common_query::native_histogram::{
24    encode_native_histogram, native_histogram_column_schema, native_histogram_value_type,
25};
26use common_query::prelude::{
27    GREPTIME_TEMPORALITY_DELTA, OTLP_AGGREGATION_TEMPORALITY_LABEL, greptime_timestamp,
28    greptime_value,
29};
30use common_query::prometheus::PROMETHEUS_STALE_NAN_BITS;
31use lazy_static::lazy_static;
32use otel_arrow_rust::proto::opentelemetry::collector::metrics::v1::ExportMetricsServiceRequest;
33use otel_arrow_rust::proto::opentelemetry::common::v1::{AnyValue, KeyValue, any_value};
34use otel_arrow_rust::proto::opentelemetry::metrics::v1::{metric, number_data_point, *};
35use session::protocol_ctx::{MetricType, OtlpMetricCtx};
36use snafu::OptionExt;
37use table::requests::{
38    METADATA_QUALITY_DECLARED, METRIC_TEMPORALITY_CUMULATIVE, METRIC_TEMPORALITY_DELTA,
39    SEMANTIC_METRIC_METADATA_QUALITY, SEMANTIC_METRIC_ORIGINAL_NAME, SEMANTIC_METRIC_TEMPORALITY,
40    SEMANTIC_METRIC_TYPE, SEMANTIC_METRIC_UNIT,
41};
42
43use crate::error::{self, Result};
44use crate::metrics::OTLP_EXPONENTIAL_HISTOGRAM_REJECTED_DATA_POINTS;
45use crate::otlp::trace::{KEY_SERVICE_INSTANCE_ID, KEY_SERVICE_NAME, KEY_SERVICE_NAMESPACE};
46use crate::query_handler::MetricsIngestOutcome;
47use crate::row_writer::{self, MultiTableData, TableData};
48pub use crate::semantic::SemanticIndex;
49use crate::semantic::{
50    METRIC_TYPE_COUNTER, METRIC_TYPE_GAUGE, METRIC_TYPE_HISTOGRAM, METRIC_TYPE_SUMMARY,
51    METRIC_TYPE_UPDOWN_COUNTER,
52};
53
54mod exemplars;
55mod prepare;
56mod resource_info;
57
58pub use prepare::{PreparedMetrics, prepare_metrics};
59mod translator;
60
61pub use resource_info::OTEL_RESOURCE_INFO_TABLE_NAME;
62use resource_info::ResourceInfoData;
63pub(crate) use translator::ucum_to_openmetrics_unit;
64use translator::{translate_label_name, translate_metric_name};
65
66/// the default column count for table writer
67const APPROXIMATE_COLUMN_COUNT: usize = 8;
68
69const COUNT_TABLE_SUFFIX: &str = "_count";
70const SUM_TABLE_SUFFIX: &str = "_sum";
71const BUCKET_TABLE_SUFFIX: &str = "_bucket";
72
73const JOB_KEY: &str = "job";
74const INSTANCE_KEY: &str = "instance";
75
76// see: https://prometheus.io/docs/guides/opentelemetry/#promoting-resource-attributes
77const DEFAULT_PROMOTE_ATTRS: [&str; 19] = [
78    "service.instance.id",
79    "service.name",
80    "service.namespace",
81    "service.version",
82    "cloud.availability_zone",
83    "cloud.region",
84    "container.name",
85    "deployment.environment",
86    "deployment.environment.name",
87    "k8s.cluster.name",
88    "k8s.container.name",
89    "k8s.cronjob.name",
90    "k8s.daemonset.name",
91    "k8s.deployment.name",
92    "k8s.job.name",
93    "k8s.namespace.name",
94    "k8s.pod.name",
95    "k8s.replicaset.name",
96    "k8s.statefulset.name",
97];
98
99lazy_static! {
100    static ref DEFAULT_PROMOTE_ATTRS_SET: HashSet<String> =
101        HashSet::from_iter(DEFAULT_PROMOTE_ATTRS.iter().map(|s| s.to_string()));
102}
103
104const OTEL_SCOPE_NAME: &str = "name";
105const OTEL_SCOPE_VERSION: &str = "version";
106const OTEL_SCOPE_SCHEMA_URL: &str = "schema_url";
107const MIN_EXPONENTIAL_HISTOGRAM_SCALE: i32 = -4;
108const MAX_EXPONENTIAL_HISTOGRAM_SCALE: i32 = 8;
109const MAX_REJECTION_MESSAGE_BYTES: usize = 512;
110
111/// Result of converting one OTLP metrics request.
112#[derive(Debug)]
113pub struct MetricsConversion {
114    /// Kept normalized until the frontend reserves memory for row expansion.
115    pub exemplars: Vec<crate::exemplar::Exemplar>,
116    pub requests: RowInsertRequests,
117    /// Row count of `requests`; the resource descriptor is not counted.
118    pub rows: usize,
119    /// Per-table semantic index for the auto-create path to stamp as table
120    /// options; covers the descriptor table too.
121    pub semantic_index: SemanticIndex,
122    /// The synthesized resource descriptor, written separately from the
123    /// metric data. See [`resource_info`].
124    pub resource_info: Option<RowInsertRequests>,
125    pub outcome: MetricsIngestOutcome,
126}
127
128struct MetricMetadata {
129    name: String,
130    original_name: String,
131    native_schema: Option<ColumnSchema>,
132    semantics: SemanticIndex,
133}
134
135impl MetricMetadata {
136    fn new(metric: &Metric, ctx: &OtlpMetricCtx) -> Result<Self> {
137        let name = translate_metric_name(metric, &ctx.metric_type, ctx.metric_translation_strategy);
138        let emitted = emitted_semantic_tables(&ctx.metric_type, &name);
139        let mut semantics = SemanticIndex::default();
140        record_metric_semantics(&mut semantics, metric, &name, &emitted);
141        let native_schema = matches!(metric.data, Some(metric::Data::ExponentialHistogram(_)))
142            .then(native_histogram_column_schema)
143            .transpose()
144            .map_err(|error| {
145                error::InternalSnafu {
146                    err_msg: error.to_string(),
147                }
148                .build()
149            })?;
150        Ok(Self {
151            name,
152            original_name: metric.name.clone(),
153            native_schema,
154            semantics,
155        })
156    }
157}
158
159/// Converts OpenTelemetry metrics without validating destination table schemas.
160/// Ingestion callers use [`prepare_metrics`], run destination preflight, then call
161/// [`PreparedMetrics::finish`] to apply rejections and merge surviving points.
162///
163/// See
164/// <https://github.com/open-telemetry/opentelemetry-proto/blob/main/opentelemetry/proto/metrics/v1/metrics.proto>
165/// for data structure of OTLP metrics.
166pub fn to_grpc_insert_requests(
167    request: ExportMetricsServiceRequest,
168    metric_ctx: &mut OtlpMetricCtx,
169) -> Result<MetricsConversion> {
170    let prepared = prepare_metrics(request, metric_ctx)?;
171    let rejections = vec![None; prepared.num_points()];
172    prepared.finish(rejections, &std::collections::HashMap::new())
173}
174
175fn validate_sample_kind(table_name: &str, schema: &[ColumnSchema]) -> Result<()> {
176    let field_count = schema
177        .iter()
178        .filter(|column| column.semantic_type == SemanticType::Field as i32)
179        .count();
180    let has_native_histogram = schema.iter().any(|column| {
181        column.semantic_type == SemanticType::Field as i32
182            && api::helper::is_column_type_value_eq(
183                column.datatype,
184                column.datatype_extension.clone(),
185                native_histogram_value_type(),
186            )
187    });
188    if has_native_histogram && field_count != 1 {
189        return Err(error::InvalidParameterSnafu {
190            reason: format!(
191                "OTLP metric `{}` cannot mix native histogram and float sample fields",
192                table_name
193            ),
194        }
195        .build());
196    }
197    Ok(())
198}
199
200/// The tables a metric emits and their per-table `metric.type`. Histogram fans
201/// out into `_bucket` (the histogram) plus `_sum`/`_count` counters; summary
202/// fans out into the quantile table plus `_count`/`_sum` counters.
203fn emitted_semantic_tables(metric_type: &MetricType, base: &str) -> Vec<(String, &'static str)> {
204    match metric_type {
205        MetricType::Gauge => vec![(base.to_string(), METRIC_TYPE_GAUGE)],
206        MetricType::MonotonicSum => vec![(base.to_string(), METRIC_TYPE_COUNTER)],
207        MetricType::NonMonotonicSum => vec![(base.to_string(), METRIC_TYPE_UPDOWN_COUNTER)],
208        MetricType::Histogram => vec![
209            (
210                format!("{base}{BUCKET_TABLE_SUFFIX}"),
211                METRIC_TYPE_HISTOGRAM,
212            ),
213            (format!("{base}{SUM_TABLE_SUFFIX}"), METRIC_TYPE_COUNTER),
214            (format!("{base}{COUNT_TABLE_SUFFIX}"), METRIC_TYPE_COUNTER),
215        ],
216        MetricType::Summary => vec![
217            (base.to_string(), METRIC_TYPE_SUMMARY),
218            (format!("{base}{COUNT_TABLE_SUFFIX}"), METRIC_TYPE_COUNTER),
219            (format!("{base}{SUM_TABLE_SUFFIX}"), METRIC_TYPE_COUNTER),
220        ],
221        MetricType::ExponentialHistogram => {
222            vec![(base.to_string(), METRIC_TYPE_HISTOGRAM)]
223        }
224        MetricType::Init => vec![],
225    }
226}
227
228/// Maps OTLP `aggregation_temporality` to the semantic value, or `None` when the
229/// instrument has no temporality (gauge/summary) or it is unspecified.
230fn temporality_value(data: &metric::Data) -> Option<&'static str> {
231    let raw = match data {
232        metric::Data::Sum(sum) => sum.aggregation_temporality,
233        metric::Data::Histogram(hist) => hist.aggregation_temporality,
234        metric::Data::ExponentialHistogram(hist) => hist.aggregation_temporality,
235        _ => return None,
236    };
237    match AggregationTemporality::try_from(raw) {
238        Ok(AggregationTemporality::Delta) => Some(METRIC_TEMPORALITY_DELTA),
239        Ok(AggregationTemporality::Cumulative) => Some(METRIC_TEMPORALITY_CUMULATIVE),
240        _ => None,
241    }
242}
243
244/// Records the declared metric-level semantic keys for every table this metric
245/// emits.
246fn record_metric_semantics(
247    index: &mut SemanticIndex,
248    metric: &Metric,
249    name: &str,
250    emitted: &[(String, &str)],
251) {
252    if emitted.is_empty() {
253        return;
254    }
255
256    let temporality = metric.data.as_ref().and_then(temporality_value);
257    let unit = metric.unit.trim();
258    // `original_name` is meaningful only when translation renamed the metric.
259    let original_name = (name != metric.name.as_str()).then_some(metric.name.as_str());
260
261    for (table, metric_type) in emitted {
262        index.record_scalar(table, SEMANTIC_METRIC_TYPE, metric_type);
263        index.record_scalar(
264            table,
265            SEMANTIC_METRIC_METADATA_QUALITY,
266            METADATA_QUALITY_DECLARED,
267        );
268        if let Some(temporality) = temporality {
269            index.record_scalar(table, SEMANTIC_METRIC_TEMPORALITY, temporality);
270        }
271        if !unit.is_empty() {
272            index.record_scalar(table, SEMANTIC_METRIC_UNIT, unit);
273        }
274        if let Some(original_name) = original_name {
275            index.record_scalar(table, SEMANTIC_METRIC_ORIGINAL_NAME, original_name);
276        }
277    }
278}
279
280fn from_metric_type(data: &metric::Data) -> MetricType {
281    match data {
282        metric::Data::Gauge(_) => MetricType::Gauge,
283        metric::Data::Sum(s) => {
284            if s.is_monotonic {
285                MetricType::MonotonicSum
286            } else {
287                MetricType::NonMonotonicSum
288            }
289        }
290        metric::Data::Histogram(_) => MetricType::Histogram,
291        metric::Data::ExponentialHistogram(_) => MetricType::ExponentialHistogram,
292        metric::Data::Summary(_) => MetricType::Summary,
293    }
294}
295
296/// Non-scalar values (arrays, maps, bytes) are not representable as tags.
297fn scalar_value_string(value: Option<&AnyValue>) -> Option<String> {
298    match value.and_then(|v| v.value.as_ref())? {
299        any_value::Value::StringValue(s) => Some(s.clone()),
300        any_value::Value::IntValue(v) => Some(v.to_string()),
301        any_value::Value::DoubleValue(v) => Some(v.to_string()),
302        any_value::Value::BoolValue(v) => Some(v.to_string()),
303        _ => None,
304    }
305}
306
307/// Prometheus-style `(job, instance)` identity. Per the OTel Prometheus
308/// compatibility spec `job` folds in `service.namespace` when present; a
309/// resource without `service.name` gets no job rather than a fabricated one.
310/// Both fields are optional strings, so a named type keeps a swapped
311/// destructuring from type-checking at the call sites.
312pub(crate) struct ServiceIdentity {
313    pub job: Option<String>,
314    pub instance: Option<String>,
315}
316
317pub(crate) fn service_identity(attrs: &[KeyValue]) -> ServiceIdentity {
318    let mut name = None;
319    let mut namespace = None;
320    let mut instance = None;
321    for kv in attrs {
322        match kv.key.as_str() {
323            KEY_SERVICE_NAME => name = scalar_value_string(kv.value.as_ref()),
324            KEY_SERVICE_NAMESPACE => namespace = scalar_value_string(kv.value.as_ref()),
325            KEY_SERVICE_INSTANCE_ID => instance = scalar_value_string(kv.value.as_ref()),
326            _ => {}
327        }
328    }
329    let job = name.map(|name| match namespace {
330        Some(ns) if !ns.is_empty() => format!("{ns}/{name}"),
331        _ => name,
332    });
333    ServiceIdentity { job, instance }
334}
335
336fn string_key_value(key: &str, value: String) -> KeyValue {
337    KeyValue {
338        key: key.to_string(),
339        value: Some(AnyValue {
340            value: Some(any_value::Value::StringValue(value)),
341        }),
342    }
343}
344
345fn process_resource_attrs(attrs: &mut Vec<KeyValue>, metric_ctx: &OtlpMetricCtx) {
346    // remap the service identity attributes to job and instance
347    let ServiceIdentity { job, instance } = service_identity(attrs);
348
349    // if promote all, then exclude the list, else, include the list
350    if metric_ctx.promote_all_resource_attrs {
351        attrs.retain(|kv| !metric_ctx.resource_attrs.contains(&kv.key));
352    } else {
353        attrs.retain(|kv| {
354            metric_ctx.resource_attrs.contains(&kv.key)
355                || DEFAULT_PROMOTE_ATTRS_SET.contains(&kv.key)
356        });
357    }
358
359    if let Some(job) = job {
360        attrs.push(string_key_value(JOB_KEY, job));
361    }
362    if let Some(instance) = instance {
363        attrs.push(string_key_value(INSTANCE_KEY, instance));
364    }
365}
366
367fn process_scope_attrs(scope: &ScopeMetrics, metric_ctx: &OtlpMetricCtx) -> Option<Vec<KeyValue>> {
368    if !metric_ctx.promote_scope_attrs {
369        return None;
370    }
371
372    // persist scope attrs with name, version and schema_url
373    scope.scope.as_ref().map(|s| {
374        let mut attrs = s.attributes.clone();
375        attrs.push(KeyValue {
376            key: OTEL_SCOPE_NAME.to_string(),
377            value: Some(AnyValue {
378                value: Some(any_value::Value::StringValue(s.name.clone())),
379            }),
380        });
381        attrs.push(KeyValue {
382            key: OTEL_SCOPE_VERSION.to_string(),
383            value: Some(AnyValue {
384                value: Some(any_value::Value::StringValue(s.version.clone())),
385            }),
386        });
387        attrs.push(KeyValue {
388            key: OTEL_SCOPE_SCHEMA_URL.to_string(),
389            value: Some(AnyValue {
390                value: Some(any_value::Value::StringValue(scope.schema_url.clone())),
391            }),
392        });
393        attrs
394    })
395}
396
397#[allow(clippy::too_many_arguments)]
398fn encode_metrics(
399    table_writer: &mut MultiTableData,
400    metric: &Metric,
401    metadata: &MetricMetadata,
402    resource_attrs: Option<&Vec<KeyValue>>,
403    scope_attrs: Option<&Vec<KeyValue>>,
404    metric_ctx: &OtlpMetricCtx,
405    outcome: &mut MetricsIngestOutcome,
406    exemplars: &mut Vec<exemplars::RowExemplars>,
407) -> Result<()> {
408    let name = &metadata.name;
409
410    if let Some(data) = &metric.data {
411        match data {
412            metric::Data::Gauge(gauge) => {
413                encode_gauge(
414                    table_writer,
415                    name,
416                    gauge,
417                    resource_attrs,
418                    scope_attrs,
419                    metric_ctx,
420                    exemplars,
421                )?;
422                add_accepted_data_points(outcome, gauge.data_points.len())?;
423            }
424            metric::Data::Sum(sum) => {
425                encode_sum(
426                    table_writer,
427                    name,
428                    sum,
429                    resource_attrs,
430                    scope_attrs,
431                    metric_ctx,
432                    exemplars,
433                )?;
434                add_accepted_data_points(outcome, sum.data_points.len())?;
435            }
436            metric::Data::Summary(summary) => {
437                encode_summary(
438                    table_writer,
439                    name,
440                    summary,
441                    resource_attrs,
442                    scope_attrs,
443                    metric_ctx,
444                )?;
445                add_accepted_data_points(outcome, summary.data_points.len())?;
446            }
447            metric::Data::Histogram(hist) => encode_histogram(
448                table_writer,
449                name,
450                hist,
451                resource_attrs,
452                scope_attrs,
453                metric_ctx,
454                outcome,
455                exemplars,
456            )?,
457            metric::Data::ExponentialHistogram(hist) => encode_exponential_histogram(
458                table_writer,
459                metadata,
460                hist,
461                resource_attrs,
462                scope_attrs,
463                metric_ctx,
464                outcome,
465                exemplars,
466            )?,
467        }
468    }
469
470    Ok(())
471}
472
473fn add_accepted_data_points(outcome: &mut MetricsIngestOutcome, count: usize) -> Result<()> {
474    let count = i64::try_from(count).map_err(|_| {
475        error::InvalidParameterSnafu {
476            reason: "OTLP metrics data-point count exceeds i64",
477        }
478        .build()
479    })?;
480    outcome.accepted_data_points =
481        outcome
482            .accepted_data_points
483            .checked_add(count)
484            .ok_or_else(|| {
485                error::InvalidParameterSnafu {
486                    reason: "OTLP accepted data-point count overflows i64",
487                }
488                .build()
489            })?;
490    Ok(())
491}
492
493fn reject_data_points(
494    outcome: &mut MetricsIngestOutcome,
495    count: usize,
496    reason: impl FnOnce() -> String,
497) -> Result<()> {
498    if count == 0 {
499        return Ok(());
500    }
501    let count = i64::try_from(count).map_err(|_| {
502        error::InvalidParameterSnafu {
503            reason: "OTLP rejected data-point count exceeds i64",
504        }
505        .build()
506    })?;
507    outcome.rejected_data_points =
508        outcome
509            .rejected_data_points
510            .checked_add(count)
511            .ok_or_else(|| {
512                error::InvalidParameterSnafu {
513                    reason: "OTLP rejected data-point count overflows i64",
514                }
515                .build()
516            })?;
517    append_rejection_message(&mut outcome.error_message, reason);
518    Ok(())
519}
520
521fn append_rejection_message(message: &mut Option<String>, reason: impl FnOnce() -> String) {
522    let message = message.get_or_insert_with(String::new);
523    let separator = if message.is_empty() { "" } else { "; " };
524    let Some(available) = MAX_REJECTION_MESSAGE_BYTES.checked_sub(message.len()) else {
525        return;
526    };
527    if available <= separator.len() {
528        return;
529    }
530    let reason = reason();
531    message.push_str(separator);
532
533    let available = MAX_REJECTION_MESSAGE_BYTES - message.len();
534    if reason.len() <= available {
535        message.push_str(&reason);
536        return;
537    }
538
539    const ELLIPSIS: &str = "...";
540    let mut end = available.saturating_sub(ELLIPSIS.len());
541    while !reason.is_char_boundary(end) {
542        end -= 1;
543    }
544    message.push_str(&reason[..end]);
545    if available >= ELLIPSIS.len() {
546        message.push_str(ELLIPSIS);
547    }
548}
549
550#[allow(clippy::too_many_arguments)]
551fn encode_exponential_histogram(
552    table_writer: &mut MultiTableData,
553    metadata: &MetricMetadata,
554    histogram: &ExponentialHistogram,
555    resource_attrs: Option<&Vec<KeyValue>>,
556    scope_attrs: Option<&Vec<KeyValue>>,
557    metric_ctx: &OtlpMetricCtx,
558    outcome: &mut MetricsIngestOutcome,
559    exemplars: &mut Vec<exemplars::RowExemplars>,
560) -> Result<()> {
561    let name = &metadata.name;
562    let temporality = match exponential_histogram_gate(histogram) {
563        Ok(temporality) => temporality,
564        Err(rejection) => {
565            reject_data_points(outcome, histogram.data_points.len(), || {
566                rejection.message(name)
567            })?;
568            OTLP_EXPONENTIAL_HISTOGRAM_REJECTED_DATA_POINTS
569                .with_label_values(&[rejection.reason_label()])
570                .inc_by(histogram.data_points.len() as u64);
571            return Ok(());
572        }
573    };
574
575    let column_schema = metadata
576        .native_schema
577        .as_ref()
578        .context(error::InternalSnafu {
579            err_msg: "missing native histogram schema",
580        })?;
581    for (index, data_point) in histogram.data_points.iter().enumerate() {
582        let (value, timestamp_nanos) = match exponential_histogram_value(data_point, temporality) {
583            Ok(value) => value,
584            Err(reason) => {
585                reject_data_points(outcome, 1, || {
586                    format!("metric `{name}` data point {index}: {reason}")
587                })?;
588                OTLP_EXPONENTIAL_HISTOGRAM_REJECTED_DATA_POINTS
589                    .with_label_values(&["invalid_data_point"])
590                    .inc();
591                continue;
592            }
593        };
594
595        let table = table_writer.get_or_default_table_data(
596            name,
597            APPROXIMATE_COLUMN_COUNT,
598            histogram.data_points.len(),
599        );
600        let mut row = table.alloc_one_row();
601        write_tags_and_timestamp(
602            table,
603            &mut row,
604            resource_attrs,
605            scope_attrs,
606            Some(data_point.attributes.as_ref()),
607            timestamp_nanos,
608            metric_ctx,
609        )?;
610        write_temporality_tag(
611            table,
612            &mut row,
613            temporality == AggregationTemporality::Delta,
614        )?;
615        row_writer::write_by_schema(
616            table,
617            std::iter::once((column_schema.clone(), Some(value))),
618            &mut row,
619        )?;
620        exemplars::append(
621            exemplars,
622            &data_point.exemplars,
623            name,
624            table.num_rows(),
625            metric_ctx,
626        );
627        table.add_row(row);
628        add_accepted_data_points(outcome, 1)?;
629    }
630
631    Ok(())
632}
633
634#[derive(Debug)]
635pub(crate) enum ExponentialHistogramRejection {
636    UnspecifiedTemporality,
637}
638
639impl ExponentialHistogramRejection {
640    fn reason_label(&self) -> &'static str {
641        match self {
642            Self::UnspecifiedTemporality => "unspecified_temporality",
643        }
644    }
645
646    fn message(&self, name: &str) -> String {
647        match self {
648            Self::UnspecifiedTemporality => format!(
649                "metric `{name}` has unspecified OTLP exponential histogram temporality; delta or cumulative temporality is required"
650            ),
651        }
652    }
653}
654
655/// Validate temporality before processing points; delta values remain raw intervals.
656pub(crate) fn exponential_histogram_gate(
657    histogram: &ExponentialHistogram,
658) -> std::result::Result<AggregationTemporality, ExponentialHistogramRejection> {
659    match AggregationTemporality::try_from(histogram.aggregation_temporality) {
660        Ok(temporality @ (AggregationTemporality::Cumulative | AggregationTemporality::Delta)) => {
661            Ok(temporality)
662        }
663        _ => Err(ExponentialHistogramRejection::UnspecifiedTemporality),
664    }
665}
666
667pub(crate) fn exponential_histogram_value(
668    data_point: &ExponentialHistogramDataPoint,
669    temporality: AggregationTemporality,
670) -> std::result::Result<(ValueData, i64), String> {
671    if data_point.start_time_unix_nano > data_point.time_unix_nano {
672        return Err(format!(
673            "start_time_unix_nano {} exceeds time_unix_nano {}",
674            data_point.start_time_unix_nano, data_point.time_unix_nano
675        ));
676    }
677
678    let timestamp_nanos = i64::try_from(data_point.time_unix_nano)
679        .map_err(|_| format!("time_unix_nano {} overflows i64", data_point.time_unix_nano))?;
680    let timestamp = timestamp_millis(data_point.time_unix_nano, "time_unix_nano")?;
681    let start_timestamp =
682        timestamp_millis(data_point.start_time_unix_nano, "start_time_unix_nano")?;
683
684    let reset_hint = if temporality == AggregationTemporality::Delta {
685        ResetHint::Gauge
686    } else {
687        ResetHint::Unspecified
688    } as i32;
689    let no_recorded_value = data_point.flags & DataPointFlags::NoRecordedValueMask as u32 != 0;
690    let histogram = if no_recorded_value {
691        PromHistogram {
692            sum: f64::from_bits(PROMETHEUS_STALE_NAN_BITS),
693            schema: 0,
694            zero_threshold: 0.0,
695            reset_hint,
696            timestamp,
697            start_timestamp,
698            count: Some(PromCount::CountInt(0)),
699            zero_count: Some(PromZeroCount::ZeroCountInt(0)),
700            ..Default::default()
701        }
702    } else {
703        if data_point.scale < MIN_EXPONENTIAL_HISTOGRAM_SCALE {
704            return Err(format!(
705                "scale {} is unsupported; minimum supported scale is {}",
706                data_point.scale, MIN_EXPONENTIAL_HISTOGRAM_SCALE
707            ));
708        }
709        if !data_point.zero_threshold.is_finite() || data_point.zero_threshold < 0.0 {
710            return Err(format!(
711                "zero_threshold {} must be finite and non-negative",
712                data_point.zero_threshold
713            ));
714        }
715
716        let downscale_shift = if data_point.scale > MAX_EXPONENTIAL_HISTOGRAM_SCALE {
717            let shift = data_point
718                .scale
719                .checked_sub(MAX_EXPONENTIAL_HISTOGRAM_SCALE)
720                .ok_or_else(|| "downscale shift overflows i32".to_string())?;
721            u32::try_from(shift).map_err(|_| "downscale shift exceeds u32".to_string())?
722        } else {
723            0
724        };
725        let (positive_spans, positive_deltas, positive_count) =
726            convert_bucket_range("positive", data_point.positive.as_ref(), downscale_shift)?;
727        let (negative_spans, negative_deltas, negative_count) =
728            convert_bucket_range("negative", data_point.negative.as_ref(), downscale_shift)?;
729        let bucket_count = data_point
730            .zero_count
731            .checked_add(positive_count)
732            .and_then(|count| count.checked_add(negative_count))
733            .ok_or_else(|| "bucket observation total overflows u64".to_string())?;
734        if bucket_count != data_point.count {
735            return Err(format!(
736                "buckets contain {bucket_count} observations, declared count is {}",
737                data_point.count
738            ));
739        }
740
741        i64::try_from(data_point.count)
742            .map_err(|_| format!("count {} overflows i64", data_point.count))?;
743        i64::try_from(data_point.zero_count)
744            .map_err(|_| format!("zero_count {} overflows i64", data_point.zero_count))?;
745
746        let sum = match data_point.sum {
747            Some(sum) if sum.is_nan() => f64::NAN,
748            Some(sum) => sum,
749            None => f64::NAN,
750        };
751        PromHistogram {
752            sum,
753            schema: data_point.scale.min(MAX_EXPONENTIAL_HISTOGRAM_SCALE),
754            zero_threshold: data_point.zero_threshold,
755            negative_spans,
756            negative_deltas,
757            positive_spans,
758            positive_deltas,
759            reset_hint,
760            timestamp,
761            start_timestamp,
762            count: Some(PromCount::CountInt(data_point.count)),
763            zero_count: Some(PromZeroCount::ZeroCountInt(data_point.zero_count)),
764            ..Default::default()
765        }
766    };
767
768    encode_native_histogram(&histogram)
769        .map(|value| (value, timestamp_nanos))
770        .map_err(|error| format!("OTLP exponential histogram cannot be encoded: {error}"))
771}
772
773fn timestamp_millis(timestamp_nanos: u64, name: &str) -> std::result::Result<i64, String> {
774    i64::try_from(timestamp_nanos / 1_000_000)
775        .map_err(|_| format!("{name} {timestamp_nanos} milliseconds overflow i64"))
776}
777
778fn convert_bucket_range(
779    name: &str,
780    buckets: Option<&exponential_histogram_data_point::Buckets>,
781    downscale_shift: u32,
782) -> std::result::Result<(Vec<BucketSpan>, Vec<i64>, u64), String> {
783    let Some(buckets) = buckets else {
784        return Ok((Vec::new(), Vec::new(), 0));
785    };
786    if buckets.bucket_counts.is_empty() {
787        return Ok((Vec::new(), Vec::new(), 0));
788    }
789
790    let mut merged = Vec::<(i32, u64)>::with_capacity(buckets.bucket_counts.len());
791    let mut total = 0u64;
792    for (position, count) in buckets.bucket_counts.iter().copied().enumerate() {
793        let position =
794            i32::try_from(position).map_err(|_| format!("{name} bucket length exceeds i32"))?;
795        let source_index = buckets.offset.checked_add(position).ok_or_else(|| {
796            format!(
797                "{name} bucket index overflows i32 at offset {} and position {position}",
798                buckets.offset
799            )
800        })?;
801        let target_index = downscale_bucket_index(source_index, downscale_shift)?
802            .checked_add(1)
803            .ok_or_else(|| format!("{name} shifted bucket index overflows i32"))?;
804        if let Some((last_index, last_count)) = merged.last_mut() {
805            if *last_index == target_index {
806                *last_count = last_count
807                    .checked_add(count)
808                    .ok_or_else(|| format!("{name} merged bucket count overflows u64"))?;
809                total = total
810                    .checked_add(count)
811                    .ok_or_else(|| format!("{name} bucket count total overflows u64"))?;
812                continue;
813            }
814            let next_index = last_index
815                .checked_add(1)
816                .ok_or_else(|| format!("{name} target bucket index overflows i32"))?;
817            if target_index != next_index {
818                return Err(format!(
819                    "{name} bucket indexes are not contiguous after downscaling"
820                ));
821            }
822        }
823        total = total
824            .checked_add(count)
825            .ok_or_else(|| format!("{name} bucket count total overflows u64"))?;
826        merged.push((target_index, count));
827    }
828
829    let mut spans = Vec::<BucketSpan>::new();
830    let mut deltas = Vec::with_capacity(merged.len());
831    let mut previous_index = None::<i32>;
832    let mut previous = 0i64;
833    for (index, count) in merged {
834        if count == 0 {
835            continue;
836        }
837        match (spans.last_mut(), previous_index) {
838            (Some(span), Some(prev_index)) if prev_index.checked_add(1) == Some(index) => {
839                span.length = span
840                    .length
841                    .checked_add(1)
842                    .ok_or_else(|| format!("{name} bucket span length exceeds u32"))?;
843            }
844            (_, prev_index) => {
845                let offset = match prev_index {
846                    Some(prev_index) => index
847                        .checked_sub(prev_index)
848                        .and_then(|gap| gap.checked_sub(1))
849                        .ok_or_else(|| format!("{name} bucket span offset overflows i32"))?,
850                    None => index,
851                };
852                spans.push(BucketSpan { offset, length: 1 });
853            }
854        }
855        let count = i64::try_from(count)
856            .map_err(|_| format!("{name} bucket count {count} overflows i64"))?;
857        let delta = count
858            .checked_sub(previous)
859            .ok_or_else(|| format!("{name} bucket delta overflows i64"))?;
860        deltas.push(delta);
861        previous_index = Some(index);
862        // Bucket deltas continue across span gaps; omitted zero buckets do not reset them.
863        previous = count;
864    }
865
866    Ok((spans, deltas, total))
867}
868
869fn downscale_bucket_index(index: i32, downscale_shift: u32) -> std::result::Result<i32, String> {
870    if downscale_shift == 0 {
871        return Ok(index);
872    }
873    if downscale_shift >= i32::BITS {
874        return Ok(if index < 0 { -1 } else { 0 });
875    }
876
877    let divisor = 1i64
878        .checked_shl(downscale_shift)
879        .ok_or_else(|| format!("downscale shift {downscale_shift} is invalid"))?;
880    i32::try_from(i64::from(index).div_euclid(divisor))
881        .map_err(|_| format!("downscaled bucket index {index} overflows i32"))
882}
883
884#[derive(Debug, Clone, Copy, PartialEq, Eq)]
885enum AttributeType {
886    Resource,
887    Scope,
888    DataPoint,
889}
890
891fn write_attributes(
892    writer: &mut TableData,
893    row: &mut Vec<Value>,
894    attrs: Option<&Vec<KeyValue>>,
895    attribute_type: AttributeType,
896    metric_ctx: &OtlpMetricCtx,
897) -> Result<()> {
898    let Some(attrs) = attrs else {
899        return Ok(());
900    };
901
902    let mut tags = Vec::with_capacity(attrs.len());
903    for attr in attrs {
904        // TODO(sunng87): allow different type of values
905        let Some(value) = scalar_value_string(attr.value.as_ref()) else {
906            continue;
907        };
908        let key = match attribute_type {
909            AttributeType::Resource | AttributeType::DataPoint => {
910                translate_label_name(&attr.key, metric_ctx.metric_translation_strategy)
911            }
912            AttributeType::Scope => {
913                format!(
914                    "otel_scope_{}",
915                    translate_label_name(&attr.key, metric_ctx.metric_translation_strategy)
916                )
917            }
918        };
919        // OTLP attribute keys may include __name__, but Prometheus metric identity comes from
920        // the OTLP metric name. Reject collisions after translation and scope prefixing.
921        // https://opentelemetry.io/docs/specs/otel/compatibility/prometheus_and_openmetrics/#metric-metadata-1
922        if key == OTLP_AGGREGATION_TEMPORALITY_LABEL || key == crate::prom_store::METRIC_NAME_LABEL
923        {
924            return Err(error::InvalidOtlpMetricInputSnafu {
925                reason: format!(
926                    "OTLP attribute `{}` resolves to reserved label `{}`",
927                    attr.key, key
928                ),
929            }
930            .build());
931        }
932        tags.push((key, value));
933    }
934    row_writer::write_tags(writer, tags.into_iter(), row)?;
935
936    Ok(())
937}
938
939fn write_timestamp(
940    table: &mut TableData,
941    row: &mut Vec<Value>,
942    time_nano: i64,
943    metric_ctx: &OtlpMetricCtx,
944) -> Result<()> {
945    // Keep the full nanosecond precision whenever the request is headed for
946    // the metric engine: `Inserter::handle_metric_row_inserts` converts the
947    // timestamps to the physical table's time index unit (which may be
948    // micro/nanosecond). Only the non-metric prometheus-compatible path is
949    // fixed to milliseconds, to keep auto-created mito tables on the
950    // millisecond time index.
951    if metric_ctx.with_metric_engine {
952        row_writer::write_ts_to_nanos(
953            table,
954            greptime_timestamp(),
955            Some(time_nano),
956            Precision::Nanosecond,
957            row,
958        )
959    } else {
960        row_writer::write_ts_to_millis(
961            table,
962            greptime_timestamp(),
963            Some(time_nano / 1000000),
964            Precision::Millisecond,
965            row,
966        )
967    }
968}
969
970fn write_data_point_value(
971    table: &mut TableData,
972    row: &mut Vec<Value>,
973    field: &str,
974    value: &Option<number_data_point::Value>,
975) -> Result<()> {
976    match value {
977        Some(number_data_point::Value::AsInt(val)) => {
978            // we coerce all values to f64
979            row_writer::write_f64(table, field, *val as f64, row)?;
980        }
981        Some(number_data_point::Value::AsDouble(val)) => {
982            row_writer::write_f64(table, field, *val, row)?;
983        }
984        _ => {}
985    }
986    Ok(())
987}
988
989fn write_temporality_tag(
990    table: &mut TableData,
991    row: &mut Vec<Value>,
992    is_delta: bool,
993) -> Result<()> {
994    if is_delta {
995        row_writer::write_tag(
996            table,
997            OTLP_AGGREGATION_TEMPORALITY_LABEL,
998            GREPTIME_TEMPORALITY_DELTA,
999            row,
1000        )?;
1001    }
1002    Ok(())
1003}
1004
1005fn has_no_recorded_value(flags: u32) -> bool {
1006    flags & DataPointFlags::NoRecordedValueMask as u32 != 0
1007}
1008
1009fn write_tags_and_timestamp(
1010    table: &mut TableData,
1011    row: &mut Vec<Value>,
1012    resource_attrs: Option<&Vec<KeyValue>>,
1013    scope_attrs: Option<&Vec<KeyValue>>,
1014    data_point_attrs: Option<&Vec<KeyValue>>,
1015    timestamp_nanos: i64,
1016    metric_ctx: &OtlpMetricCtx,
1017) -> Result<()> {
1018    // TODO(shuiyisong): check `__type__` and `__unit__` tags in prometheus
1019    write_attributes(
1020        table,
1021        row,
1022        resource_attrs,
1023        AttributeType::Resource,
1024        metric_ctx,
1025    )?;
1026    write_attributes(table, row, scope_attrs, AttributeType::Scope, metric_ctx)?;
1027    write_attributes(
1028        table,
1029        row,
1030        data_point_attrs,
1031        AttributeType::DataPoint,
1032        metric_ctx,
1033    )?;
1034
1035    write_timestamp(table, row, timestamp_nanos, metric_ctx)?;
1036
1037    Ok(())
1038}
1039
1040/// encode this gauge metric
1041///
1042/// note that there can be multiple data points in the request, it's going to be
1043/// stored as multiple rows
1044fn encode_gauge(
1045    table_writer: &mut MultiTableData,
1046    name: &str,
1047    gauge: &Gauge,
1048    resource_attrs: Option<&Vec<KeyValue>>,
1049    scope_attrs: Option<&Vec<KeyValue>>,
1050    metric_ctx: &OtlpMetricCtx,
1051    exemplars: &mut Vec<exemplars::RowExemplars>,
1052) -> Result<()> {
1053    let table = table_writer.get_or_default_table_data(
1054        name,
1055        APPROXIMATE_COLUMN_COUNT,
1056        gauge.data_points.len(),
1057    );
1058
1059    for data_point in &gauge.data_points {
1060        let mut row = table.alloc_one_row();
1061        write_tags_and_timestamp(
1062            table,
1063            &mut row,
1064            resource_attrs,
1065            scope_attrs,
1066            Some(data_point.attributes.as_ref()),
1067            data_point.time_unix_nano as i64,
1068            metric_ctx,
1069        )?;
1070
1071        write_data_point_value(table, &mut row, greptime_value(), &data_point.value)?;
1072        exemplars::append(
1073            exemplars,
1074            &data_point.exemplars,
1075            name,
1076            table.num_rows(),
1077            metric_ctx,
1078        );
1079        table.add_row(row);
1080    }
1081
1082    Ok(())
1083}
1084
1085/// Encodes sums, preserving delta points as interval values with a temporality tag.
1086/// Ingestion is stateless: values are never accumulated across timestamps.
1087fn encode_sum(
1088    table_writer: &mut MultiTableData,
1089    name: &str,
1090    sum: &Sum,
1091    resource_attrs: Option<&Vec<KeyValue>>,
1092    scope_attrs: Option<&Vec<KeyValue>>,
1093    metric_ctx: &OtlpMetricCtx,
1094    exemplars: &mut Vec<exemplars::RowExemplars>,
1095) -> Result<()> {
1096    let is_delta = matches!(
1097        AggregationTemporality::try_from(sum.aggregation_temporality),
1098        Ok(AggregationTemporality::Delta)
1099    );
1100    let table = table_writer.get_or_default_table_data(
1101        name,
1102        APPROXIMATE_COLUMN_COUNT,
1103        sum.data_points.len(),
1104    );
1105
1106    for data_point in &sum.data_points {
1107        let mut row = table.alloc_one_row();
1108        write_tags_and_timestamp(
1109            table,
1110            &mut row,
1111            resource_attrs,
1112            scope_attrs,
1113            Some(data_point.attributes.as_ref()),
1114            data_point.time_unix_nano as i64,
1115            metric_ctx,
1116        )?;
1117        write_temporality_tag(table, &mut row, is_delta)?;
1118        if has_no_recorded_value(data_point.flags) {
1119            row_writer::write_f64(
1120                table,
1121                greptime_value(),
1122                f64::from_bits(PROMETHEUS_STALE_NAN_BITS),
1123                &mut row,
1124            )?;
1125        } else {
1126            write_data_point_value(table, &mut row, greptime_value(), &data_point.value)?;
1127        }
1128        exemplars::append(
1129            exemplars,
1130            &data_point.exemplars,
1131            name,
1132            table.num_rows(),
1133            metric_ctx,
1134        );
1135        table.add_row(row);
1136    }
1137
1138    Ok(())
1139}
1140
1141const HISTOGRAM_LE_COLUMN: &str = "le";
1142
1143/// Encode histogram data. This function returns 3 insert requests for 3 tables.
1144///
1145/// The implementation has been following Prometheus histogram table format:
1146///
1147/// - A `%metric%_bucket` table including `greptime_le` tag that stores bucket upper
1148///   limit, and `greptime_value` for bucket count
1149/// - A `%metric%_sum` table storing sum of samples
1150/// -  A `%metric%_count` table storing count of samples.
1151///
1152/// By its Prometheus compatibility, we hope to be able to use prometheus
1153/// quantile functions on this table.
1154/// Delta points retain their temporality tag and interval values. Bucket counts
1155/// are prefix-summed within each point, never accumulated across timestamps.
1156#[allow(clippy::too_many_arguments)]
1157fn encode_histogram(
1158    table_writer: &mut MultiTableData,
1159    name: &str,
1160    hist: &Histogram,
1161    resource_attrs: Option<&Vec<KeyValue>>,
1162    scope_attrs: Option<&Vec<KeyValue>>,
1163    metric_ctx: &OtlpMetricCtx,
1164    outcome: &mut MetricsIngestOutcome,
1165    exemplars: &mut Vec<exemplars::RowExemplars>,
1166) -> Result<()> {
1167    let normalized_name = name;
1168
1169    let bucket_table_name = format!("{}{}", normalized_name, BUCKET_TABLE_SUFFIX);
1170    let sum_table_name = format!("{}{}", normalized_name, SUM_TABLE_SUFFIX);
1171    let count_table_name = format!("{}{}", normalized_name, COUNT_TABLE_SUFFIX);
1172
1173    let is_delta = matches!(
1174        AggregationTemporality::try_from(hist.aggregation_temporality),
1175        Ok(AggregationTemporality::Delta)
1176    );
1177    let stale_value = f64::from_bits(PROMETHEUS_STALE_NAN_BITS);
1178    for (index, data_point) in hist.data_points.iter().enumerate() {
1179        if let Some(reason) = histogram_data_point_rejection(data_point, is_delta) {
1180            reject_data_points(outcome, 1, || {
1181                format!("metric `{name}` data point {index}: {reason}")
1182            })?;
1183            continue;
1184        }
1185
1186        let bucket_table =
1187            table_writer.get_or_default_table_data(&bucket_table_name, APPROXIMATE_COLUMN_COUNT, 0);
1188        let no_recorded_value = has_no_recorded_value(data_point.flags);
1189        if no_recorded_value {
1190            bucket_table.reserve_rows(data_point.explicit_bounds.len());
1191            bucket_table.reserve_rows(1);
1192        } else {
1193            bucket_table.reserve_rows(data_point.bucket_counts.len().max(1));
1194        }
1195        let bucket_values = if no_recorded_value {
1196            data_point
1197                .explicit_bounds
1198                .iter()
1199                .copied()
1200                .chain(std::iter::once(f64::INFINITY))
1201                .map(|bound| (bound, stale_value))
1202                .collect::<Vec<_>>()
1203        } else if data_point.bucket_counts.is_empty() && data_point.explicit_bounds.is_empty() {
1204            // OTLP count-only histograms map to one implicit Prometheus infinity bucket.
1205            vec![(f64::INFINITY, data_point.count as f64)]
1206        } else {
1207            let mut accumulated_count = 0u64;
1208            let mut values = Vec::with_capacity(data_point.bucket_counts.len());
1209            for (idx, count) in data_point.bucket_counts.iter().enumerate() {
1210                accumulated_count = accumulated_count.checked_add(*count).ok_or_else(|| {
1211                    error::InvalidParameterSnafu {
1212                        reason: format!(
1213                            "metric `{name}` data point {index}: bucket prefix overflows u64"
1214                        ),
1215                    }
1216                    .build()
1217                })?;
1218                let bound =
1219                    data_point.explicit_bounds.get(idx).copied().or_else(|| {
1220                        (idx == data_point.explicit_bounds.len()).then_some(f64::INFINITY)
1221                    });
1222                if let Some(bound) = bound {
1223                    values.push((bound, accumulated_count as f64));
1224                }
1225            }
1226            values
1227        };
1228        let mut by_bucket = vec![Vec::new(); bucket_values.len()];
1229        for exemplar in &data_point.exemplars {
1230            let value = match exemplars::value(exemplar) {
1231                Ok(value) => value,
1232                Err(_) => {
1233                    crate::metrics::EXEMPLARS_DROPPED_TOTAL
1234                        .with_label_values(&["otlp"])
1235                        .inc();
1236                    continue;
1237                }
1238            };
1239            let Some(bucket) = bucket_values.iter().position(|(bound, _)| {
1240                value <= *bound || (value.is_nan() && *bound == f64::INFINITY)
1241            }) else {
1242                crate::metrics::EXEMPLARS_DROPPED_TOTAL
1243                    .with_label_values(&["otlp"])
1244                    .inc();
1245                continue;
1246            };
1247            by_bucket[bucket].push(exemplar);
1248        }
1249        for ((bound, value), bucket_exemplars) in bucket_values.into_iter().zip(by_bucket) {
1250            let mut bucket_row = bucket_table.alloc_one_row();
1251            write_tags_and_timestamp(
1252                bucket_table,
1253                &mut bucket_row,
1254                resource_attrs,
1255                scope_attrs,
1256                Some(data_point.attributes.as_ref()),
1257                data_point.time_unix_nano as i64,
1258                metric_ctx,
1259            )?;
1260            write_temporality_tag(bucket_table, &mut bucket_row, is_delta)?;
1261            // Preserve existing OTLP series identities, including the "inf" bucket.
1262            row_writer::write_tag(bucket_table, HISTOGRAM_LE_COLUMN, bound, &mut bucket_row)?;
1263            row_writer::write_f64(bucket_table, greptime_value(), value, &mut bucket_row)?;
1264
1265            exemplars::append(
1266                exemplars,
1267                bucket_exemplars,
1268                &bucket_table_name,
1269                bucket_table.num_rows(),
1270                metric_ctx,
1271            );
1272            bucket_table.add_row(bucket_row);
1273        }
1274
1275        if let Some(sum) = data_point.sum {
1276            let sum_table = table_writer.get_or_default_table_data(
1277                &sum_table_name,
1278                APPROXIMATE_COLUMN_COUNT,
1279                hist.data_points.len(),
1280            );
1281            let mut sum_row = sum_table.alloc_one_row();
1282            write_tags_and_timestamp(
1283                sum_table,
1284                &mut sum_row,
1285                resource_attrs,
1286                scope_attrs,
1287                Some(data_point.attributes.as_ref()),
1288                data_point.time_unix_nano as i64,
1289                metric_ctx,
1290            )?;
1291            write_temporality_tag(sum_table, &mut sum_row, is_delta)?;
1292            row_writer::write_f64(
1293                sum_table,
1294                greptime_value(),
1295                if no_recorded_value { stale_value } else { sum },
1296                &mut sum_row,
1297            )?;
1298            sum_table.add_row(sum_row);
1299        }
1300
1301        let count_table = table_writer.get_or_default_table_data(
1302            &count_table_name,
1303            APPROXIMATE_COLUMN_COUNT,
1304            hist.data_points.len(),
1305        );
1306        let mut count_row = count_table.alloc_one_row();
1307        write_tags_and_timestamp(
1308            count_table,
1309            &mut count_row,
1310            resource_attrs,
1311            scope_attrs,
1312            Some(data_point.attributes.as_ref()),
1313            data_point.time_unix_nano as i64,
1314            metric_ctx,
1315        )?;
1316        write_temporality_tag(count_table, &mut count_row, is_delta)?;
1317        row_writer::write_f64(
1318            count_table,
1319            greptime_value(),
1320            if no_recorded_value {
1321                stale_value
1322            } else {
1323                data_point.count as f64
1324            },
1325            &mut count_row,
1326        )?;
1327        count_table.add_row(count_row);
1328        add_accepted_data_points(outcome, 1)?;
1329    }
1330
1331    Ok(())
1332}
1333
1334pub(crate) fn histogram_data_point_rejection(
1335    data_point: &HistogramDataPoint,
1336    is_delta: bool,
1337) -> Option<String> {
1338    if has_no_recorded_value(data_point.flags) {
1339        return None;
1340    }
1341
1342    if is_delta {
1343        let valid_empty_layout =
1344            data_point.bucket_counts.is_empty() && data_point.explicit_bounds.is_empty();
1345        let expected_buckets = data_point.explicit_bounds.len().checked_add(1);
1346        if !valid_empty_layout && expected_buckets != Some(data_point.bucket_counts.len()) {
1347            return Some(format!(
1348                "bucket_counts length {} must equal explicit_bounds length {} plus one",
1349                data_point.bucket_counts.len(),
1350                data_point.explicit_bounds.len()
1351            ));
1352        }
1353        if data_point
1354            .explicit_bounds
1355            .iter()
1356            .any(|bound| !bound.is_finite())
1357            || data_point
1358                .explicit_bounds
1359                .windows(2)
1360                .any(|bounds| bounds[0] >= bounds[1])
1361        {
1362            return Some("explicit_bounds must be finite and strictly increasing".to_string());
1363        }
1364        if data_point.count == 0 && data_point.sum.is_some_and(|sum| sum != 0.0) {
1365            return Some("sum must be absent or zero when count is zero".to_string());
1366        }
1367    }
1368
1369    let bucket_total = data_point
1370        .bucket_counts
1371        .iter()
1372        .try_fold(0u64, |total, count| total.checked_add(*count));
1373    let Some(bucket_total) = bucket_total else {
1374        return Some("bucket prefix overflows u64".to_string());
1375    };
1376    if is_delta && !data_point.bucket_counts.is_empty() && bucket_total != data_point.count {
1377        return Some(format!(
1378            "buckets contain {bucket_total} observations, declared count is {}",
1379            data_point.count
1380        ));
1381    }
1382
1383    None
1384}
1385
1386fn encode_summary(
1387    table_writer: &mut MultiTableData,
1388    name: &str,
1389    summary: &Summary,
1390    resource_attrs: Option<&Vec<KeyValue>>,
1391    scope_attrs: Option<&Vec<KeyValue>>,
1392    metric_ctx: &OtlpMetricCtx,
1393) -> Result<()> {
1394    // 1. quantile table
1395    // 2. count table
1396    // 3. sum table
1397
1398    let metric_name = name;
1399    let count_name = format!("{}{}", metric_name, COUNT_TABLE_SUFFIX);
1400    let sum_name = format!("{}{}", metric_name, SUM_TABLE_SUFFIX);
1401
1402    for data_point in &summary.data_points {
1403        {
1404            let quantile_table = table_writer.get_or_default_table_data(
1405                metric_name,
1406                APPROXIMATE_COLUMN_COUNT,
1407                summary.data_points.len(),
1408            );
1409
1410            for quantile in &data_point.quantile_values {
1411                let mut row = quantile_table.alloc_one_row();
1412                write_tags_and_timestamp(
1413                    quantile_table,
1414                    &mut row,
1415                    resource_attrs,
1416                    scope_attrs,
1417                    Some(data_point.attributes.as_ref()),
1418                    data_point.time_unix_nano as i64,
1419                    metric_ctx,
1420                )?;
1421                row_writer::write_tag(quantile_table, "quantile", quantile.quantile, &mut row)?;
1422                row_writer::write_f64(quantile_table, greptime_value(), quantile.value, &mut row)?;
1423                quantile_table.add_row(row);
1424            }
1425        }
1426        {
1427            let count_table = table_writer.get_or_default_table_data(
1428                &count_name,
1429                APPROXIMATE_COLUMN_COUNT,
1430                summary.data_points.len(),
1431            );
1432            let mut row = count_table.alloc_one_row();
1433            write_tags_and_timestamp(
1434                count_table,
1435                &mut row,
1436                resource_attrs,
1437                scope_attrs,
1438                Some(data_point.attributes.as_ref()),
1439                data_point.time_unix_nano as i64,
1440                metric_ctx,
1441            )?;
1442
1443            row_writer::write_f64(
1444                count_table,
1445                greptime_value(),
1446                data_point.count as f64,
1447                &mut row,
1448            )?;
1449
1450            count_table.add_row(row);
1451        }
1452        {
1453            let sum_table = table_writer.get_or_default_table_data(
1454                &sum_name,
1455                APPROXIMATE_COLUMN_COUNT,
1456                summary.data_points.len(),
1457            );
1458
1459            let mut row = sum_table.alloc_one_row();
1460            write_tags_and_timestamp(
1461                sum_table,
1462                &mut row,
1463                resource_attrs,
1464                scope_attrs,
1465                Some(data_point.attributes.as_ref()),
1466                data_point.time_unix_nano as i64,
1467                metric_ctx,
1468            )?;
1469
1470            row_writer::write_f64(sum_table, greptime_value(), data_point.sum, &mut row)?;
1471
1472            sum_table.add_row(row);
1473        }
1474    }
1475
1476    Ok(())
1477}
1478
1479#[cfg(test)]
1480mod tests {
1481    use api::v1::ColumnDataType;
1482    use common_query::prelude::set_default_prefix;
1483    use otel_arrow_rust::proto::opentelemetry::common::v1::any_value::Value as Val;
1484    use otel_arrow_rust::proto::opentelemetry::common::v1::{AnyValue, InstrumentationScope};
1485    use otel_arrow_rust::proto::opentelemetry::metrics::v1::number_data_point::Value;
1486    use otel_arrow_rust::proto::opentelemetry::metrics::v1::summary_data_point::ValueAtQuantile;
1487    use otel_arrow_rust::proto::opentelemetry::metrics::v1::{
1488        AggregationTemporality, HistogramDataPoint, NumberDataPoint, SummaryDataPoint,
1489    };
1490    use otel_arrow_rust::proto::opentelemetry::resource::v1::Resource;
1491    use session::protocol_ctx::OtlpMetricTranslationStrategy;
1492
1493    use super::*;
1494
1495    mod delta;
1496
1497    fn keyvalue(key: &str, value: &str) -> KeyValue {
1498        KeyValue {
1499            key: key.into(),
1500            value: Some(AnyValue {
1501                value: Some(Val::StringValue(value.into())),
1502            }),
1503        }
1504    }
1505
1506    fn bool_keyvalue(key: &str, value: bool) -> KeyValue {
1507        KeyValue {
1508            key: key.into(),
1509            value: Some(AnyValue {
1510                value: Some(Val::BoolValue(value)),
1511            }),
1512        }
1513    }
1514
1515    fn descriptor_ctx() -> OtlpMetricCtx {
1516        OtlpMetricCtx {
1517            resource_info: true,
1518            ..Default::default()
1519        }
1520    }
1521
1522    fn attr_value(attrs: &[KeyValue], key: &str) -> Option<String> {
1523        attrs
1524            .iter()
1525            .find(|kv| kv.key == key)
1526            .and_then(|kv| scalar_value_string(kv.value.as_ref()))
1527    }
1528
1529    fn gauge_request(
1530        resource_attrs: Vec<KeyValue>,
1531        metric_name: &str,
1532    ) -> ExportMetricsServiceRequest {
1533        use otel_arrow_rust::proto::opentelemetry::resource::v1::Resource;
1534        ExportMetricsServiceRequest {
1535            resource_metrics: vec![ResourceMetrics {
1536                resource: Some(Resource {
1537                    attributes: resource_attrs,
1538                    ..Default::default()
1539                }),
1540                scope_metrics: vec![ScopeMetrics {
1541                    metrics: vec![Metric {
1542                        name: metric_name.to_string(),
1543                        data: Some(metric::Data::Gauge(Gauge {
1544                            data_points: vec![NumberDataPoint {
1545                                time_unix_nano: 1_000_000,
1546                                value: Some(Value::AsInt(1)),
1547                                ..Default::default()
1548                            }],
1549                        })),
1550                        ..Default::default()
1551                    }],
1552                    ..Default::default()
1553                }],
1554                ..Default::default()
1555            }],
1556        }
1557    }
1558
1559    fn column_names(request: &RowInsertRequests, table: &str) -> Vec<String> {
1560        request
1561            .inserts
1562            .iter()
1563            .find(|r| r.table_name == table)
1564            .unwrap_or_else(|| panic!("missing table {table}"))
1565            .rows
1566            .as_ref()
1567            .unwrap()
1568            .schema
1569            .iter()
1570            .map(|c| c.column_name.clone())
1571            .collect()
1572    }
1573
1574    #[test]
1575    fn test_conversion_synthesizes_resource_descriptor() {
1576        set_default_prefix(None).unwrap();
1577        let request = gauge_request(
1578            vec![keyvalue("service.name", "api"), keyvalue("host.id", "h-1")],
1579            "my_gauge",
1580        );
1581        let conversion = to_grpc_insert_requests(request, &mut descriptor_ctx()).unwrap();
1582
1583        // descriptor keeps raw OTel keys while the metric table's labels went
1584        // through the (default underscore-escaping) translation strategy
1585        let resource_info = conversion.resource_info.expect("descriptor synthesized");
1586        let descriptor_cols = column_names(&resource_info, OTEL_RESOURCE_INFO_TABLE_NAME);
1587        assert!(descriptor_cols.contains(&"host.id".to_string()));
1588        assert!(descriptor_cols.contains(&"service.name".to_string()));
1589        assert!(descriptor_cols.contains(&"job".to_string()));
1590        let metric_cols = column_names(&conversion.requests, "my_gauge");
1591        assert!(metric_cols.contains(&"service_name".to_string()));
1592        assert!(!metric_cols.contains(&"service.name".to_string()));
1593        // host.id is not in the promote list: only the descriptor keeps it
1594        assert!(!metric_cols.contains(&"host_id".to_string()));
1595
1596        let decoded = decode(&conversion.semantic_index);
1597        let t = &decoded[OTEL_RESOURCE_INFO_TABLE_NAME];
1598        assert_eq!(
1599            t.get(SEMANTIC_METRIC_TYPE).map(String::as_str),
1600            Some("info")
1601        );
1602        assert_eq!(
1603            t.get(SEMANTIC_METRIC_METADATA_QUALITY).map(String::as_str),
1604            Some("declared")
1605        );
1606    }
1607
1608    #[test]
1609    fn test_descriptors_and_exemplars_exclude_points_rejected_by_destination_preflight() {
1610        let mut request = gauge_request(vec![keyvalue("service.name", "api")], "accepted");
1611        let Some(metric::Data::Gauge(gauge)) = request.resource_metrics[0].scope_metrics[0].metrics
1612            [0]
1613        .data
1614        .as_mut() else {
1615            unreachable!()
1616        };
1617        gauge.data_points[0].exemplars.push(Exemplar {
1618            time_unix_nano: 1,
1619            value: Some(exemplar::Value::AsDouble(0.5)),
1620            ..Default::default()
1621        });
1622        let mut rejected = request.resource_metrics[0].clone();
1623        let metric = &mut rejected.scope_metrics[0].metrics[0];
1624        metric.name = "rejected".into();
1625        let Some(metric::Data::Gauge(gauge)) = metric.data.as_mut() else {
1626            unreachable!()
1627        };
1628        gauge.data_points[0].time_unix_nano =
1629            2 * common_catalog::consts::SEMANTIC_GRAPH_WINDOW_NANOS as u64;
1630        request.resource_metrics.push(rejected);
1631        let mut prepared = prepare_metrics(
1632            request,
1633            &mut OtlpMetricCtx {
1634                with_metric_engine: true,
1635                ..descriptor_ctx()
1636            },
1637        )
1638        .unwrap();
1639        for request in &mut prepared.requests {
1640            let rows = request.rows.as_mut().unwrap();
1641            rows.schema.push(ColumnSchema {
1642                column_name: "defaulted_host".into(),
1643                datatype: api::v1::ColumnDataType::String as i32,
1644                semantic_type: SemanticType::Tag as i32,
1645                ..Default::default()
1646            });
1647            for row in &mut rows.rows {
1648                row.values.push(api::v1::Value {
1649                    value_data: Some(ValueData::StringValue("unknown".into())),
1650                });
1651            }
1652        }
1653        let conversion = prepared
1654            .finish(
1655                vec![None, Some("incompatible schema".into())],
1656                &Default::default(),
1657            )
1658            .unwrap();
1659        assert_eq!(
1660            (
1661                conversion.outcome.accepted_data_points,
1662                conversion.outcome.rejected_data_points
1663            ),
1664            (1, 1)
1665        );
1666        assert_eq!(conversion.exemplars.len(), 1);
1667        assert_eq!(
1668            conversion.exemplars[0].series_labels["defaulted_host"],
1669            "unknown"
1670        );
1671        let (exemplars, _) = crate::exemplar::into_rows(conversion.exemplars).unwrap();
1672        let exemplar_rows = exemplars.inserts[0].rows.as_ref().unwrap();
1673        let metric = exemplar_rows
1674            .schema
1675            .iter()
1676            .position(|c| c.column_name == common_query::prelude::EXEMPLAR_METRIC_COLUMN)
1677            .unwrap();
1678        assert_eq!(
1679            exemplar_rows.rows[0].values[metric].value_data,
1680            Some(ValueData::StringValue("accepted".into()))
1681        );
1682        let descriptors = conversion.resource_info.unwrap();
1683        let rows = descriptors.inserts[0].rows.as_ref().unwrap();
1684        assert_eq!(rows.rows.len(), 1);
1685        let timestamp = rows
1686            .schema
1687            .iter()
1688            .position(|c| c.column_name == greptime_timestamp())
1689            .unwrap();
1690        assert_eq!(
1691            rows.rows[0].values[timestamp].value_data,
1692            Some(ValueData::TimestampMillisecondValue(1))
1693        );
1694    }
1695
1696    #[test]
1697    fn test_finish_rejects_mismatched_point_counts() {
1698        for (requests, rejections) in [(0, 1), (2, 1), (1, 0), (1, 2)] {
1699            let mut prepared = prepare_metrics(
1700                gauge_request(vec![], "gauge"),
1701                &mut OtlpMetricCtx::default(),
1702            )
1703            .unwrap();
1704            prepared.requests.resize(requests, Default::default());
1705            let err = prepared
1706                .finish(vec![None; rejections], &Default::default())
1707                .unwrap_err();
1708            assert!(matches!(err, error::Error::Internal { .. }));
1709        }
1710    }
1711
1712    #[test]
1713    fn test_conversion_describes_only_accepted_exponential_histogram_windows() {
1714        set_default_prefix(None).unwrap();
1715        let window = common_catalog::consts::SEMANTIC_GRAPH_WINDOW_NANOS as u64;
1716        let mut valid = exponential_point();
1717        valid.time_unix_nano = window + 1_000_000;
1718
1719        let mut invalid_count = valid.clone();
1720        invalid_count.time_unix_nano += 8_000_000;
1721        invalid_count.count += 1;
1722        let mut invalid_scale = valid.clone();
1723        invalid_scale.time_unix_nano += window;
1724        invalid_scale.scale = -5;
1725        // OTLP bucket conversion succeeds, but the shared native encoder rejects this index.
1726        let invalid_native = ExponentialHistogramDataPoint {
1727            time_unix_nano: 3 * window + 1_000_000,
1728            count: 1,
1729            positive: Some(exponential_buckets(2000, vec![1])),
1730            ..Default::default()
1731        };
1732        assert!(
1733            exponential_histogram_value(&invalid_native, AggregationTemporality::Cumulative)
1734                .unwrap_err()
1735                .contains("out of range")
1736        );
1737        let stale = ExponentialHistogramDataPoint {
1738            time_unix_nano: 4 * window + 1_000_000,
1739            count: u64::MAX,
1740            scale: i32::MIN,
1741            flags: DataPointFlags::NoRecordedValueMask as u32,
1742            ..Default::default()
1743        };
1744        let invalid_time = ExponentialHistogramDataPoint {
1745            time_unix_nano: 5 * window + 1_000_000,
1746            start_time_unix_nano: 5 * window + 2_000_000,
1747            ..stale.clone()
1748        };
1749        let rejected = vec![invalid_count, invalid_scale, invalid_native, invalid_time];
1750        let mut mixed = rejected.clone();
1751        mixed.extend([valid, stale]);
1752        for (points, expected) in [
1753            (rejected, vec![]),
1754            (
1755                mixed,
1756                vec![
1757                    (window / 1_000_000 + 1) as i64,
1758                    (4 * window / 1_000_000 + 1) as i64,
1759                ],
1760            ),
1761        ] {
1762            let mut request = metrics_request(vec![exponential_metric(
1763                "latency",
1764                points,
1765                AggregationTemporality::Cumulative,
1766            )]);
1767            request.resource_metrics[0].resource = Some(Resource {
1768                attributes: vec![keyvalue("service.name", "api")],
1769                ..Default::default()
1770            });
1771            let conversion = to_grpc_insert_requests(request, &mut descriptor_ctx()).unwrap();
1772            assert_eq!(conversion.outcome.rejected_data_points, 4);
1773            assert_eq!(
1774                conversion.outcome.accepted_data_points,
1775                expected.len() as i64
1776            );
1777            assert_eq!(conversion.rows, expected.len());
1778            assert_eq!(conversion.resource_info.is_some(), !expected.is_empty());
1779            let actual = conversion
1780                .resource_info
1781                .map(|descriptor| {
1782                    let rows = descriptor.inserts[0].rows.as_ref().unwrap();
1783                    let ts_index = rows
1784                        .schema
1785                        .iter()
1786                        .position(|column| column.column_name == greptime_timestamp())
1787                        .unwrap();
1788                    rows.rows
1789                        .iter()
1790                        .map(|row| match row.values[ts_index].value_data {
1791                            Some(ValueData::TimestampMillisecondValue(ts)) => ts,
1792                            _ => panic!("expected descriptor timestamp"),
1793                        })
1794                        .collect::<Vec<_>>()
1795                })
1796                .unwrap_or_default();
1797            assert_eq!(actual, expected);
1798        }
1799    }
1800
1801    #[test]
1802    fn test_conversion_skips_descriptor_on_metric_name_collision() {
1803        set_default_prefix(None).unwrap();
1804        for (enabled, attributes, warns) in [
1805            (true, vec![keyvalue("service.name", "api")], true),
1806            (false, vec![keyvalue("service.name", "api")], false),
1807            (true, vec![keyvalue("unprojected", "value")], false),
1808        ] {
1809            let logs = tempfile::NamedTempFile::new().unwrap();
1810            let writer = logs.reopen().unwrap();
1811            let subscriber = common_telemetry::tracing_subscriber::fmt()
1812                .without_time()
1813                .with_ansi(false)
1814                .with_writer(move || writer.try_clone().unwrap())
1815                .finish();
1816            let conversion = tracing::subscriber::with_default(subscriber, || {
1817                let mut ctx = descriptor_ctx();
1818                ctx.resource_info = enabled;
1819                to_grpc_insert_requests(
1820                    gauge_request(attributes, OTEL_RESOURCE_INFO_TABLE_NAME),
1821                    &mut ctx,
1822                )
1823                .unwrap()
1824            });
1825            let diagnostics = std::fs::read_to_string(logs.path()).unwrap();
1826            assert_eq!(
1827                diagnostics.contains("Skipping OTLP resource descriptor"),
1828                warns
1829            );
1830            assert!(conversion.resource_info.is_none());
1831            assert!(
1832                conversion
1833                    .requests
1834                    .inserts
1835                    .iter()
1836                    .any(|r| r.table_name == OTEL_RESOURCE_INFO_TABLE_NAME)
1837            );
1838        }
1839    }
1840
1841    #[test]
1842    fn test_job_composition_follows_service_namespace() {
1843        let mut attrs = vec![
1844            keyvalue("service.name", "api"),
1845            keyvalue("service.namespace", "shop"),
1846        ];
1847        process_resource_attrs(&mut attrs, &OtlpMetricCtx::default());
1848        assert_eq!(attr_value(&attrs, "job").as_deref(), Some("shop/api"));
1849
1850        let mut attrs = vec![keyvalue("service.name", "api")];
1851        process_resource_attrs(&mut attrs, &OtlpMetricCtx::default());
1852        assert_eq!(attr_value(&attrs, "job").as_deref(), Some("api"));
1853
1854        let mut attrs = vec![
1855            keyvalue("service.name", "api"),
1856            keyvalue("service.namespace", ""),
1857        ];
1858        process_resource_attrs(&mut attrs, &OtlpMetricCtx::default());
1859        assert_eq!(attr_value(&attrs, "job").as_deref(), Some("api"));
1860
1861        // no service.name: no job is fabricated
1862        let mut attrs = vec![
1863            keyvalue("service.namespace", "shop"),
1864            keyvalue("service.instance.id", "inst-1"),
1865        ];
1866        process_resource_attrs(&mut attrs, &OtlpMetricCtx::default());
1867        assert_eq!(attr_value(&attrs, "job"), None);
1868        assert_eq!(attr_value(&attrs, "instance").as_deref(), Some("inst-1"));
1869    }
1870
1871    #[test]
1872    fn test_reject_metric_name_attribute() {
1873        set_default_prefix(None).unwrap();
1874        for (strategy, key) in [
1875            (
1876                OtlpMetricTranslationStrategy::UnderscoreEscapingWithSuffixes,
1877                "__name__",
1878            ),
1879            (
1880                OtlpMetricTranslationStrategy::UnderscoreEscapingWithoutSuffixes,
1881                "__name__",
1882            ),
1883            (
1884                OtlpMetricTranslationStrategy::NoUtf8EscapingWithSuffixes,
1885                "__name__",
1886            ),
1887            (OtlpMetricTranslationStrategy::NoTranslation, "__name__"),
1888            (
1889                OtlpMetricTranslationStrategy::UnderscoreEscapingWithSuffixes,
1890                "..name..",
1891            ),
1892        ] {
1893            for source in [
1894                AttributeType::Resource,
1895                AttributeType::Scope,
1896                AttributeType::DataPoint,
1897            ] {
1898                let mut request = gauge_request(vec![], "source_metric");
1899                let resource = &mut request.resource_metrics[0];
1900                let scope = &mut resource.scope_metrics[0];
1901                let Some(metric::Data::Gauge(gauge)) = scope.metrics[0].data.as_mut() else {
1902                    unreachable!();
1903                };
1904                gauge.data_points[0].exemplars.push(Exemplar {
1905                    value: Some(exemplar::Value::AsDouble(0.25)),
1906                    time_unix_nano: 1_000_000,
1907                    ..Default::default()
1908                });
1909                let attributes = vec![keyvalue(key, "wrong_metric")];
1910                match source {
1911                    AttributeType::Resource => {
1912                        resource.resource.as_mut().unwrap().attributes = attributes;
1913                    }
1914                    AttributeType::Scope => {
1915                        scope.scope = Some(InstrumentationScope {
1916                            attributes,
1917                            ..Default::default()
1918                        });
1919                    }
1920                    AttributeType::DataPoint => gauge.data_points[0].attributes = attributes,
1921                }
1922                let mut ctx = OtlpMetricCtx {
1923                    with_metric_engine: true,
1924                    metric_translation_strategy: strategy,
1925                    promote_all_resource_attrs: true,
1926                    promote_scope_attrs: true,
1927                    ..Default::default()
1928                };
1929                let result = to_grpc_insert_requests(request, &mut ctx);
1930                if source == AttributeType::Scope {
1931                    let conversion = result.unwrap();
1932                    assert_eq!(1, conversion.exemplars.len());
1933                    assert!(
1934                        column_names(&conversion.requests, "source_metric")
1935                            .contains(&"otel_scope___name__".to_string())
1936                    );
1937                } else {
1938                    let conversion = result.unwrap();
1939                    assert_eq!(conversion.outcome.accepted_data_points, 0);
1940                    assert_eq!(conversion.outcome.rejected_data_points, 1);
1941                    assert_eq!(conversion.exemplars.len(), 0);
1942                    assert!(conversion.outcome.error_message.unwrap().contains(&format!(
1943                        "OTLP attribute `{key}` resolves to reserved label `__name__`"
1944                    )));
1945                }
1946            }
1947        }
1948    }
1949
1950    #[test]
1951    fn test_boolean_attributes_keep_series_distinct() {
1952        set_default_prefix(None).unwrap();
1953        let request = metrics_request(vec![Metric {
1954            name: "bool_repro".to_string(),
1955            data: Some(metric::Data::Sum(Sum {
1956                data_points: vec![
1957                    NumberDataPoint {
1958                        attributes: vec![bool_keyvalue("failed", false)],
1959                        start_time_unix_nano: 1_000_000,
1960                        time_unix_nano: 2_000_000,
1961                        value: Some(Value::AsDouble(100.0)),
1962                        ..Default::default()
1963                    },
1964                    NumberDataPoint {
1965                        attributes: vec![bool_keyvalue("failed", true)],
1966                        start_time_unix_nano: 1_000_000,
1967                        time_unix_nano: 2_000_000,
1968                        value: Some(Value::AsDouble(7.0)),
1969                        ..Default::default()
1970                    },
1971                ],
1972                aggregation_temporality: AggregationTemporality::Cumulative as i32,
1973                is_monotonic: true,
1974            })),
1975            ..Default::default()
1976        }]);
1977        let conversion = to_grpc_insert_requests(request, &mut OtlpMetricCtx::default()).unwrap();
1978
1979        let rows = conversion
1980            .requests
1981            .inserts
1982            .iter()
1983            .find(|insert| insert.table_name == "bool_repro_total")
1984            .expect("missing bool_repro_total table")
1985            .rows
1986            .as_ref()
1987            .unwrap();
1988        assert_eq!(2, rows.rows.len());
1989        let failed = rows
1990            .schema
1991            .iter()
1992            .position(|column| column.column_name == "failed")
1993            .expect("boolean attribute `failed` was dropped from the label set");
1994        let values = rows
1995            .rows
1996            .iter()
1997            .map(|row| row.values[failed].value_data.clone())
1998            .collect::<Vec<_>>();
1999        assert_eq!(
2000            vec![
2001                Some(ValueData::StringValue("false".to_string())),
2002                Some(ValueData::StringValue("true".to_string())),
2003            ],
2004            values
2005        );
2006    }
2007
2008    #[test]
2009    fn test_encode_gauge() {
2010        let mut tables = MultiTableData::default();
2011
2012        let data_points = vec![
2013            NumberDataPoint {
2014                attributes: vec![keyvalue("host", "testsevrer")],
2015                time_unix_nano: 100,
2016                value: Some(Value::AsInt(100)),
2017                ..Default::default()
2018            },
2019            NumberDataPoint {
2020                attributes: vec![keyvalue("host", "testserver")],
2021                time_unix_nano: 105,
2022                value: Some(Value::AsInt(105)),
2023                ..Default::default()
2024            },
2025        ];
2026        let gauge = Gauge { data_points };
2027        encode_gauge(
2028            &mut tables,
2029            "datamon",
2030            &gauge,
2031            Some(&vec![]),
2032            Some(&vec![keyvalue("scope", "otel")]),
2033            &OtlpMetricCtx::default(),
2034            &mut Vec::new(),
2035        )
2036        .unwrap();
2037
2038        let table = tables.get_or_default_table_data("datamon", 0, 0);
2039        assert_eq!(table.num_rows(), 2);
2040        assert_eq!(table.num_columns(), 4);
2041        assert_eq!(
2042            table
2043                .columns()
2044                .iter()
2045                .map(|c| &c.column_name)
2046                .collect::<Vec<&String>>(),
2047            vec![
2048                "otel_scope_scope",
2049                "host",
2050                greptime_timestamp(),
2051                greptime_value()
2052            ]
2053        );
2054    }
2055
2056    #[test]
2057    fn test_encode_sum() {
2058        let mut tables = MultiTableData::default();
2059
2060        let data_points = vec![
2061            NumberDataPoint {
2062                attributes: vec![keyvalue("host", "testserver")],
2063                time_unix_nano: 100,
2064                value: Some(Value::AsInt(100)),
2065                ..Default::default()
2066            },
2067            NumberDataPoint {
2068                attributes: vec![keyvalue("host", "testserver")],
2069                time_unix_nano: 105,
2070                value: Some(Value::AsInt(0)),
2071                ..Default::default()
2072            },
2073        ];
2074        let sum = Sum {
2075            data_points,
2076            ..Default::default()
2077        };
2078        encode_sum(
2079            &mut tables,
2080            "datamon",
2081            &sum,
2082            Some(&vec![]),
2083            Some(&vec![keyvalue("scope", "otel")]),
2084            &OtlpMetricCtx::default(),
2085            &mut Vec::new(),
2086        )
2087        .unwrap();
2088
2089        let table = tables.get_or_default_table_data("datamon", 0, 0);
2090        assert_eq!(table.num_rows(), 2);
2091        assert_eq!(table.num_columns(), 4);
2092        assert_eq!(
2093            table
2094                .columns()
2095                .iter()
2096                .map(|c| &c.column_name)
2097                .collect::<Vec<&String>>(),
2098            vec![
2099                "otel_scope_scope",
2100                "host",
2101                greptime_timestamp(),
2102                greptime_value()
2103            ]
2104        );
2105    }
2106
2107    #[test]
2108    fn test_encode_summary() {
2109        let mut tables = MultiTableData::default();
2110
2111        let data_points = vec![SummaryDataPoint {
2112            attributes: vec![keyvalue("host", "testserver")],
2113            time_unix_nano: 100,
2114            count: 25,
2115            sum: 5400.0,
2116            quantile_values: vec![
2117                ValueAtQuantile {
2118                    quantile: 0.90,
2119                    value: 1000.0,
2120                },
2121                ValueAtQuantile {
2122                    quantile: 0.95,
2123                    value: 3030.0,
2124                },
2125            ],
2126            ..Default::default()
2127        }];
2128        let summary = Summary { data_points };
2129        encode_summary(
2130            &mut tables,
2131            "datamon",
2132            &summary,
2133            Some(&vec![]),
2134            Some(&vec![keyvalue("scope", "otel")]),
2135            &OtlpMetricCtx::default(),
2136        )
2137        .unwrap();
2138
2139        let table = tables.get_or_default_table_data("datamon", 0, 0);
2140        assert_eq!(table.num_rows(), 2);
2141        assert_eq!(table.num_columns(), 5);
2142        assert_eq!(
2143            table
2144                .columns()
2145                .iter()
2146                .map(|c| &c.column_name)
2147                .collect::<Vec<&String>>(),
2148            vec![
2149                "otel_scope_scope",
2150                "host",
2151                greptime_timestamp(),
2152                "quantile",
2153                greptime_value()
2154            ]
2155        );
2156
2157        let table = tables.get_or_default_table_data("datamon_count", 0, 0);
2158        assert_eq!(table.num_rows(), 1);
2159        assert_eq!(table.num_columns(), 4);
2160        assert_eq!(
2161            table
2162                .columns()
2163                .iter()
2164                .map(|c| &c.column_name)
2165                .collect::<Vec<&String>>(),
2166            vec![
2167                "otel_scope_scope",
2168                "host",
2169                greptime_timestamp(),
2170                greptime_value()
2171            ]
2172        );
2173
2174        let table = tables.get_or_default_table_data("datamon_sum", 0, 0);
2175        assert_eq!(table.num_rows(), 1);
2176        assert_eq!(table.num_columns(), 4);
2177        assert_eq!(
2178            table
2179                .columns()
2180                .iter()
2181                .map(|c| &c.column_name)
2182                .collect::<Vec<&String>>(),
2183            vec![
2184                "otel_scope_scope",
2185                "host",
2186                greptime_timestamp(),
2187                greptime_value()
2188            ]
2189        );
2190    }
2191
2192    #[test]
2193    fn test_encode_histogram() {
2194        let mut tables = MultiTableData::default();
2195        let mut outcome = MetricsIngestOutcome::default();
2196
2197        let data_points = vec![HistogramDataPoint {
2198            attributes: vec![keyvalue("host", "testserver")],
2199            time_unix_nano: 100,
2200            start_time_unix_nano: 23,
2201            count: 25,
2202            sum: Some(100.),
2203            max: Some(200.),
2204            min: Some(0.03),
2205            bucket_counts: vec![2, 4, 6, 9, 4],
2206            explicit_bounds: vec![0.1, 1., 10., 100.],
2207            ..Default::default()
2208        }];
2209
2210        let histogram = Histogram {
2211            data_points,
2212            aggregation_temporality: AggregationTemporality::Delta.into(),
2213        };
2214        encode_histogram(
2215            &mut tables,
2216            "histo",
2217            &histogram,
2218            Some(&vec![]),
2219            Some(&vec![keyvalue("scope", "otel")]),
2220            &OtlpMetricCtx::default(),
2221            &mut outcome,
2222            &mut Vec::new(),
2223        )
2224        .unwrap();
2225
2226        assert_eq!(3, tables.num_tables());
2227        assert_eq!(1, outcome.accepted_data_points);
2228
2229        // bucket table
2230        let bucket_table = tables.get_or_default_table_data("histo_bucket", 0, 0);
2231        assert_eq!(bucket_table.num_rows(), 5);
2232        assert_eq!(bucket_table.num_columns(), 6);
2233        assert_eq!(
2234            bucket_table
2235                .columns()
2236                .iter()
2237                .map(|c| &c.column_name)
2238                .collect::<Vec<&String>>(),
2239            vec![
2240                "otel_scope_scope",
2241                "host",
2242                greptime_timestamp(),
2243                OTLP_AGGREGATION_TEMPORALITY_LABEL,
2244                "le",
2245                greptime_value(),
2246            ]
2247        );
2248
2249        let sum_table = tables.get_or_default_table_data("histo_sum", 0, 0);
2250        assert_eq!(sum_table.num_rows(), 1);
2251        assert_eq!(sum_table.num_columns(), 5);
2252        assert_eq!(
2253            sum_table
2254                .columns()
2255                .iter()
2256                .map(|c| &c.column_name)
2257                .collect::<Vec<&String>>(),
2258            vec![
2259                "otel_scope_scope",
2260                "host",
2261                greptime_timestamp(),
2262                OTLP_AGGREGATION_TEMPORALITY_LABEL,
2263                greptime_value()
2264            ]
2265        );
2266
2267        let count_table = tables.get_or_default_table_data("histo_count", 0, 0);
2268        assert_eq!(count_table.num_rows(), 1);
2269        assert_eq!(count_table.num_columns(), 5);
2270        assert_eq!(
2271            count_table
2272                .columns()
2273                .iter()
2274                .map(|c| &c.column_name)
2275                .collect::<Vec<&String>>(),
2276            vec![
2277                "otel_scope_scope",
2278                "host",
2279                greptime_timestamp(),
2280                OTLP_AGGREGATION_TEMPORALITY_LABEL,
2281                greptime_value()
2282            ]
2283        );
2284    }
2285
2286    use std::collections::BTreeMap;
2287
2288    use table::requests::validate_semantic_option;
2289
2290    fn decode(index: &SemanticIndex) -> BTreeMap<String, BTreeMap<String, String>> {
2291        let nested: BTreeMap<String, BTreeMap<String, BTreeMap<String, String>>> =
2292            serde_json::from_str(&index.encode("public").expect("non-empty index")).unwrap();
2293        nested.into_values().next().unwrap()
2294    }
2295
2296    fn record(metric: &Metric, metric_type: MetricType, name: &str) -> SemanticIndex {
2297        let ctx = OtlpMetricCtx {
2298            metric_type,
2299            ..Default::default()
2300        };
2301        let mut index = SemanticIndex::default();
2302        record_metric_semantics(
2303            &mut index,
2304            metric,
2305            name,
2306            &emitted_semantic_tables(&ctx.metric_type, name),
2307        );
2308        index
2309    }
2310
2311    #[test]
2312    fn test_metric_type_constants_validate() {
2313        for value in [
2314            METRIC_TYPE_COUNTER,
2315            METRIC_TYPE_UPDOWN_COUNTER,
2316            METRIC_TYPE_GAUGE,
2317            METRIC_TYPE_HISTOGRAM,
2318            METRIC_TYPE_SUMMARY,
2319        ] {
2320            assert!(
2321                validate_semantic_option(SEMANTIC_METRIC_TYPE, value),
2322                "metric.type value `{value}` must be in the vocabulary domain"
2323            );
2324        }
2325        for value in ["delta", "cumulative"] {
2326            assert!(validate_semantic_option(SEMANTIC_METRIC_TEMPORALITY, value));
2327        }
2328    }
2329
2330    #[test]
2331    fn test_record_monotonic_sum() {
2332        let metric = Metric {
2333            name: "claude_code.cost.usage".to_string(),
2334            unit: "USD".to_string(),
2335            data: Some(metric::Data::Sum(Sum {
2336                aggregation_temporality: AggregationTemporality::Delta as i32,
2337                is_monotonic: true,
2338                ..Default::default()
2339            })),
2340            ..Default::default()
2341        };
2342        let index = record(
2343            &metric,
2344            MetricType::MonotonicSum,
2345            "claude_code_cost_usage_USD_total",
2346        );
2347        let decoded = decode(&index);
2348        let t = &decoded["claude_code_cost_usage_USD_total"];
2349
2350        assert_eq!(
2351            t.get(SEMANTIC_METRIC_TYPE).map(String::as_str),
2352            Some("counter")
2353        );
2354        assert_eq!(
2355            t.get(SEMANTIC_METRIC_TEMPORALITY).map(String::as_str),
2356            Some("delta")
2357        );
2358        assert_eq!(t.get(SEMANTIC_METRIC_UNIT).map(String::as_str), Some("USD"));
2359        assert_eq!(
2360            t.get(SEMANTIC_METRIC_ORIGINAL_NAME).map(String::as_str),
2361            Some("claude_code.cost.usage")
2362        );
2363        assert_eq!(
2364            t.get(SEMANTIC_METRIC_METADATA_QUALITY).map(String::as_str),
2365            Some("declared")
2366        );
2367    }
2368
2369    #[test]
2370    fn test_record_non_monotonic_sum() {
2371        let metric = Metric {
2372            name: "queue_size".to_string(),
2373            data: Some(metric::Data::Sum(Sum {
2374                aggregation_temporality: AggregationTemporality::Cumulative as i32,
2375                is_monotonic: false,
2376                ..Default::default()
2377            })),
2378            ..Default::default()
2379        };
2380        let index = record(&metric, MetricType::NonMonotonicSum, "queue_size");
2381        let decoded = decode(&index);
2382        let t = &decoded["queue_size"];
2383        assert_eq!(
2384            t.get(SEMANTIC_METRIC_TYPE).map(String::as_str),
2385            Some("updown_counter")
2386        );
2387        assert_eq!(
2388            t.get(SEMANTIC_METRIC_TEMPORALITY).map(String::as_str),
2389            Some("cumulative")
2390        );
2391        // Name unchanged by translation -> no original_name.
2392        assert_eq!(t.get(SEMANTIC_METRIC_ORIGINAL_NAME), None);
2393    }
2394
2395    #[test]
2396    fn test_record_gauge_has_no_temporality() {
2397        let metric = Metric {
2398            name: "temperature".to_string(),
2399            data: Some(metric::Data::Gauge(Gauge::default())),
2400            ..Default::default()
2401        };
2402        let index = record(&metric, MetricType::Gauge, "temperature");
2403        let decoded = decode(&index);
2404        let t = &decoded["temperature"];
2405        assert_eq!(
2406            t.get(SEMANTIC_METRIC_TYPE).map(String::as_str),
2407            Some("gauge")
2408        );
2409        assert_eq!(t.get(SEMANTIC_METRIC_TEMPORALITY), None);
2410    }
2411
2412    #[test]
2413    fn test_record_histogram_fans_out_with_distinct_types() {
2414        let metric = Metric {
2415            name: "request.duration".to_string(),
2416            unit: "s".to_string(),
2417            data: Some(metric::Data::Histogram(Histogram {
2418                aggregation_temporality: AggregationTemporality::Cumulative as i32,
2419                ..Default::default()
2420            })),
2421            ..Default::default()
2422        };
2423        let index = record(&metric, MetricType::Histogram, "request_duration");
2424        let decoded = decode(&index);
2425
2426        let bucket = &decoded["request_duration_bucket"];
2427        assert_eq!(
2428            bucket.get(SEMANTIC_METRIC_TYPE).map(String::as_str),
2429            Some("histogram")
2430        );
2431        assert_eq!(
2432            bucket.get(SEMANTIC_METRIC_UNIT).map(String::as_str),
2433            Some("s")
2434        );
2435
2436        for companion in ["request_duration_sum", "request_duration_count"] {
2437            let t = &decoded[companion];
2438            assert_eq!(
2439                t.get(SEMANTIC_METRIC_TYPE).map(String::as_str),
2440                Some("counter")
2441            );
2442            assert_eq!(
2443                t.get(SEMANTIC_METRIC_TEMPORALITY).map(String::as_str),
2444                Some("cumulative")
2445            );
2446        }
2447    }
2448
2449    #[test]
2450    fn test_record_summary_fans_out() {
2451        let metric = Metric {
2452            name: "rpc.latency".to_string(),
2453            data: Some(metric::Data::Summary(Summary::default())),
2454            ..Default::default()
2455        };
2456        let index = record(&metric, MetricType::Summary, "rpc_latency");
2457        let decoded = decode(&index);
2458
2459        assert_eq!(
2460            decoded["rpc_latency"]
2461                .get(SEMANTIC_METRIC_TYPE)
2462                .map(String::as_str),
2463            Some("summary")
2464        );
2465        // Summary has no temporality.
2466        assert_eq!(
2467            decoded["rpc_latency"].get(SEMANTIC_METRIC_TEMPORALITY),
2468            None
2469        );
2470        for companion in ["rpc_latency_count", "rpc_latency_sum"] {
2471            assert_eq!(
2472                decoded[companion]
2473                    .get(SEMANTIC_METRIC_TYPE)
2474                    .map(String::as_str),
2475                Some("counter")
2476            );
2477        }
2478    }
2479
2480    fn exponential_buckets(
2481        offset: i32,
2482        bucket_counts: Vec<u64>,
2483    ) -> exponential_histogram_data_point::Buckets {
2484        exponential_histogram_data_point::Buckets {
2485            offset,
2486            bucket_counts,
2487        }
2488    }
2489
2490    fn exponential_point() -> ExponentialHistogramDataPoint {
2491        ExponentialHistogramDataPoint {
2492            start_time_unix_nano: 1_000_000,
2493            time_unix_nano: 2_000_000,
2494            count: 28,
2495            sum: None,
2496            scale: 9,
2497            zero_count: 7,
2498            positive: Some(exponential_buckets(-3, vec![1, 2, 3, 4, 5, 6])),
2499            zero_threshold: 0.0,
2500            ..Default::default()
2501        }
2502    }
2503
2504    fn native_field(value: &ValueData, name: &str) -> Option<ValueData> {
2505        let ValueData::StructValue(value) = value else {
2506            panic!("expected native histogram Struct value");
2507        };
2508        let index = common_query::native_histogram::NATIVE_HISTOGRAM_FIELD_NAMES
2509            .iter()
2510            .position(|field| *field == name)
2511            .unwrap();
2512        value.items[index].value_data.clone()
2513    }
2514
2515    fn i32_list(value: Option<ValueData>) -> Vec<i32> {
2516        let Some(ValueData::ListValue(value)) = value else {
2517            panic!("expected i32 list");
2518        };
2519        value
2520            .items
2521            .into_iter()
2522            .map(|item| match item.value_data {
2523                Some(ValueData::I32Value(value)) => value,
2524                _ => panic!("expected i32 value"),
2525            })
2526            .collect()
2527    }
2528
2529    fn i64_list(value: Option<ValueData>) -> Vec<i64> {
2530        let Some(ValueData::ListValue(value)) = value else {
2531            panic!("expected i64 list");
2532        };
2533        value
2534            .items
2535            .into_iter()
2536            .map(|item| match item.value_data {
2537                Some(ValueData::I64Value(value)) => value,
2538                _ => panic!("expected i64 value"),
2539            })
2540            .collect()
2541    }
2542
2543    #[test]
2544    fn test_downscale_bucket_index_uses_signed_floor_division() {
2545        assert_eq!(downscale_bucket_index(-3, 1).unwrap(), -2);
2546        assert_eq!(downscale_bucket_index(-2, 1).unwrap(), -1);
2547        assert_eq!(downscale_bucket_index(-1, 1).unwrap(), -1);
2548        assert_eq!(downscale_bucket_index(0, 1).unwrap(), 0);
2549        assert_eq!(downscale_bucket_index(1, 1).unwrap(), 0);
2550        assert_eq!(downscale_bucket_index(2, 1).unwrap(), 1);
2551        assert_eq!(downscale_bucket_index(i32::MIN, 32).unwrap(), -1);
2552        assert_eq!(downscale_bucket_index(i32::MAX, 32).unwrap(), 0);
2553    }
2554
2555    #[test]
2556    fn test_convert_bucket_range_downscales_before_prometheus_shift() {
2557        let buckets = exponential_buckets(-3, vec![1, 2, 3, 4, 5, 6]);
2558        let (spans, deltas, total) = convert_bucket_range("positive", Some(&buckets), 1).unwrap();
2559
2560        assert_eq!(
2561            spans,
2562            vec![BucketSpan {
2563                offset: -1,
2564                length: 4
2565            }]
2566        );
2567        assert_eq!(deltas, vec![1, 4, 4, -3]);
2568        assert_eq!(total, 21);
2569    }
2570
2571    #[test]
2572    fn test_convert_bucket_range_compacts_zero_runs() {
2573        let large = (1u64 << 53) + 1;
2574        for (offset, counts, shift, expected_spans, expected_deltas, expected_total) in [
2575            (
2576                -3,
2577                vec![0, 3, 0, 0, 7, 9, 0],
2578                0,
2579                vec![(-1, 1), (2, 2)],
2580                vec![3, 4, 2],
2581                19,
2582            ),
2583            (
2584                -4,
2585                vec![0, 0, 2, 3, 0, 0, 0, 0, 7, 0],
2586                1,
2587                vec![(0, 1), (2, 1)],
2588                vec![5, 2],
2589                12,
2590            ),
2591            (0, vec![0, 0, 0], 0, vec![], vec![], 0),
2592            (0, vec![], 0, vec![], vec![], 0),
2593            (i32::MIN, vec![0, 1], 0, vec![(i32::MIN + 2, 1)], vec![1], 1),
2594            (i32::MAX - 1, vec![1], 0, vec![(i32::MAX, 1)], vec![1], 1),
2595            (
2596                0,
2597                vec![large, 0, large + 2],
2598                0,
2599                vec![(1, 1), (1, 1)],
2600                vec![large as i64, 2],
2601                large * 2 + 2,
2602            ),
2603        ] {
2604            let buckets = exponential_buckets(offset, counts);
2605            let (spans, deltas, total) =
2606                convert_bucket_range("positive", Some(&buckets), shift).unwrap();
2607            assert_eq!(
2608                spans
2609                    .iter()
2610                    .map(|span| (span.offset, span.length))
2611                    .collect::<Vec<_>>(),
2612                expected_spans,
2613                "{buckets:?}, shift={shift}"
2614            );
2615            assert_eq!(deltas, expected_deltas, "{buckets:?}, shift={shift}");
2616            assert_eq!(total, expected_total);
2617        }
2618
2619        // Compaction must not hide invalid source indexes, even for empty buckets.
2620        let buckets = exponential_buckets(i32::MAX, vec![0]);
2621        assert!(
2622            convert_bucket_range("positive", Some(&buckets), 0)
2623                .unwrap_err()
2624                .contains("shifted bucket index overflows")
2625        );
2626    }
2627
2628    #[test]
2629    fn test_exponential_histogram_compaction_preserves_integer_counts() {
2630        use common_query::native_histogram::{
2631            COUNT_I64_FIELD, NEGATIVE_BUCKETS_I64_FIELD, NEGATIVE_SPAN_LENGTHS_FIELD,
2632            NEGATIVE_SPAN_OFFSETS_FIELD, POSITIVE_BUCKETS_I64_FIELD, POSITIVE_SPAN_LENGTHS_FIELD,
2633            POSITIVE_SPAN_OFFSETS_FIELD,
2634        };
2635
2636        let large = (1u64 << 53) + 1;
2637        let buckets = exponential_buckets(-2, vec![0, large, 0, 3, 0]);
2638        let point = ExponentialHistogramDataPoint {
2639            count: 2 * (large + 3),
2640            positive: Some(buckets.clone()),
2641            negative: Some(buckets),
2642            ..Default::default()
2643        };
2644        let (value, _) =
2645            exponential_histogram_value(&point, AggregationTemporality::Cumulative).unwrap();
2646        assert_eq!(
2647            native_field(&value, COUNT_I64_FIELD),
2648            Some(ValueData::I64Value(point.count as i64))
2649        );
2650        for (offsets, lengths, counts) in [
2651            (
2652                POSITIVE_SPAN_OFFSETS_FIELD,
2653                POSITIVE_SPAN_LENGTHS_FIELD,
2654                POSITIVE_BUCKETS_I64_FIELD,
2655            ),
2656            (
2657                NEGATIVE_SPAN_OFFSETS_FIELD,
2658                NEGATIVE_SPAN_LENGTHS_FIELD,
2659                NEGATIVE_BUCKETS_I64_FIELD,
2660            ),
2661        ] {
2662            assert_eq!(i32_list(native_field(&value, offsets)), vec![0, 1]);
2663            assert_eq!(i32_list(native_field(&value, lengths)), vec![1, 1]);
2664            assert_eq!(
2665                i64_list(native_field(&value, counts)),
2666                vec![large as i64, 3]
2667            );
2668        }
2669    }
2670
2671    #[test]
2672    fn test_exponential_histogram_value_uses_integer_family() {
2673        use common_query::native_histogram::{
2674            COUNT_F64_FIELD, COUNT_I64_FIELD, POSITIVE_BUCKETS_I64_FIELD,
2675            POSITIVE_SPAN_LENGTHS_FIELD, POSITIVE_SPAN_OFFSETS_FIELD, SCHEMA_FIELD, SUM_FIELD,
2676            ZERO_COUNT_I64_FIELD,
2677        };
2678
2679        let (value, timestamp_nanos) =
2680            exponential_histogram_value(&exponential_point(), AggregationTemporality::Cumulative)
2681                .unwrap();
2682
2683        assert_eq!(timestamp_nanos, 2_000_000);
2684        assert_eq!(
2685            native_field(&value, SCHEMA_FIELD),
2686            Some(ValueData::I32Value(8))
2687        );
2688        assert_eq!(
2689            native_field(&value, COUNT_I64_FIELD),
2690            Some(ValueData::I64Value(28))
2691        );
2692        assert_eq!(
2693            native_field(&value, ZERO_COUNT_I64_FIELD),
2694            Some(ValueData::I64Value(7))
2695        );
2696        assert_eq!(native_field(&value, COUNT_F64_FIELD), None);
2697        assert_eq!(
2698            i32_list(native_field(&value, POSITIVE_SPAN_OFFSETS_FIELD)),
2699            vec![-1]
2700        );
2701        assert_eq!(
2702            i32_list(native_field(&value, POSITIVE_SPAN_LENGTHS_FIELD)),
2703            vec![4]
2704        );
2705        assert_eq!(
2706            i64_list(native_field(&value, POSITIVE_BUCKETS_I64_FIELD)),
2707            vec![1, 5, 9, 6]
2708        );
2709        let Some(ValueData::F64Value(sum)) = native_field(&value, SUM_FIELD) else {
2710            panic!("expected histogram sum");
2711        };
2712        assert_eq!(sum.to_bits(), f64::NAN.to_bits());
2713        assert_ne!(sum.to_bits(), PROMETHEUS_STALE_NAN_BITS);
2714    }
2715
2716    #[test]
2717    fn test_no_recorded_value_ignores_other_value_fields() {
2718        use common_query::native_histogram::{
2719            COUNT_I64_FIELD, POSITIVE_BUCKETS_I64_FIELD, SCHEMA_FIELD, SUM_FIELD,
2720            ZERO_COUNT_I64_FIELD, ZERO_THRESHOLD_FIELD,
2721        };
2722
2723        let point = ExponentialHistogramDataPoint {
2724            start_time_unix_nano: 1_000_000,
2725            time_unix_nano: 2_000_000,
2726            count: u64::MAX,
2727            sum: Some(1.0),
2728            scale: i32::MIN,
2729            zero_count: u64::MAX,
2730            positive: Some(exponential_buckets(i32::MAX, vec![u64::MAX])),
2731            flags: DataPointFlags::NoRecordedValueMask as u32,
2732            zero_threshold: f64::NAN,
2733            ..Default::default()
2734        };
2735        let (value, _) =
2736            exponential_histogram_value(&point, AggregationTemporality::Cumulative).unwrap();
2737
2738        assert_eq!(
2739            native_field(&value, SCHEMA_FIELD),
2740            Some(ValueData::I32Value(0))
2741        );
2742        assert_eq!(
2743            native_field(&value, ZERO_THRESHOLD_FIELD),
2744            Some(ValueData::F64Value(0.0))
2745        );
2746        assert_eq!(
2747            native_field(&value, COUNT_I64_FIELD),
2748            Some(ValueData::I64Value(0))
2749        );
2750        assert_eq!(
2751            native_field(&value, ZERO_COUNT_I64_FIELD),
2752            Some(ValueData::I64Value(0))
2753        );
2754        assert!(i64_list(native_field(&value, POSITIVE_BUCKETS_I64_FIELD)).is_empty());
2755        let Some(ValueData::F64Value(sum)) = native_field(&value, SUM_FIELD) else {
2756            panic!("expected histogram sum");
2757        };
2758        assert_eq!(sum.to_bits(), PROMETHEUS_STALE_NAN_BITS);
2759    }
2760
2761    #[test]
2762    fn test_non_flag_nan_sum_is_normalized() {
2763        use common_query::native_histogram::SUM_FIELD;
2764
2765        let point = ExponentialHistogramDataPoint {
2766            sum: Some(f64::from_bits(PROMETHEUS_STALE_NAN_BITS)),
2767            ..Default::default()
2768        };
2769        let (value, _) =
2770            exponential_histogram_value(&point, AggregationTemporality::Cumulative).unwrap();
2771        let Some(ValueData::F64Value(sum)) = native_field(&value, SUM_FIELD) else {
2772            panic!("expected histogram sum");
2773        };
2774        assert_eq!(sum.to_bits(), f64::NAN.to_bits());
2775        assert_ne!(sum.to_bits(), PROMETHEUS_STALE_NAN_BITS);
2776    }
2777
2778    #[test]
2779    fn test_exponential_histogram_rejects_invalid_values() {
2780        let mut cases = Vec::new();
2781
2782        let mut point = exponential_point();
2783        point.scale = -5;
2784        cases.push((point, "scale -5 is unsupported"));
2785
2786        let mut point = exponential_point();
2787        point.zero_threshold = f64::INFINITY;
2788        cases.push((point, "must be finite and non-negative"));
2789
2790        let mut point = exponential_point();
2791        point.start_time_unix_nano = point.time_unix_nano + 1;
2792        cases.push((point, "start_time_unix_nano"));
2793
2794        let mut point = exponential_point();
2795        point.count = 27;
2796        cases.push((point, "declared count is 27"));
2797
2798        let point = ExponentialHistogramDataPoint {
2799            count: u64::MAX,
2800            zero_count: u64::MAX,
2801            ..Default::default()
2802        };
2803        cases.push((point, "count 18446744073709551615 overflows i64"));
2804
2805        let point = ExponentialHistogramDataPoint {
2806            count: 1,
2807            scale: 8,
2808            positive: Some(exponential_buckets(i32::MAX, vec![1])),
2809            ..Default::default()
2810        };
2811        cases.push((point, "shifted bucket index overflows i32"));
2812
2813        for (point, expected) in cases {
2814            let error = exponential_histogram_value(&point, AggregationTemporality::Cumulative)
2815                .unwrap_err();
2816            assert!(
2817                error.contains(expected),
2818                "expected {expected:?}, got {error}"
2819            );
2820        }
2821
2822        let buckets = exponential_buckets(0, vec![u64::MAX, 1]);
2823        let error = convert_bucket_range("positive", Some(&buckets), 1).unwrap_err();
2824        assert!(
2825            error.contains("merged bucket count overflows u64"),
2826            "{error}"
2827        );
2828
2829        let buckets = exponential_buckets(i32::MAX, vec![1, 1]);
2830        let error = convert_bucket_range("positive", Some(&buckets), 1).unwrap_err();
2831        assert!(error.contains("bucket index overflows i32"), "{error}");
2832    }
2833
2834    fn metrics_request(metrics: Vec<Metric>) -> ExportMetricsServiceRequest {
2835        ExportMetricsServiceRequest {
2836            resource_metrics: vec![ResourceMetrics {
2837                scope_metrics: vec![ScopeMetrics {
2838                    metrics,
2839                    ..Default::default()
2840                }],
2841                ..Default::default()
2842            }],
2843        }
2844    }
2845
2846    #[test]
2847    fn test_invalid_exemplars_preserve_metric_payload() {
2848        use otel_arrow_rust::proto::opentelemetry::metrics::v1::{Exemplar, exemplar};
2849
2850        let valid = Exemplar {
2851            time_unix_nano: 1,
2852            value: Some(exemplar::Value::AsDouble(0.5)),
2853            ..Default::default()
2854        };
2855        for invalid in [
2856            Exemplar::default(),
2857            Exemplar {
2858                trace_id: vec![1],
2859                ..valid.clone()
2860            },
2861            Exemplar {
2862                span_id: vec![1],
2863                ..valid.clone()
2864            },
2865            Exemplar {
2866                time_unix_nano: u64::MAX,
2867                ..valid.clone()
2868            },
2869            Exemplar {
2870                filtered_attributes: vec![keyvalue("http.url", &"x".repeat(128))],
2871                ..valid.clone()
2872            },
2873            Exemplar {
2874                filtered_attributes: vec![keyvalue("a", "1"), keyvalue("a", "2")],
2875                ..valid.clone()
2876            },
2877        ] {
2878            let exemplars = vec![invalid.clone(), valid.clone(), invalid, valid.clone()];
2879            let point = NumberDataPoint {
2880                time_unix_nano: 1,
2881                value: Some(Value::AsDouble(1.0)),
2882                exemplars: exemplars.clone(),
2883                ..Default::default()
2884            };
2885            let metrics = vec![
2886                Metric {
2887                    name: "gauge".into(),
2888                    data: Some(metric::Data::Gauge(Gauge {
2889                        data_points: vec![point.clone()],
2890                    })),
2891                    ..Default::default()
2892                },
2893                Metric {
2894                    name: "sum".into(),
2895                    data: Some(metric::Data::Sum(Sum {
2896                        data_points: vec![point],
2897                        aggregation_temporality: AggregationTemporality::Cumulative as i32,
2898                        ..Default::default()
2899                    })),
2900                    ..Default::default()
2901                },
2902                exponential_metric(
2903                    "histogram",
2904                    vec![ExponentialHistogramDataPoint {
2905                        exemplars,
2906                        ..exponential_point()
2907                    }],
2908                    AggregationTemporality::Cumulative,
2909                ),
2910            ];
2911            let conversion = to_grpc_insert_requests(
2912                metrics_request(metrics),
2913                &mut OtlpMetricCtx {
2914                    with_metric_engine: true,
2915                    ..Default::default()
2916                },
2917            )
2918            .unwrap();
2919            assert_eq!(conversion.rows, 3);
2920            assert_eq!(conversion.outcome.accepted_data_points, 3);
2921            assert_eq!(conversion.outcome.rejected_data_points, 0);
2922            assert_eq!(conversion.exemplars.len(), 6);
2923        }
2924    }
2925
2926    #[test]
2927    fn test_histogram_exemplars_follow_emitted_buckets() {
2928        use common_query::prelude::EXEMPLAR_METRIC_COLUMN;
2929        use otel_arrow_rust::proto::opentelemetry::metrics::v1::{Exemplar, exemplar};
2930        use prost::Message;
2931
2932        for mode in ["delta", "empty", "stale", "missing_infinity"] {
2933            let mut point = HistogramDataPoint {
2934                time_unix_nano: 100,
2935                count: 4,
2936                explicit_bounds: vec![1.0, 2.0],
2937                bucket_counts: vec![1, 1, 2],
2938                exemplars: [5.0, 1.0, 0.5, f64::NEG_INFINITY, f64::INFINITY, f64::NAN]
2939                    .into_iter()
2940                    .enumerate()
2941                    .map(|(i, value)| Exemplar {
2942                        time_unix_nano: i as u64 + 1,
2943                        value: Some(exemplar::Value::AsDouble(value)),
2944                        ..Default::default()
2945                    })
2946                    .collect(),
2947                ..Default::default()
2948            };
2949            point.exemplars.extend([
2950                Exemplar::default(),
2951                Exemplar {
2952                    trace_id: vec![1],
2953                    value: Some(exemplar::Value::AsDouble(1.0)),
2954                    ..Default::default()
2955                },
2956            ]);
2957            match mode {
2958                "empty" => {
2959                    point.explicit_bounds.clear();
2960                    point.bucket_counts.clear();
2961                }
2962                "stale" => point.flags = DataPointFlags::NoRecordedValueMask as u32,
2963                "missing_infinity" => {
2964                    // Cumulative conversion tolerates a missing infinity bucket.
2965                    point.explicit_bounds = vec![1.0];
2966                    point.bucket_counts = vec![1];
2967                    point.count = 1;
2968                }
2969                _ => {}
2970            }
2971            let mut histogram = Histogram {
2972                data_points: vec![point],
2973                aggregation_temporality: if mode == "missing_infinity" {
2974                    AggregationTemporality::Cumulative
2975                } else {
2976                    AggregationTemporality::Delta
2977                } as i32,
2978            };
2979            if mode != "missing_infinity" {
2980                histogram.data_points.push(HistogramDataPoint {
2981                    explicit_bounds: vec![1.0],
2982                    bucket_counts: vec![4],
2983                    count: 4,
2984                    exemplars: vec![Exemplar::default()],
2985                    ..Default::default()
2986                });
2987            }
2988            let mut request = gauge_request(vec![], "gauge");
2989            request.resource_metrics[0].scope_metrics[0]
2990                .metrics
2991                .push(Metric {
2992                    name: "histogram".into(),
2993                    data: Some(metric::Data::Histogram(histogram.clone())),
2994                    ..Default::default()
2995                });
2996            let ctx = OtlpMetricCtx {
2997                with_metric_engine: true,
2998                ..Default::default()
2999            };
3000            let mut conversion =
3001                to_grpc_insert_requests(request.clone(), &mut ctx.clone()).unwrap();
3002            assert_eq!(conversion.outcome.accepted_data_points, 2, "{mode}");
3003            assert_eq!(
3004                conversion.outcome.rejected_data_points,
3005                i64::from(mode != "missing_infinity"),
3006                "{mode}"
3007            );
3008
3009            // Exemplars must not change either the histogram or the independent gauge payload.
3010            for point in &mut histogram.data_points {
3011                point.exemplars.clear();
3012            }
3013            request.resource_metrics[0].scope_metrics[0].metrics[1].data =
3014                Some(metric::Data::Histogram(histogram));
3015            let mut baseline = to_grpc_insert_requests(request, &mut ctx.clone()).unwrap();
3016            conversion
3017                .requests
3018                .inserts
3019                .sort_by(|a, b| a.table_name.cmp(&b.table_name));
3020            baseline
3021                .requests
3022                .inserts
3023                .sort_by(|a, b| a.table_name.cmp(&b.table_name));
3024            // Compare wire bytes so stale NaN values compare by their bits.
3025            assert_eq!(
3026                conversion.requests.encode_to_vec(),
3027                baseline.requests.encode_to_vec(),
3028                "{mode}"
3029            );
3030
3031            let expected = match mode {
3032                "empty" => vec![
3033                    (1, "inf"),
3034                    (2, "inf"),
3035                    (3, "inf"),
3036                    (4, "inf"),
3037                    (5, "inf"),
3038                    (6, "inf"),
3039                ],
3040                "missing_infinity" => vec![(2, "1"), (3, "1"), (4, "1")],
3041                _ => vec![
3042                    (1, "inf"),
3043                    (2, "1"),
3044                    (3, "1"),
3045                    (4, "1"),
3046                    (5, "inf"),
3047                    (6, "inf"),
3048                ],
3049            };
3050            assert_eq!(conversion.exemplars.len(), expected.len(), "{mode}");
3051            let (exemplars, _) = crate::exemplar::into_rows(conversion.exemplars).unwrap();
3052            let rows = exemplars.inserts[0].rows.as_ref().unwrap();
3053            let index = |name| {
3054                rows.schema
3055                    .iter()
3056                    .position(|c| c.column_name == name)
3057                    .unwrap()
3058            };
3059            let mut actual = Vec::new();
3060            for row in &rows.rows {
3061                assert_eq!(
3062                    row.values[index(EXEMPLAR_METRIC_COLUMN)].value_data,
3063                    Some(ValueData::StringValue("histogram_bucket".into()))
3064                );
3065                let Some(ValueData::TimestampNanosecondValue(timestamp)) =
3066                    row.values[index(greptime_timestamp())].value_data
3067                else {
3068                    panic!("missing exemplar timestamp")
3069                };
3070                let Some(ValueData::StringValue(ref bound)) = row.values[index("le")].value_data
3071                else {
3072                    panic!("missing bucket label")
3073                };
3074                actual.push((timestamp, bound.as_str()));
3075                if timestamp == 3 {
3076                    assert_eq!(
3077                        row.values[index(greptime_value())].value_data,
3078                        Some(ValueData::F64Value(0.5))
3079                    );
3080                }
3081            }
3082            actual.sort_unstable();
3083            assert_eq!(actual, expected, "{mode}");
3084        }
3085    }
3086
3087    fn exponential_metric(
3088        name: impl Into<String>,
3089        data_points: Vec<ExponentialHistogramDataPoint>,
3090        temporality: AggregationTemporality,
3091    ) -> Metric {
3092        Metric {
3093            name: name.into(),
3094            data: Some(metric::Data::ExponentialHistogram(ExponentialHistogram {
3095                data_points,
3096                aggregation_temporality: temporality as i32,
3097            })),
3098            ..Default::default()
3099        }
3100    }
3101
3102    fn histogram_metric(name: impl Into<String>) -> Metric {
3103        Metric {
3104            name: name.into(),
3105            data: Some(metric::Data::Histogram(Histogram {
3106                data_points: vec![HistogramDataPoint {
3107                    start_time_unix_nano: 1_000_000,
3108                    time_unix_nano: 2_000_000,
3109                    count: 1,
3110                    sum: Some(1.0),
3111                    bucket_counts: vec![1],
3112                    ..Default::default()
3113                }],
3114                aggregation_temporality: AggregationTemporality::Cumulative as i32,
3115            })),
3116            ..Default::default()
3117        }
3118    }
3119
3120    #[test]
3121    fn test_metric_engine_path_keeps_nanosecond_precision() {
3122        let time_unix_nano = 1_704_067_200_123_456_789u64;
3123        let request = metrics_request(vec![Metric {
3124            name: "my_gauge".to_string(),
3125            data: Some(metric::Data::Gauge(Gauge {
3126                data_points: vec![NumberDataPoint {
3127                    time_unix_nano,
3128                    value: Some(Value::AsDouble(1.0)),
3129                    ..Default::default()
3130                }],
3131            })),
3132            ..Default::default()
3133        }]);
3134
3135        // The metric engine path keeps the full nanosecond precision here;
3136        // `Inserter::handle_metric_row_inserts` converts the timestamps to
3137        // the physical table's time index unit afterwards.
3138        let mut metric_ctx = OtlpMetricCtx {
3139            with_metric_engine: true,
3140            ..Default::default()
3141        };
3142        let MetricsConversion { requests, .. } =
3143            to_grpc_insert_requests(request, &mut metric_ctx).unwrap();
3144
3145        let rows = requests.inserts[0].rows.as_ref().unwrap();
3146        let ts_index = rows
3147            .schema
3148            .iter()
3149            .position(|column| column.column_name == greptime_timestamp())
3150            .unwrap();
3151        assert_eq!(
3152            rows.schema[ts_index].datatype,
3153            ColumnDataType::TimestampNanosecond as i32
3154        );
3155        assert!(matches!(
3156            rows.rows[0].values[ts_index].value_data,
3157            Some(ValueData::TimestampNanosecondValue(
3158                1_704_067_200_123_456_789
3159            ))
3160        ));
3161
3162        // The non-metric prometheus-compatible path stays millisecond so
3163        // auto-created mito tables keep the millisecond time index.
3164        let mut compat_ctx = OtlpMetricCtx::default();
3165        let request = metrics_request(vec![Metric {
3166            name: "my_gauge".to_string(),
3167            data: Some(metric::Data::Gauge(Gauge {
3168                data_points: vec![NumberDataPoint {
3169                    time_unix_nano,
3170                    value: Some(Value::AsDouble(1.0)),
3171                    ..Default::default()
3172                }],
3173            })),
3174            ..Default::default()
3175        }]);
3176        let MetricsConversion { requests, .. } =
3177            to_grpc_insert_requests(request, &mut compat_ctx).unwrap();
3178
3179        let rows = requests.inserts[0].rows.as_ref().unwrap();
3180        let ts_index = rows
3181            .schema
3182            .iter()
3183            .position(|column| column.column_name == greptime_timestamp())
3184            .unwrap();
3185        assert_eq!(
3186            rows.schema[ts_index].datatype,
3187            ColumnDataType::TimestampMillisecond as i32
3188        );
3189        assert!(matches!(
3190            rows.rows[0].values[ts_index].value_data,
3191            Some(ValueData::TimestampMillisecondValue(1_704_067_200_123))
3192        ));
3193    }
3194
3195    #[test]
3196    fn test_exponential_histogram_rejection_metrics() {
3197        // Other conversion tests update the same process-global counters.
3198        const ISOLATED_ENV: &str = "GREPTIME_TEST_OTLP_REJECTION_METRICS_ISOLATED";
3199        if std::env::var_os(ISOLATED_ENV).is_none() {
3200            let output = std::process::Command::new(std::env::current_exe().unwrap())
3201                .args([
3202                    "--exact",
3203                    "otlp::metrics::tests::test_exponential_histogram_rejection_metrics",
3204                ])
3205                .env(ISOLATED_ENV, "1")
3206                .output()
3207                .unwrap();
3208            let stdout = String::from_utf8_lossy(&output.stdout);
3209            assert!(
3210                output.status.success() && stdout.contains("1 passed"),
3211                "isolated rejection metrics test failed\nstdout:\n{stdout}\nstderr:\n{}",
3212                String::from_utf8_lossy(&output.stderr)
3213            );
3214            return;
3215        }
3216
3217        let mut invalid = exponential_point();
3218        invalid.scale = -5;
3219        for (temporality, points, reason, accepted, rejected) in [
3220            (
3221                AggregationTemporality::Delta,
3222                vec![exponential_point(); 2],
3223                "invalid_data_point",
3224                2,
3225                0,
3226            ),
3227            (
3228                AggregationTemporality::Unspecified,
3229                vec![exponential_point(); 2],
3230                "unspecified_temporality",
3231                0,
3232                2,
3233            ),
3234            (
3235                AggregationTemporality::Cumulative,
3236                vec![exponential_point(), invalid],
3237                "invalid_data_point",
3238                1,
3239                1,
3240            ),
3241            (
3242                AggregationTemporality::Cumulative,
3243                vec![exponential_point()],
3244                "invalid_data_point",
3245                1,
3246                0,
3247            ),
3248        ] {
3249            let counter =
3250                OTLP_EXPONENTIAL_HISTOGRAM_REJECTED_DATA_POINTS.with_label_values(&[reason]);
3251            let before = counter.get();
3252            let mut request =
3253                metrics_request(vec![exponential_metric("latency", points, temporality)]);
3254            request.resource_metrics[0].resource = Some(Resource {
3255                attributes: vec![keyvalue("service.name", "api")],
3256                ..Default::default()
3257            });
3258            let mut ctx = descriptor_ctx();
3259            let conversion = to_grpc_insert_requests(request, &mut ctx).unwrap();
3260            assert_eq!(conversion.outcome.accepted_data_points, accepted);
3261            assert_eq!(conversion.outcome.rejected_data_points, rejected);
3262            assert_eq!(counter.get() - before, rejected as u64, "{reason}");
3263            assert_eq!(conversion.resource_info.is_some(), accepted > 0);
3264
3265            let before = counter.get();
3266            let empty = metrics_request(vec![exponential_metric("empty", vec![], temporality)]);
3267            assert_eq!(
3268                to_grpc_insert_requests(empty, &mut ctx)
3269                    .unwrap()
3270                    .outcome
3271                    .rejected_data_points,
3272                0
3273            );
3274            assert_eq!(counter.get(), before, "empty metric: {reason}");
3275        }
3276    }
3277
3278    #[test]
3279    fn test_exponential_histogram_default_context_and_partial_outcome() {
3280        let request = metrics_request(vec![
3281            Metric {
3282                name: "temperature".to_string(),
3283                data: Some(metric::Data::Gauge(Gauge {
3284                    data_points: vec![NumberDataPoint::default()],
3285                })),
3286                ..Default::default()
3287            },
3288            exponential_metric(
3289                "latency",
3290                vec![exponential_point()],
3291                AggregationTemporality::Cumulative,
3292            ),
3293            exponential_metric(
3294                "delta_latency",
3295                vec![exponential_point()],
3296                AggregationTemporality::Delta,
3297            ),
3298        ]);
3299        let MetricsConversion {
3300            requests,
3301            semantic_index,
3302            outcome,
3303            ..
3304        } = to_grpc_insert_requests(request, &mut OtlpMetricCtx::default()).unwrap();
3305
3306        assert_eq!(outcome.accepted_data_points, 3);
3307        assert_eq!(outcome.rejected_data_points, 0);
3308        assert!(outcome.error_message.is_none());
3309        assert_eq!(requests.inserts.len(), 3);
3310        let semantics = decode(&semantic_index);
3311        assert!(semantics.contains_key("temperature"));
3312        assert!(semantics.contains_key("latency"));
3313        assert_eq!(
3314            semantics["delta_latency"][SEMANTIC_METRIC_TEMPORALITY],
3315            "delta"
3316        );
3317
3318        let empty = metrics_request(vec![exponential_metric(
3319            "empty",
3320            vec![],
3321            AggregationTemporality::Cumulative,
3322        )]);
3323        let outcome = to_grpc_insert_requests(empty, &mut OtlpMetricCtx::default())
3324            .unwrap()
3325            .outcome;
3326        assert_eq!(outcome.rejected_data_points, 0);
3327        assert_eq!(outcome.error_message, None);
3328    }
3329
3330    #[test]
3331    fn test_exponential_histogram_cannot_share_table_with_scalar_metric() {
3332        let request = metrics_request(vec![
3333            Metric {
3334                name: "latency".to_string(),
3335                data: Some(metric::Data::Gauge(Gauge {
3336                    data_points: vec![NumberDataPoint {
3337                        value: Some(number_data_point::Value::AsDouble(1.0)),
3338                        ..Default::default()
3339                    }],
3340                })),
3341                ..Default::default()
3342            },
3343            exponential_metric(
3344                "latency",
3345                vec![exponential_point()],
3346                AggregationTemporality::Cumulative,
3347            ),
3348        ]);
3349        let mut ctx = OtlpMetricCtx::default();
3350
3351        let conversion = to_grpc_insert_requests(request, &mut ctx).unwrap();
3352        assert_eq!(conversion.outcome.accepted_data_points, 0);
3353        assert_eq!(conversion.outcome.rejected_data_points, 2);
3354        assert!(conversion.requests.inserts.is_empty());
3355    }
3356
3357    #[test]
3358    fn test_histogram_cannot_replace_exponential_histogram_table() {
3359        let request = metrics_request(vec![
3360            exponential_metric(
3361                "latency_bucket",
3362                vec![exponential_point()],
3363                AggregationTemporality::Cumulative,
3364            ),
3365            histogram_metric("latency"),
3366        ]);
3367        let mut ctx = OtlpMetricCtx::default();
3368
3369        let conversion = to_grpc_insert_requests(request, &mut ctx).unwrap();
3370        assert_eq!(conversion.outcome.accepted_data_points, 0);
3371        assert_eq!(conversion.outcome.rejected_data_points, 2);
3372        assert!(conversion.requests.inserts.is_empty());
3373    }
3374
3375    #[test]
3376    fn test_histograms_with_same_name_across_resources_are_merged() {
3377        let metric = histogram_metric("latency");
3378        let request = ExportMetricsServiceRequest {
3379            resource_metrics: ["service-a", "service-b"]
3380                .into_iter()
3381                .map(|service| ResourceMetrics {
3382                    resource: Some(Resource {
3383                        attributes: vec![keyvalue("service.name", service)],
3384                        ..Default::default()
3385                    }),
3386                    scope_metrics: vec![ScopeMetrics {
3387                        metrics: vec![metric.clone()],
3388                        ..Default::default()
3389                    }],
3390                    ..Default::default()
3391                })
3392                .collect(),
3393        };
3394
3395        let MetricsConversion {
3396            requests,
3397            rows,
3398            outcome,
3399            ..
3400        } = to_grpc_insert_requests(request, &mut OtlpMetricCtx::default()).unwrap();
3401
3402        assert_eq!(outcome.accepted_data_points, 2);
3403        assert_eq!(rows, 6);
3404        assert_eq!(requests.inserts.len(), 3);
3405        for request in requests.inserts {
3406            assert_eq!(
3407                request.rows.unwrap().rows.len(),
3408                2,
3409                "{}",
3410                request.table_name
3411            );
3412        }
3413    }
3414
3415    #[test]
3416    fn test_exponential_histogram_rejects_temporality_before_stale_point() {
3417        let stale = ExponentialHistogramDataPoint {
3418            flags: DataPointFlags::NoRecordedValueMask as u32,
3419            ..Default::default()
3420        };
3421        let request = metrics_request(vec![exponential_metric(
3422            "latency",
3423            vec![stale],
3424            AggregationTemporality::Unspecified,
3425        )]);
3426        let mut ctx = OtlpMetricCtx::default();
3427        let MetricsConversion {
3428            requests,
3429            rows,
3430            semantic_index,
3431            outcome,
3432            ..
3433        } = to_grpc_insert_requests(request, &mut ctx).unwrap();
3434
3435        assert_eq!(outcome.accepted_data_points, 0);
3436        assert_eq!(outcome.rejected_data_points, 1);
3437        assert_eq!(rows, 0);
3438        assert!(requests.inserts.is_empty());
3439        assert!(semantic_index.is_empty());
3440    }
3441
3442    #[test]
3443    fn test_exponential_histogram_storage_paths_share_struct() {
3444        use common_query::prelude::greptime_native_histogram;
3445
3446        let mut point = exponential_point();
3447        point.sum = Some(42.0);
3448        let request = metrics_request(vec![exponential_metric(
3449            "request.duration",
3450            vec![point],
3451            AggregationTemporality::Cumulative,
3452        )]);
3453        let mut ordinary_ctx = OtlpMetricCtx::default();
3454        let ordinary_requests = to_grpc_insert_requests(request.clone(), &mut ordinary_ctx)
3455            .unwrap()
3456            .requests;
3457        let mut metric_ctx = OtlpMetricCtx {
3458            with_metric_engine: true,
3459            ..Default::default()
3460        };
3461        let metric_requests = to_grpc_insert_requests(request, &mut metric_ctx)
3462            .unwrap()
3463            .requests;
3464
3465        let ordinary_insert = &ordinary_requests.inserts[0];
3466        let metric_insert = &metric_requests.inserts[0];
3467        assert_eq!(ordinary_insert.table_name, "request_duration");
3468        assert_eq!(metric_insert.table_name, "request_duration");
3469        let ordinary_rows = ordinary_insert.rows.as_ref().unwrap();
3470        let metric_rows = metric_insert.rows.as_ref().unwrap();
3471        let field = greptime_native_histogram();
3472        let ordinary_histogram = ordinary_rows.rows[0].values[ordinary_rows
3473            .schema
3474            .iter()
3475            .position(|column| column.column_name == field)
3476            .unwrap()]
3477        .clone();
3478        let metric_histogram = metric_rows.rows[0].values[metric_rows
3479            .schema
3480            .iter()
3481            .position(|column| column.column_name == field)
3482            .unwrap()]
3483        .clone();
3484        assert_eq!(ordinary_histogram, metric_histogram);
3485        assert!(matches!(
3486            ordinary_rows.rows[0].values[ordinary_rows
3487                .schema
3488                .iter()
3489                .position(|column| column.column_name == greptime_timestamp())
3490                .unwrap()]
3491            .value_data,
3492            Some(ValueData::TimestampMillisecondValue(2))
3493        ));
3494        assert!(matches!(
3495            metric_rows.rows[0].values[metric_rows
3496                .schema
3497                .iter()
3498                .position(|column| column.column_name == greptime_timestamp())
3499                .unwrap()]
3500            .value_data,
3501            Some(ValueData::TimestampNanosecondValue(2_000_000))
3502        ));
3503    }
3504
3505    #[test]
3506    fn test_rejection_message_is_bounded() {
3507        let request = metrics_request(vec![exponential_metric(
3508            "x".repeat(1_000),
3509            vec![ExponentialHistogramDataPoint::default()],
3510            AggregationTemporality::Unspecified,
3511        )]);
3512        let outcome = to_grpc_insert_requests(request, &mut OtlpMetricCtx::default())
3513            .unwrap()
3514            .outcome;
3515
3516        assert_eq!(outcome.rejected_data_points, 1);
3517        assert!(outcome.error_message.unwrap().len() <= MAX_REJECTION_MESSAGE_BYTES);
3518    }
3519
3520    #[test]
3521    fn test_rejection_message_reason_is_lazy_after_cap() {
3522        let mut outcome = MetricsIngestOutcome {
3523            error_message: Some("x".repeat(MAX_REJECTION_MESSAGE_BYTES)),
3524            ..Default::default()
3525        };
3526        let mut reason_built = false;
3527
3528        reject_data_points(&mut outcome, 1, || {
3529            reason_built = true;
3530            "unused".to_string()
3531        })
3532        .unwrap();
3533
3534        assert_eq!(outcome.rejected_data_points, 1);
3535        assert!(!reason_built);
3536    }
3537}