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::{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_COUNT, GREPTIME_TEMPORALITY_DELTA, OTLP_AGGREGATION_TEMPORALITY_LABEL,
28    greptime_timestamp, greptime_value,
29};
30use common_query::prometheus::PROMETHEUS_STALE_NAN_BITS;
31use common_telemetry::warn;
32use lazy_static::lazy_static;
33use otel_arrow_rust::proto::opentelemetry::collector::metrics::v1::ExportMetricsServiceRequest;
34use otel_arrow_rust::proto::opentelemetry::common::v1::{AnyValue, KeyValue, any_value};
35use otel_arrow_rust::proto::opentelemetry::metrics::v1::{metric, number_data_point, *};
36use session::protocol_ctx::{MetricType, OtlpMetricCtx};
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::otlp::trace::{KEY_SERVICE_INSTANCE_ID, KEY_SERVICE_NAME, KEY_SERVICE_NAMESPACE};
45use crate::query_handler::MetricsIngestOutcome;
46use crate::row_writer::{self, MultiTableData, TableData};
47pub use crate::semantic::SemanticIndex;
48use crate::semantic::{
49    METRIC_TYPE_COUNTER, METRIC_TYPE_GAUGE, METRIC_TYPE_HISTOGRAM, METRIC_TYPE_SUMMARY,
50    METRIC_TYPE_UPDOWN_COUNTER,
51};
52
53mod resource_info;
54mod translator;
55
56pub use resource_info::OTEL_RESOURCE_INFO_TABLE_NAME;
57use resource_info::ResourceInfoData;
58pub use translator::legacy_normalize_otlp_name;
59pub(crate) use translator::ucum_to_openmetrics_unit;
60use translator::{translate_label_name, translate_metric_name};
61
62/// the default column count for table writer
63const APPROXIMATE_COLUMN_COUNT: usize = 8;
64
65const COUNT_TABLE_SUFFIX: &str = "_count";
66const SUM_TABLE_SUFFIX: &str = "_sum";
67const BUCKET_TABLE_SUFFIX: &str = "_bucket";
68
69const JOB_KEY: &str = "job";
70const INSTANCE_KEY: &str = "instance";
71
72// see: https://prometheus.io/docs/guides/opentelemetry/#promoting-resource-attributes
73const DEFAULT_PROMOTE_ATTRS: [&str; 19] = [
74    "service.instance.id",
75    "service.name",
76    "service.namespace",
77    "service.version",
78    "cloud.availability_zone",
79    "cloud.region",
80    "container.name",
81    "deployment.environment",
82    "deployment.environment.name",
83    "k8s.cluster.name",
84    "k8s.container.name",
85    "k8s.cronjob.name",
86    "k8s.daemonset.name",
87    "k8s.deployment.name",
88    "k8s.job.name",
89    "k8s.namespace.name",
90    "k8s.pod.name",
91    "k8s.replicaset.name",
92    "k8s.statefulset.name",
93];
94
95lazy_static! {
96    static ref DEFAULT_PROMOTE_ATTRS_SET: HashSet<String> =
97        HashSet::from_iter(DEFAULT_PROMOTE_ATTRS.iter().map(|s| s.to_string()));
98}
99
100const OTEL_SCOPE_NAME: &str = "name";
101const OTEL_SCOPE_VERSION: &str = "version";
102const OTEL_SCOPE_SCHEMA_URL: &str = "schema_url";
103const MIN_EXPONENTIAL_HISTOGRAM_SCALE: i32 = -4;
104const MAX_EXPONENTIAL_HISTOGRAM_SCALE: i32 = 8;
105const MAX_REJECTION_MESSAGE_BYTES: usize = 512;
106
107/// Result of converting one OTLP metrics request.
108#[derive(Debug)]
109pub struct MetricsConversion {
110    pub requests: RowInsertRequests,
111    /// Row count of `requests`; the resource descriptor is not counted.
112    pub rows: usize,
113    /// Per-table semantic index for the auto-create path to stamp as table
114    /// options; covers the descriptor table too.
115    pub semantic_index: SemanticIndex,
116    /// The synthesized resource descriptor, written separately from the
117    /// metric data. See [`resource_info`].
118    pub resource_info: Option<RowInsertRequests>,
119    pub outcome: MetricsIngestOutcome,
120}
121
122/// Convert OpenTelemetry metrics to GreptimeDB insert requests
123///
124/// See
125/// <https://github.com/open-telemetry/opentelemetry-proto/blob/main/opentelemetry/proto/metrics/v1/metrics.proto>
126/// for data structure of OTLP metrics.
127pub fn to_grpc_insert_requests(
128    request: ExportMetricsServiceRequest,
129    metric_ctx: &mut OtlpMetricCtx,
130) -> Result<MetricsConversion> {
131    let mut table_writer = MultiTableData::default();
132    let mut semantic_index = SemanticIndex::default();
133    let mut outcome = MetricsIngestOutcome::default();
134    let mut resource_info = ResourceInfoData::default();
135
136    for resource in &request.resource_metrics {
137        if metric_ctx.resource_info
138            && !metric_ctx.is_legacy
139            && let Some(r) = resource.resource.as_ref()
140        {
141            resource_info.observe(&r.attributes, resource, metric_ctx);
142        }
143
144        let resource_attrs = resource.resource.as_ref().map(|r| {
145            let mut attrs = r.attributes.clone();
146            process_resource_attrs(&mut attrs, metric_ctx);
147            attrs
148        });
149
150        for scope in &resource.scope_metrics {
151            let scope_attrs = process_scope_attrs(scope, metric_ctx);
152
153            for metric in &scope.metrics {
154                if metric.data.is_none() {
155                    continue;
156                }
157                if let Some(t) = metric.data.as_ref().map(from_metric_type) {
158                    metric_ctx.set_metric_type(t);
159                }
160
161                encode_metrics(
162                    &mut table_writer,
163                    metric,
164                    resource_attrs.as_ref(),
165                    scope_attrs.as_ref(),
166                    metric_ctx,
167                    &mut semantic_index,
168                    &mut outcome,
169                )?;
170            }
171        }
172    }
173
174    let (requests, rows) = table_writer.into_row_insert_requests();
175
176    validate_sample_kinds(&requests)?;
177
178    // The metric and the descriptor would fight over the same table across
179    // two engines. The user's metric wins.
180    let resource_info = if !metric_ctx.resource_info {
181        None
182    } else if requests
183        .inserts
184        .iter()
185        .any(|r| r.table_name == OTEL_RESOURCE_INFO_TABLE_NAME)
186    {
187        warn!(
188            "Skipping OTLP resource descriptor synthesis: the request writes \
189             a metric named `{OTEL_RESOURCE_INFO_TABLE_NAME}`"
190        );
191        None
192    } else {
193        resource_info.into_row_insert_requests()?
194    };
195    if resource_info.is_some() {
196        semantic_index.record_scalar(
197            OTEL_RESOURCE_INFO_TABLE_NAME,
198            SEMANTIC_METRIC_TYPE,
199            crate::semantic::METRIC_TYPE_INFO,
200        );
201        semantic_index.record_scalar(
202            OTEL_RESOURCE_INFO_TABLE_NAME,
203            SEMANTIC_METRIC_METADATA_QUALITY,
204            METADATA_QUALITY_DECLARED,
205        );
206    }
207
208    Ok(MetricsConversion {
209        requests,
210        rows,
211        semantic_index,
212        outcome,
213        resource_info,
214    })
215}
216
217fn validate_sample_kinds(requests: &RowInsertRequests) -> Result<()> {
218    for request in &requests.inserts {
219        let Some(rows) = &request.rows else {
220            continue;
221        };
222        let field_count = rows
223            .schema
224            .iter()
225            .filter(|column| column.semantic_type == SemanticType::Field as i32)
226            .count();
227        let has_native_histogram = rows.schema.iter().any(|column| {
228            column.semantic_type == SemanticType::Field as i32
229                && api::helper::is_column_type_value_eq(
230                    column.datatype,
231                    column.datatype_extension.clone(),
232                    native_histogram_value_type(),
233                )
234        });
235        if has_native_histogram && field_count != 1 {
236            return Err(error::InvalidParameterSnafu {
237                reason: format!(
238                    "OTLP metric `{}` cannot mix native histogram and float sample fields",
239                    request.table_name
240                ),
241            }
242            .build());
243        }
244    }
245    Ok(())
246}
247
248/// The tables a metric emits and their per-table `metric.type`. Histogram fans
249/// out into `_bucket` (the histogram) plus `_sum`/`_count` counters; summary
250/// fans out into the quantile table plus `_count`/`_sum` counters (legacy
251/// summary stays a single table).
252fn emitted_semantic_tables(
253    metric_type: &MetricType,
254    is_legacy: bool,
255    base: &str,
256) -> Vec<(String, &'static str)> {
257    match metric_type {
258        MetricType::Gauge => vec![(base.to_string(), METRIC_TYPE_GAUGE)],
259        MetricType::MonotonicSum => vec![(base.to_string(), METRIC_TYPE_COUNTER)],
260        MetricType::NonMonotonicSum => vec![(base.to_string(), METRIC_TYPE_UPDOWN_COUNTER)],
261        MetricType::Histogram => vec![
262            (
263                format!("{base}{BUCKET_TABLE_SUFFIX}"),
264                METRIC_TYPE_HISTOGRAM,
265            ),
266            (format!("{base}{SUM_TABLE_SUFFIX}"), METRIC_TYPE_COUNTER),
267            (format!("{base}{COUNT_TABLE_SUFFIX}"), METRIC_TYPE_COUNTER),
268        ],
269        MetricType::Summary if is_legacy => vec![(base.to_string(), METRIC_TYPE_SUMMARY)],
270        MetricType::Summary => vec![
271            (base.to_string(), METRIC_TYPE_SUMMARY),
272            (format!("{base}{COUNT_TABLE_SUFFIX}"), METRIC_TYPE_COUNTER),
273            (format!("{base}{SUM_TABLE_SUFFIX}"), METRIC_TYPE_COUNTER),
274        ],
275        MetricType::ExponentialHistogram => {
276            vec![(base.to_string(), METRIC_TYPE_HISTOGRAM)]
277        }
278        MetricType::Init => vec![],
279    }
280}
281
282/// Maps OTLP `aggregation_temporality` to the semantic value, or `None` when the
283/// instrument has no temporality (gauge/summary) or it is unspecified.
284fn temporality_value(data: &metric::Data) -> Option<&'static str> {
285    let raw = match data {
286        metric::Data::Sum(sum) => sum.aggregation_temporality,
287        metric::Data::Histogram(hist) => hist.aggregation_temporality,
288        metric::Data::ExponentialHistogram(hist) => hist.aggregation_temporality,
289        _ => return None,
290    };
291    match AggregationTemporality::try_from(raw) {
292        Ok(AggregationTemporality::Delta) => Some(METRIC_TEMPORALITY_DELTA),
293        Ok(AggregationTemporality::Cumulative) => Some(METRIC_TEMPORALITY_CUMULATIVE),
294        _ => None,
295    }
296}
297
298/// Records the declared metric-level semantic keys for every table this metric
299/// emits.
300fn record_metric_semantics(
301    index: &mut SemanticIndex,
302    metric: &Metric,
303    name: &str,
304    metric_ctx: &OtlpMetricCtx,
305) {
306    let emitted = emitted_semantic_tables(&metric_ctx.metric_type, metric_ctx.is_legacy, name);
307    if emitted.is_empty() {
308        return;
309    }
310
311    let temporality = metric.data.as_ref().and_then(temporality_value);
312    let unit = metric.unit.trim();
313    // `original_name` is meaningful only when translation renamed the metric.
314    let original_name = (name != metric.name.as_str()).then_some(metric.name.as_str());
315
316    for (table, metric_type) in &emitted {
317        index.record_scalar(table, SEMANTIC_METRIC_TYPE, metric_type);
318        index.record_scalar(
319            table,
320            SEMANTIC_METRIC_METADATA_QUALITY,
321            METADATA_QUALITY_DECLARED,
322        );
323        if let Some(temporality) = temporality {
324            index.record_scalar(table, SEMANTIC_METRIC_TEMPORALITY, temporality);
325        }
326        if !unit.is_empty() {
327            index.record_scalar(table, SEMANTIC_METRIC_UNIT, unit);
328        }
329        if let Some(original_name) = original_name {
330            index.record_scalar(table, SEMANTIC_METRIC_ORIGINAL_NAME, original_name);
331        }
332    }
333}
334
335fn from_metric_type(data: &metric::Data) -> MetricType {
336    match data {
337        metric::Data::Gauge(_) => MetricType::Gauge,
338        metric::Data::Sum(s) => {
339            if s.is_monotonic {
340                MetricType::MonotonicSum
341            } else {
342                MetricType::NonMonotonicSum
343            }
344        }
345        metric::Data::Histogram(_) => MetricType::Histogram,
346        metric::Data::ExponentialHistogram(_) => MetricType::ExponentialHistogram,
347        metric::Data::Summary(_) => MetricType::Summary,
348    }
349}
350
351/// Non-scalar values (bool, arrays, maps, bytes) are not representable as tags.
352fn scalar_value_string(value: Option<&AnyValue>) -> Option<String> {
353    match value.and_then(|v| v.value.as_ref())? {
354        any_value::Value::StringValue(s) => Some(s.clone()),
355        any_value::Value::IntValue(v) => Some(v.to_string()),
356        any_value::Value::DoubleValue(v) => Some(v.to_string()),
357        _ => None,
358    }
359}
360
361/// Prometheus-style `(job, instance)` identity. Per the OTel Prometheus
362/// compatibility spec `job` folds in `service.namespace` when present; a
363/// resource without `service.name` gets no job rather than a fabricated one.
364/// Both fields are optional strings, so a named type keeps a swapped
365/// destructuring from type-checking at the call sites.
366pub(crate) struct ServiceIdentity {
367    pub job: Option<String>,
368    pub instance: Option<String>,
369}
370
371pub(crate) fn service_identity(attrs: &[KeyValue]) -> ServiceIdentity {
372    let mut name = None;
373    let mut namespace = None;
374    let mut instance = None;
375    for kv in attrs {
376        match kv.key.as_str() {
377            KEY_SERVICE_NAME => name = scalar_value_string(kv.value.as_ref()),
378            KEY_SERVICE_NAMESPACE => namespace = scalar_value_string(kv.value.as_ref()),
379            KEY_SERVICE_INSTANCE_ID => instance = scalar_value_string(kv.value.as_ref()),
380            _ => {}
381        }
382    }
383    let job = name.map(|name| match namespace {
384        Some(ns) if !ns.is_empty() => format!("{ns}/{name}"),
385        _ => name,
386    });
387    ServiceIdentity { job, instance }
388}
389
390fn string_key_value(key: &str, value: String) -> KeyValue {
391    KeyValue {
392        key: key.to_string(),
393        value: Some(AnyValue {
394            value: Some(any_value::Value::StringValue(value)),
395        }),
396    }
397}
398
399fn process_resource_attrs(attrs: &mut Vec<KeyValue>, metric_ctx: &OtlpMetricCtx) {
400    if metric_ctx.is_legacy {
401        return;
402    }
403
404    // remap the service identity attributes to job and instance
405    let ServiceIdentity { job, instance } = service_identity(attrs);
406
407    // if promote all, then exclude the list, else, include the list
408    if metric_ctx.promote_all_resource_attrs {
409        attrs.retain(|kv| !metric_ctx.resource_attrs.contains(&kv.key));
410    } else {
411        attrs.retain(|kv| {
412            metric_ctx.resource_attrs.contains(&kv.key)
413                || DEFAULT_PROMOTE_ATTRS_SET.contains(&kv.key)
414        });
415    }
416
417    if let Some(job) = job {
418        attrs.push(string_key_value(JOB_KEY, job));
419    }
420    if let Some(instance) = instance {
421        attrs.push(string_key_value(INSTANCE_KEY, instance));
422    }
423}
424
425fn process_scope_attrs(scope: &ScopeMetrics, metric_ctx: &OtlpMetricCtx) -> Option<Vec<KeyValue>> {
426    if metric_ctx.is_legacy {
427        return scope.scope.as_ref().map(|s| s.attributes.clone());
428    };
429
430    if !metric_ctx.promote_scope_attrs {
431        return None;
432    }
433
434    // persist scope attrs with name, version and schema_url
435    scope.scope.as_ref().map(|s| {
436        let mut attrs = s.attributes.clone();
437        attrs.push(KeyValue {
438            key: OTEL_SCOPE_NAME.to_string(),
439            value: Some(AnyValue {
440                value: Some(any_value::Value::StringValue(s.name.clone())),
441            }),
442        });
443        attrs.push(KeyValue {
444            key: OTEL_SCOPE_VERSION.to_string(),
445            value: Some(AnyValue {
446                value: Some(any_value::Value::StringValue(s.version.clone())),
447            }),
448        });
449        attrs.push(KeyValue {
450            key: OTEL_SCOPE_SCHEMA_URL.to_string(),
451            value: Some(AnyValue {
452                value: Some(any_value::Value::StringValue(scope.schema_url.clone())),
453            }),
454        });
455        attrs
456    })
457}
458
459fn encode_metrics(
460    table_writer: &mut MultiTableData,
461    metric: &Metric,
462    resource_attrs: Option<&Vec<KeyValue>>,
463    scope_attrs: Option<&Vec<KeyValue>>,
464    metric_ctx: &OtlpMetricCtx,
465    semantic_index: &mut SemanticIndex,
466    outcome: &mut MetricsIngestOutcome,
467) -> Result<()> {
468    let name = if metric_ctx.is_legacy {
469        legacy_normalize_otlp_name(&metric.name)
470    } else {
471        translate_metric_name(
472            metric,
473            &metric_ctx.metric_type,
474            metric_ctx.metric_translation_strategy,
475        )
476    };
477
478    let emitted = if let Some(data) = &metric.data {
479        match data {
480            metric::Data::Gauge(gauge) => {
481                encode_gauge(
482                    table_writer,
483                    &name,
484                    gauge,
485                    resource_attrs,
486                    scope_attrs,
487                    metric_ctx,
488                )?;
489                add_accepted_data_points(outcome, gauge.data_points.len())?;
490                !gauge.data_points.is_empty()
491            }
492            metric::Data::Sum(sum) => {
493                encode_sum(
494                    table_writer,
495                    &name,
496                    sum,
497                    resource_attrs,
498                    scope_attrs,
499                    metric_ctx,
500                )?;
501                add_accepted_data_points(outcome, sum.data_points.len())?;
502                !sum.data_points.is_empty()
503            }
504            metric::Data::Summary(summary) => {
505                encode_summary(
506                    table_writer,
507                    &name,
508                    summary,
509                    resource_attrs,
510                    scope_attrs,
511                    metric_ctx,
512                )?;
513                add_accepted_data_points(outcome, summary.data_points.len())?;
514                !summary.data_points.is_empty()
515            }
516            metric::Data::Histogram(hist) => encode_histogram(
517                table_writer,
518                &name,
519                hist,
520                resource_attrs,
521                scope_attrs,
522                metric_ctx,
523                outcome,
524            )?,
525            metric::Data::ExponentialHistogram(hist) => encode_exponential_histogram(
526                table_writer,
527                &name,
528                hist,
529                resource_attrs,
530                scope_attrs,
531                metric_ctx,
532                outcome,
533            )?,
534        }
535    } else {
536        false
537    };
538
539    if emitted {
540        // Stamp semantic metadata only after at least one row was accepted.
541        record_metric_semantics(semantic_index, metric, &name, metric_ctx);
542    }
543
544    Ok(())
545}
546
547fn add_accepted_data_points(outcome: &mut MetricsIngestOutcome, count: usize) -> Result<()> {
548    let count = i64::try_from(count).map_err(|_| {
549        error::InvalidParameterSnafu {
550            reason: "OTLP metrics data-point count exceeds i64",
551        }
552        .build()
553    })?;
554    outcome.accepted_data_points =
555        outcome
556            .accepted_data_points
557            .checked_add(count)
558            .ok_or_else(|| {
559                error::InvalidParameterSnafu {
560                    reason: "OTLP accepted data-point count overflows i64",
561                }
562                .build()
563            })?;
564    Ok(())
565}
566
567fn reject_data_points(
568    outcome: &mut MetricsIngestOutcome,
569    count: usize,
570    reason: impl FnOnce() -> String,
571) -> Result<()> {
572    if count == 0 {
573        return Ok(());
574    }
575    let count = i64::try_from(count).map_err(|_| {
576        error::InvalidParameterSnafu {
577            reason: "OTLP rejected data-point count exceeds i64",
578        }
579        .build()
580    })?;
581    outcome.rejected_data_points =
582        outcome
583            .rejected_data_points
584            .checked_add(count)
585            .ok_or_else(|| {
586                error::InvalidParameterSnafu {
587                    reason: "OTLP rejected data-point count overflows i64",
588                }
589                .build()
590            })?;
591    append_rejection_message(&mut outcome.error_message, reason);
592    Ok(())
593}
594
595fn append_rejection_message(message: &mut Option<String>, reason: impl FnOnce() -> String) {
596    let message = message.get_or_insert_with(String::new);
597    let separator = if message.is_empty() { "" } else { "; " };
598    let Some(available) = MAX_REJECTION_MESSAGE_BYTES.checked_sub(message.len()) else {
599        return;
600    };
601    if available <= separator.len() {
602        return;
603    }
604    let reason = reason();
605    message.push_str(separator);
606
607    let available = MAX_REJECTION_MESSAGE_BYTES - message.len();
608    if reason.len() <= available {
609        message.push_str(&reason);
610        return;
611    }
612
613    const ELLIPSIS: &str = "...";
614    let mut end = available.saturating_sub(ELLIPSIS.len());
615    while !reason.is_char_boundary(end) {
616        end -= 1;
617    }
618    message.push_str(&reason[..end]);
619    if available >= ELLIPSIS.len() {
620        message.push_str(ELLIPSIS);
621    }
622}
623
624fn encode_exponential_histogram(
625    table_writer: &mut MultiTableData,
626    name: &str,
627    histogram: &ExponentialHistogram,
628    resource_attrs: Option<&Vec<KeyValue>>,
629    scope_attrs: Option<&Vec<KeyValue>>,
630    metric_ctx: &OtlpMetricCtx,
631    outcome: &mut MetricsIngestOutcome,
632) -> Result<bool> {
633    if let Err(rejection) = exponential_histogram_gate(histogram, metric_ctx) {
634        reject_data_points(outcome, histogram.data_points.len(), || {
635            rejection.message(name)
636        })?;
637        return Ok(false);
638    }
639
640    let column_schema = native_histogram_column_schema().map_err(|error| {
641        error::InternalSnafu {
642            err_msg: error.to_string(),
643        }
644        .build()
645    })?;
646    let mut emitted = false;
647    for (index, data_point) in histogram.data_points.iter().enumerate() {
648        let (value, timestamp_nanos) = match exponential_histogram_value(data_point) {
649            Ok(value) => value,
650            Err(reason) => {
651                reject_data_points(outcome, 1, || {
652                    format!("metric `{name}` data point {index}: {reason}")
653                })?;
654                continue;
655            }
656        };
657
658        let table = table_writer.get_or_default_table_data(
659            name,
660            APPROXIMATE_COLUMN_COUNT,
661            histogram.data_points.len(),
662        );
663        let mut row = table.alloc_one_row();
664        write_tags_and_timestamp(
665            table,
666            &mut row,
667            resource_attrs,
668            scope_attrs,
669            Some(data_point.attributes.as_ref()),
670            timestamp_nanos,
671            metric_ctx,
672        )?;
673        row_writer::write_by_schema(
674            table,
675            std::iter::once((column_schema.clone(), Some(value))),
676            &mut row,
677        )?;
678        table.add_row(row);
679        add_accepted_data_points(outcome, 1)?;
680        emitted = true;
681    }
682
683    Ok(emitted)
684}
685
686pub(crate) enum ExponentialHistogramRejection {
687    Disabled,
688    DeltaTemporality,
689    UnspecifiedTemporality,
690}
691
692impl ExponentialHistogramRejection {
693    fn message(&self, name: &str) -> String {
694        match self {
695            Self::Disabled => format!(
696                "metric `{name}` uses OTLP exponential histograms; set otlp.experimental_enable_exponential_histogram = true to enable ingestion"
697            ),
698            Self::DeltaTemporality => format!(
699                "metric `{name}` uses delta OTLP exponential histograms; only cumulative temporality is supported"
700            ),
701            Self::UnspecifiedTemporality => format!(
702                "metric `{name}` has unspecified OTLP exponential histogram temporality; cumulative temporality is required"
703            ),
704        }
705    }
706}
707
708/// Whole-metric acceptance, decided once for the encoder and for the resource
709/// descriptor, which must not describe a resource whose data was rejected.
710/// Individual points can still fail [`exponential_histogram_value`].
711pub(crate) fn exponential_histogram_gate(
712    histogram: &ExponentialHistogram,
713    metric_ctx: &OtlpMetricCtx,
714) -> std::result::Result<(), ExponentialHistogramRejection> {
715    if !metric_ctx.experimental_enable_exponential_histogram {
716        return Err(ExponentialHistogramRejection::Disabled);
717    }
718    match AggregationTemporality::try_from(histogram.aggregation_temporality) {
719        Ok(AggregationTemporality::Cumulative) => Ok(()),
720        Ok(AggregationTemporality::Delta) => Err(ExponentialHistogramRejection::DeltaTemporality),
721        _ => Err(ExponentialHistogramRejection::UnspecifiedTemporality),
722    }
723}
724
725pub(crate) fn exponential_histogram_value(
726    data_point: &ExponentialHistogramDataPoint,
727) -> std::result::Result<(ValueData, i64), String> {
728    if data_point.start_time_unix_nano > data_point.time_unix_nano {
729        return Err(format!(
730            "start_time_unix_nano {} exceeds time_unix_nano {}",
731            data_point.start_time_unix_nano, data_point.time_unix_nano
732        ));
733    }
734
735    let timestamp_nanos = i64::try_from(data_point.time_unix_nano)
736        .map_err(|_| format!("time_unix_nano {} overflows i64", data_point.time_unix_nano))?;
737    let timestamp = timestamp_millis(data_point.time_unix_nano, "time_unix_nano")?;
738    let start_timestamp =
739        timestamp_millis(data_point.start_time_unix_nano, "start_time_unix_nano")?;
740
741    let no_recorded_value = data_point.flags & DataPointFlags::NoRecordedValueMask as u32 != 0;
742    let histogram = if no_recorded_value {
743        PromHistogram {
744            sum: f64::from_bits(PROMETHEUS_STALE_NAN_BITS),
745            schema: 0,
746            zero_threshold: 0.0,
747            reset_hint: ResetHint::Unspecified as i32,
748            timestamp,
749            start_timestamp,
750            count: Some(PromCount::CountInt(0)),
751            zero_count: Some(PromZeroCount::ZeroCountInt(0)),
752            ..Default::default()
753        }
754    } else {
755        if data_point.scale < MIN_EXPONENTIAL_HISTOGRAM_SCALE {
756            return Err(format!(
757                "scale {} is unsupported; minimum supported scale is {}",
758                data_point.scale, MIN_EXPONENTIAL_HISTOGRAM_SCALE
759            ));
760        }
761        if !data_point.zero_threshold.is_finite() || data_point.zero_threshold < 0.0 {
762            return Err(format!(
763                "zero_threshold {} must be finite and non-negative",
764                data_point.zero_threshold
765            ));
766        }
767
768        let downscale_shift = if data_point.scale > MAX_EXPONENTIAL_HISTOGRAM_SCALE {
769            let shift = data_point
770                .scale
771                .checked_sub(MAX_EXPONENTIAL_HISTOGRAM_SCALE)
772                .ok_or_else(|| "downscale shift overflows i32".to_string())?;
773            u32::try_from(shift).map_err(|_| "downscale shift exceeds u32".to_string())?
774        } else {
775            0
776        };
777        let (positive_spans, positive_deltas, positive_count) =
778            convert_bucket_range("positive", data_point.positive.as_ref(), downscale_shift)?;
779        let (negative_spans, negative_deltas, negative_count) =
780            convert_bucket_range("negative", data_point.negative.as_ref(), downscale_shift)?;
781        let bucket_count = data_point
782            .zero_count
783            .checked_add(positive_count)
784            .and_then(|count| count.checked_add(negative_count))
785            .ok_or_else(|| "bucket observation total overflows u64".to_string())?;
786        if bucket_count != data_point.count {
787            return Err(format!(
788                "buckets contain {bucket_count} observations, declared count is {}",
789                data_point.count
790            ));
791        }
792
793        i64::try_from(data_point.count)
794            .map_err(|_| format!("count {} overflows i64", data_point.count))?;
795        i64::try_from(data_point.zero_count)
796            .map_err(|_| format!("zero_count {} overflows i64", data_point.zero_count))?;
797
798        let sum = match data_point.sum {
799            Some(sum) if sum.is_nan() => f64::NAN,
800            Some(sum) => sum,
801            None => f64::NAN,
802        };
803        PromHistogram {
804            sum,
805            schema: data_point.scale.min(MAX_EXPONENTIAL_HISTOGRAM_SCALE),
806            zero_threshold: data_point.zero_threshold,
807            negative_spans,
808            negative_deltas,
809            positive_spans,
810            positive_deltas,
811            reset_hint: ResetHint::Unspecified as i32,
812            timestamp,
813            start_timestamp,
814            count: Some(PromCount::CountInt(data_point.count)),
815            zero_count: Some(PromZeroCount::ZeroCountInt(data_point.zero_count)),
816            ..Default::default()
817        }
818    };
819
820    encode_native_histogram(&histogram)
821        .map(|value| (value, timestamp_nanos))
822        .map_err(|error| format!("OTLP exponential histogram cannot be encoded: {error}"))
823}
824
825fn timestamp_millis(timestamp_nanos: u64, name: &str) -> std::result::Result<i64, String> {
826    i64::try_from(timestamp_nanos / 1_000_000)
827        .map_err(|_| format!("{name} {timestamp_nanos} milliseconds overflow i64"))
828}
829
830fn convert_bucket_range(
831    name: &str,
832    buckets: Option<&exponential_histogram_data_point::Buckets>,
833    downscale_shift: u32,
834) -> std::result::Result<(Vec<BucketSpan>, Vec<i64>, u64), String> {
835    let Some(buckets) = buckets else {
836        return Ok((Vec::new(), Vec::new(), 0));
837    };
838    if buckets.bucket_counts.is_empty() {
839        return Ok((Vec::new(), Vec::new(), 0));
840    }
841
842    let mut merged = Vec::<(i32, u64)>::with_capacity(buckets.bucket_counts.len());
843    let mut total = 0u64;
844    for (position, count) in buckets.bucket_counts.iter().copied().enumerate() {
845        let position =
846            i32::try_from(position).map_err(|_| format!("{name} bucket length exceeds i32"))?;
847        let source_index = buckets.offset.checked_add(position).ok_or_else(|| {
848            format!(
849                "{name} bucket index overflows i32 at offset {} and position {position}",
850                buckets.offset
851            )
852        })?;
853        let target_index = downscale_bucket_index(source_index, downscale_shift)?
854            .checked_add(1)
855            .ok_or_else(|| format!("{name} shifted bucket index overflows i32"))?;
856        if let Some((last_index, last_count)) = merged.last_mut() {
857            if *last_index == target_index {
858                *last_count = last_count
859                    .checked_add(count)
860                    .ok_or_else(|| format!("{name} merged bucket count overflows u64"))?;
861                total = total
862                    .checked_add(count)
863                    .ok_or_else(|| format!("{name} bucket count total overflows u64"))?;
864                continue;
865            }
866            let next_index = last_index
867                .checked_add(1)
868                .ok_or_else(|| format!("{name} target bucket index overflows i32"))?;
869            if target_index != next_index {
870                return Err(format!(
871                    "{name} bucket indexes are not contiguous after downscaling"
872                ));
873            }
874        }
875        total = total
876            .checked_add(count)
877            .ok_or_else(|| format!("{name} bucket count total overflows u64"))?;
878        merged.push((target_index, count));
879    }
880
881    let length = u32::try_from(merged.len())
882        .map_err(|_| format!("{name} bucket span length exceeds u32"))?;
883    let span = BucketSpan {
884        offset: merged[0].0,
885        length,
886    };
887    let mut deltas = Vec::with_capacity(merged.len());
888    let mut previous = 0i64;
889    for (_, count) in merged {
890        let count = i64::try_from(count)
891            .map_err(|_| format!("{name} bucket count {count} overflows i64"))?;
892        let delta = count
893            .checked_sub(previous)
894            .ok_or_else(|| format!("{name} bucket delta overflows i64"))?;
895        deltas.push(delta);
896        previous = count;
897    }
898
899    Ok((vec![span], deltas, total))
900}
901
902fn downscale_bucket_index(index: i32, downscale_shift: u32) -> std::result::Result<i32, String> {
903    if downscale_shift == 0 {
904        return Ok(index);
905    }
906    if downscale_shift >= i32::BITS {
907        return Ok(if index < 0 { -1 } else { 0 });
908    }
909
910    let divisor = 1i64
911        .checked_shl(downscale_shift)
912        .ok_or_else(|| format!("downscale shift {downscale_shift} is invalid"))?;
913    i32::try_from(i64::from(index).div_euclid(divisor))
914        .map_err(|_| format!("downscaled bucket index {index} overflows i32"))
915}
916
917#[derive(Debug, Clone, Copy, PartialEq, Eq)]
918enum AttributeType {
919    Resource,
920    Scope,
921    DataPoint,
922    Legacy,
923}
924
925fn write_attributes(
926    writer: &mut TableData,
927    row: &mut Vec<Value>,
928    attrs: Option<&Vec<KeyValue>>,
929    attribute_type: AttributeType,
930    metric_ctx: &OtlpMetricCtx,
931) -> Result<()> {
932    let Some(attrs) = attrs else {
933        return Ok(());
934    };
935
936    let mut tags = Vec::with_capacity(attrs.len());
937    for attr in attrs {
938        // TODO(sunng87): allow different type of values
939        let Some(value) = scalar_value_string(attr.value.as_ref()) else {
940            continue;
941        };
942        let key = match attribute_type {
943            AttributeType::Resource | AttributeType::DataPoint => {
944                translate_label_name(&attr.key, metric_ctx.metric_translation_strategy)
945            }
946            AttributeType::Scope => {
947                format!(
948                    "otel_scope_{}",
949                    translate_label_name(&attr.key, metric_ctx.metric_translation_strategy)
950                )
951            }
952            AttributeType::Legacy => legacy_normalize_otlp_name(&attr.key),
953        };
954        if key == OTLP_AGGREGATION_TEMPORALITY_LABEL {
955            return Err(error::InvalidOtlpMetricInputSnafu {
956                reason: format!(
957                    "OTLP attribute `{}` resolves to reserved label `{}`",
958                    attr.key, OTLP_AGGREGATION_TEMPORALITY_LABEL
959                ),
960            }
961            .build());
962        }
963        tags.push((key, value));
964    }
965    row_writer::write_tags(writer, tags.into_iter(), row)?;
966
967    Ok(())
968}
969
970fn write_timestamp(
971    table: &mut TableData,
972    row: &mut Vec<Value>,
973    time_nano: i64,
974    legacy_mode: bool,
975) -> Result<()> {
976    if legacy_mode {
977        row_writer::write_ts_to_nanos(
978            table,
979            greptime_timestamp(),
980            Some(time_nano),
981            Precision::Nanosecond,
982            row,
983        )
984    } else {
985        row_writer::write_ts_to_millis(
986            table,
987            greptime_timestamp(),
988            Some(time_nano / 1000000),
989            Precision::Millisecond,
990            row,
991        )
992    }
993}
994
995fn write_data_point_value(
996    table: &mut TableData,
997    row: &mut Vec<Value>,
998    field: &str,
999    value: &Option<number_data_point::Value>,
1000) -> Result<()> {
1001    match value {
1002        Some(number_data_point::Value::AsInt(val)) => {
1003            // we coerce all values to f64
1004            row_writer::write_f64(table, field, *val as f64, row)?;
1005        }
1006        Some(number_data_point::Value::AsDouble(val)) => {
1007            row_writer::write_f64(table, field, *val, row)?;
1008        }
1009        _ => {}
1010    }
1011    Ok(())
1012}
1013
1014fn write_temporality_tag(
1015    table: &mut TableData,
1016    row: &mut Vec<Value>,
1017    is_delta: bool,
1018) -> Result<()> {
1019    if is_delta {
1020        row_writer::write_tag(
1021            table,
1022            OTLP_AGGREGATION_TEMPORALITY_LABEL,
1023            GREPTIME_TEMPORALITY_DELTA,
1024            row,
1025        )?;
1026    }
1027    Ok(())
1028}
1029
1030fn has_no_recorded_value(flags: u32) -> bool {
1031    flags & DataPointFlags::NoRecordedValueMask as u32 != 0
1032}
1033
1034fn write_tags_and_timestamp(
1035    table: &mut TableData,
1036    row: &mut Vec<Value>,
1037    resource_attrs: Option<&Vec<KeyValue>>,
1038    scope_attrs: Option<&Vec<KeyValue>>,
1039    data_point_attrs: Option<&Vec<KeyValue>>,
1040    timestamp_nanos: i64,
1041    metric_ctx: &OtlpMetricCtx,
1042) -> Result<()> {
1043    if metric_ctx.is_legacy {
1044        write_attributes(
1045            table,
1046            row,
1047            resource_attrs,
1048            AttributeType::Legacy,
1049            metric_ctx,
1050        )?;
1051        write_attributes(table, row, scope_attrs, AttributeType::Legacy, metric_ctx)?;
1052        write_attributes(
1053            table,
1054            row,
1055            data_point_attrs,
1056            AttributeType::Legacy,
1057            metric_ctx,
1058        )?;
1059    } else {
1060        // TODO(shuiyisong): check `__type__` and `__unit__` tags in prometheus
1061        write_attributes(
1062            table,
1063            row,
1064            resource_attrs,
1065            AttributeType::Resource,
1066            metric_ctx,
1067        )?;
1068        write_attributes(table, row, scope_attrs, AttributeType::Scope, metric_ctx)?;
1069        write_attributes(
1070            table,
1071            row,
1072            data_point_attrs,
1073            AttributeType::DataPoint,
1074            metric_ctx,
1075        )?;
1076    }
1077
1078    write_timestamp(table, row, timestamp_nanos, metric_ctx.is_legacy)?;
1079
1080    Ok(())
1081}
1082
1083/// encode this gauge metric
1084///
1085/// note that there can be multiple data points in the request, it's going to be
1086/// stored as multiple rows
1087fn encode_gauge(
1088    table_writer: &mut MultiTableData,
1089    name: &str,
1090    gauge: &Gauge,
1091    resource_attrs: Option<&Vec<KeyValue>>,
1092    scope_attrs: Option<&Vec<KeyValue>>,
1093    metric_ctx: &OtlpMetricCtx,
1094) -> Result<()> {
1095    let table = table_writer.get_or_default_table_data(
1096        name,
1097        APPROXIMATE_COLUMN_COUNT,
1098        gauge.data_points.len(),
1099    );
1100
1101    for data_point in &gauge.data_points {
1102        let mut row = table.alloc_one_row();
1103        write_tags_and_timestamp(
1104            table,
1105            &mut row,
1106            resource_attrs,
1107            scope_attrs,
1108            Some(data_point.attributes.as_ref()),
1109            data_point.time_unix_nano as i64,
1110            metric_ctx,
1111        )?;
1112
1113        write_data_point_value(table, &mut row, greptime_value(), &data_point.value)?;
1114        table.add_row(row);
1115    }
1116
1117    Ok(())
1118}
1119
1120/// encode this sum metric
1121///
1122fn encode_sum(
1123    table_writer: &mut MultiTableData,
1124    name: &str,
1125    sum: &Sum,
1126    resource_attrs: Option<&Vec<KeyValue>>,
1127    scope_attrs: Option<&Vec<KeyValue>>,
1128    metric_ctx: &OtlpMetricCtx,
1129) -> Result<()> {
1130    let is_delta = matches!(
1131        AggregationTemporality::try_from(sum.aggregation_temporality),
1132        Ok(AggregationTemporality::Delta)
1133    );
1134    let table = table_writer.get_or_default_table_data(
1135        name,
1136        APPROXIMATE_COLUMN_COUNT,
1137        sum.data_points.len(),
1138    );
1139
1140    for data_point in &sum.data_points {
1141        let mut row = table.alloc_one_row();
1142        write_tags_and_timestamp(
1143            table,
1144            &mut row,
1145            resource_attrs,
1146            scope_attrs,
1147            Some(data_point.attributes.as_ref()),
1148            data_point.time_unix_nano as i64,
1149            metric_ctx,
1150        )?;
1151        write_temporality_tag(table, &mut row, is_delta)?;
1152        if has_no_recorded_value(data_point.flags) {
1153            row_writer::write_f64(
1154                table,
1155                greptime_value(),
1156                f64::from_bits(PROMETHEUS_STALE_NAN_BITS),
1157                &mut row,
1158            )?;
1159        } else {
1160            write_data_point_value(table, &mut row, greptime_value(), &data_point.value)?;
1161        }
1162        table.add_row(row);
1163    }
1164
1165    Ok(())
1166}
1167
1168const HISTOGRAM_LE_COLUMN: &str = "le";
1169
1170/// Encode histogram data. This function returns 3 insert requests for 3 tables.
1171///
1172/// The implementation has been following Prometheus histogram table format:
1173///
1174/// - A `%metric%_bucket` table including `greptime_le` tag that stores bucket upper
1175///   limit, and `greptime_value` for bucket count
1176/// - A `%metric%_sum` table storing sum of samples
1177/// -  A `%metric%_count` table storing count of samples.
1178///
1179/// By its Prometheus compatibility, we hope to be able to use prometheus
1180/// quantile functions on this table.
1181fn encode_histogram(
1182    table_writer: &mut MultiTableData,
1183    name: &str,
1184    hist: &Histogram,
1185    resource_attrs: Option<&Vec<KeyValue>>,
1186    scope_attrs: Option<&Vec<KeyValue>>,
1187    metric_ctx: &OtlpMetricCtx,
1188    outcome: &mut MetricsIngestOutcome,
1189) -> Result<bool> {
1190    let normalized_name = name;
1191
1192    let bucket_table_name = format!("{}{}", normalized_name, BUCKET_TABLE_SUFFIX);
1193    let sum_table_name = format!("{}{}", normalized_name, SUM_TABLE_SUFFIX);
1194    let count_table_name = format!("{}{}", normalized_name, COUNT_TABLE_SUFFIX);
1195
1196    let is_delta = matches!(
1197        AggregationTemporality::try_from(hist.aggregation_temporality),
1198        Ok(AggregationTemporality::Delta)
1199    );
1200    let stale_value = f64::from_bits(PROMETHEUS_STALE_NAN_BITS);
1201    let mut emitted = false;
1202    for (index, data_point) in hist.data_points.iter().enumerate() {
1203        if let Some(reason) = histogram_data_point_rejection(data_point, is_delta) {
1204            reject_data_points(outcome, 1, || {
1205                format!("metric `{name}` data point {index}: {reason}")
1206            })?;
1207            continue;
1208        }
1209
1210        let bucket_table =
1211            table_writer.get_or_default_table_data(&bucket_table_name, APPROXIMATE_COLUMN_COUNT, 0);
1212        let no_recorded_value = has_no_recorded_value(data_point.flags);
1213        if no_recorded_value {
1214            bucket_table.reserve_rows(data_point.explicit_bounds.len());
1215            bucket_table.reserve_rows(1);
1216        } else {
1217            bucket_table.reserve_rows(data_point.bucket_counts.len().max(1));
1218        }
1219        let bucket_values = if no_recorded_value {
1220            data_point
1221                .explicit_bounds
1222                .iter()
1223                .copied()
1224                .chain(std::iter::once(f64::INFINITY))
1225                .map(|bound| (bound, stale_value))
1226                .collect::<Vec<_>>()
1227        } else if data_point.bucket_counts.is_empty() && data_point.explicit_bounds.is_empty() {
1228            // OTLP count-only histograms map to one implicit Prometheus infinity bucket.
1229            vec![(f64::INFINITY, data_point.count as f64)]
1230        } else {
1231            let mut accumulated_count = 0u64;
1232            let mut values = Vec::with_capacity(data_point.bucket_counts.len());
1233            for (idx, count) in data_point.bucket_counts.iter().enumerate() {
1234                accumulated_count = accumulated_count.checked_add(*count).ok_or_else(|| {
1235                    error::InvalidParameterSnafu {
1236                        reason: format!(
1237                            "metric `{name}` data point {index}: bucket prefix overflows u64"
1238                        ),
1239                    }
1240                    .build()
1241                })?;
1242                let bound =
1243                    data_point.explicit_bounds.get(idx).copied().or_else(|| {
1244                        (idx == data_point.explicit_bounds.len()).then_some(f64::INFINITY)
1245                    });
1246                if let Some(bound) = bound {
1247                    values.push((bound, accumulated_count as f64));
1248                }
1249            }
1250            values
1251        };
1252        for (bound, value) in bucket_values {
1253            let mut bucket_row = bucket_table.alloc_one_row();
1254            write_tags_and_timestamp(
1255                bucket_table,
1256                &mut bucket_row,
1257                resource_attrs,
1258                scope_attrs,
1259                Some(data_point.attributes.as_ref()),
1260                data_point.time_unix_nano as i64,
1261                metric_ctx,
1262            )?;
1263            write_temporality_tag(bucket_table, &mut bucket_row, is_delta)?;
1264            row_writer::write_tag(bucket_table, HISTOGRAM_LE_COLUMN, bound, &mut bucket_row)?;
1265            row_writer::write_f64(bucket_table, greptime_value(), value, &mut bucket_row)?;
1266
1267            bucket_table.add_row(bucket_row);
1268        }
1269
1270        if let Some(sum) = data_point.sum {
1271            let sum_table = table_writer.get_or_default_table_data(
1272                &sum_table_name,
1273                APPROXIMATE_COLUMN_COUNT,
1274                hist.data_points.len(),
1275            );
1276            let mut sum_row = sum_table.alloc_one_row();
1277            write_tags_and_timestamp(
1278                sum_table,
1279                &mut sum_row,
1280                resource_attrs,
1281                scope_attrs,
1282                Some(data_point.attributes.as_ref()),
1283                data_point.time_unix_nano as i64,
1284                metric_ctx,
1285            )?;
1286            write_temporality_tag(sum_table, &mut sum_row, is_delta)?;
1287            row_writer::write_f64(
1288                sum_table,
1289                greptime_value(),
1290                if no_recorded_value { stale_value } else { sum },
1291                &mut sum_row,
1292            )?;
1293            sum_table.add_row(sum_row);
1294        }
1295
1296        let count_table = table_writer.get_or_default_table_data(
1297            &count_table_name,
1298            APPROXIMATE_COLUMN_COUNT,
1299            hist.data_points.len(),
1300        );
1301        let mut count_row = count_table.alloc_one_row();
1302        write_tags_and_timestamp(
1303            count_table,
1304            &mut count_row,
1305            resource_attrs,
1306            scope_attrs,
1307            Some(data_point.attributes.as_ref()),
1308            data_point.time_unix_nano as i64,
1309            metric_ctx,
1310        )?;
1311        write_temporality_tag(count_table, &mut count_row, is_delta)?;
1312        row_writer::write_f64(
1313            count_table,
1314            greptime_value(),
1315            if no_recorded_value {
1316                stale_value
1317            } else {
1318                data_point.count as f64
1319            },
1320            &mut count_row,
1321        )?;
1322        count_table.add_row(count_row);
1323        add_accepted_data_points(outcome, 1)?;
1324        emitted = true;
1325    }
1326
1327    Ok(emitted)
1328}
1329
1330pub(crate) fn histogram_data_point_rejection(
1331    data_point: &HistogramDataPoint,
1332    is_delta: bool,
1333) -> Option<String> {
1334    if has_no_recorded_value(data_point.flags) {
1335        return None;
1336    }
1337
1338    if is_delta {
1339        let valid_empty_layout =
1340            data_point.bucket_counts.is_empty() && data_point.explicit_bounds.is_empty();
1341        let expected_buckets = data_point.explicit_bounds.len().checked_add(1);
1342        if !valid_empty_layout && expected_buckets != Some(data_point.bucket_counts.len()) {
1343            return Some(format!(
1344                "bucket_counts length {} must equal explicit_bounds length {} plus one",
1345                data_point.bucket_counts.len(),
1346                data_point.explicit_bounds.len()
1347            ));
1348        }
1349        if data_point
1350            .explicit_bounds
1351            .iter()
1352            .any(|bound| !bound.is_finite())
1353            || data_point
1354                .explicit_bounds
1355                .windows(2)
1356                .any(|bounds| bounds[0] >= bounds[1])
1357        {
1358            return Some("explicit_bounds must be finite and strictly increasing".to_string());
1359        }
1360        if data_point.count == 0 && data_point.sum.is_some_and(|sum| sum != 0.0) {
1361            return Some("sum must be absent or zero when count is zero".to_string());
1362        }
1363    }
1364
1365    let bucket_total = data_point
1366        .bucket_counts
1367        .iter()
1368        .try_fold(0u64, |total, count| total.checked_add(*count));
1369    let Some(bucket_total) = bucket_total else {
1370        return Some("bucket prefix overflows u64".to_string());
1371    };
1372    if is_delta && !data_point.bucket_counts.is_empty() && bucket_total != data_point.count {
1373        return Some(format!(
1374            "buckets contain {bucket_total} observations, declared count is {}",
1375            data_point.count
1376        ));
1377    }
1378
1379    None
1380}
1381
1382fn encode_summary(
1383    table_writer: &mut MultiTableData,
1384    name: &str,
1385    summary: &Summary,
1386    resource_attrs: Option<&Vec<KeyValue>>,
1387    scope_attrs: Option<&Vec<KeyValue>>,
1388    metric_ctx: &OtlpMetricCtx,
1389) -> Result<()> {
1390    if metric_ctx.is_legacy {
1391        let table = table_writer.get_or_default_table_data(
1392            name,
1393            APPROXIMATE_COLUMN_COUNT,
1394            summary.data_points.len(),
1395        );
1396
1397        for data_point in &summary.data_points {
1398            let mut row = table.alloc_one_row();
1399            write_tags_and_timestamp(
1400                table,
1401                &mut row,
1402                resource_attrs,
1403                scope_attrs,
1404                Some(data_point.attributes.as_ref()),
1405                data_point.time_unix_nano as i64,
1406                metric_ctx,
1407            )?;
1408
1409            for quantile in &data_point.quantile_values {
1410                row_writer::write_f64(
1411                    table,
1412                    format!("greptime_p{:02}", quantile.quantile * 100f64),
1413                    quantile.value,
1414                    &mut row,
1415                )?;
1416            }
1417
1418            row_writer::write_f64(table, GREPTIME_COUNT, data_point.count as f64, &mut row)?;
1419            table.add_row(row);
1420        }
1421    } else {
1422        // 1. quantile table
1423        // 2. count table
1424        // 3. sum table
1425
1426        let metric_name = name;
1427        let count_name = format!("{}{}", metric_name, COUNT_TABLE_SUFFIX);
1428        let sum_name = format!("{}{}", metric_name, SUM_TABLE_SUFFIX);
1429
1430        for data_point in &summary.data_points {
1431            {
1432                let quantile_table = table_writer.get_or_default_table_data(
1433                    metric_name,
1434                    APPROXIMATE_COLUMN_COUNT,
1435                    summary.data_points.len(),
1436                );
1437
1438                for quantile in &data_point.quantile_values {
1439                    let mut row = quantile_table.alloc_one_row();
1440                    write_tags_and_timestamp(
1441                        quantile_table,
1442                        &mut row,
1443                        resource_attrs,
1444                        scope_attrs,
1445                        Some(data_point.attributes.as_ref()),
1446                        data_point.time_unix_nano as i64,
1447                        metric_ctx,
1448                    )?;
1449                    row_writer::write_tag(quantile_table, "quantile", quantile.quantile, &mut row)?;
1450                    row_writer::write_f64(
1451                        quantile_table,
1452                        greptime_value(),
1453                        quantile.value,
1454                        &mut row,
1455                    )?;
1456                    quantile_table.add_row(row);
1457                }
1458            }
1459            {
1460                let count_table = table_writer.get_or_default_table_data(
1461                    &count_name,
1462                    APPROXIMATE_COLUMN_COUNT,
1463                    summary.data_points.len(),
1464                );
1465                let mut row = count_table.alloc_one_row();
1466                write_tags_and_timestamp(
1467                    count_table,
1468                    &mut row,
1469                    resource_attrs,
1470                    scope_attrs,
1471                    Some(data_point.attributes.as_ref()),
1472                    data_point.time_unix_nano as i64,
1473                    metric_ctx,
1474                )?;
1475
1476                row_writer::write_f64(
1477                    count_table,
1478                    greptime_value(),
1479                    data_point.count as f64,
1480                    &mut row,
1481                )?;
1482
1483                count_table.add_row(row);
1484            }
1485            {
1486                let sum_table = table_writer.get_or_default_table_data(
1487                    &sum_name,
1488                    APPROXIMATE_COLUMN_COUNT,
1489                    summary.data_points.len(),
1490                );
1491
1492                let mut row = sum_table.alloc_one_row();
1493                write_tags_and_timestamp(
1494                    sum_table,
1495                    &mut row,
1496                    resource_attrs,
1497                    scope_attrs,
1498                    Some(data_point.attributes.as_ref()),
1499                    data_point.time_unix_nano as i64,
1500                    metric_ctx,
1501                )?;
1502
1503                row_writer::write_f64(sum_table, greptime_value(), data_point.sum, &mut row)?;
1504
1505                sum_table.add_row(row);
1506            }
1507        }
1508    }
1509
1510    Ok(())
1511}
1512
1513#[cfg(test)]
1514mod tests {
1515    use common_query::prelude::set_default_prefix;
1516    use otel_arrow_rust::proto::opentelemetry::common::v1::AnyValue;
1517    use otel_arrow_rust::proto::opentelemetry::common::v1::any_value::Value as Val;
1518    use otel_arrow_rust::proto::opentelemetry::metrics::v1::number_data_point::Value;
1519    use otel_arrow_rust::proto::opentelemetry::metrics::v1::summary_data_point::ValueAtQuantile;
1520    use otel_arrow_rust::proto::opentelemetry::metrics::v1::{
1521        AggregationTemporality, HistogramDataPoint, NumberDataPoint, SummaryDataPoint,
1522    };
1523    use otel_arrow_rust::proto::opentelemetry::resource::v1::Resource;
1524
1525    use super::*;
1526
1527    mod delta;
1528
1529    fn keyvalue(key: &str, value: &str) -> KeyValue {
1530        KeyValue {
1531            key: key.into(),
1532            value: Some(AnyValue {
1533                value: Some(Val::StringValue(value.into())),
1534            }),
1535        }
1536    }
1537
1538    fn descriptor_ctx() -> OtlpMetricCtx {
1539        OtlpMetricCtx {
1540            resource_info: true,
1541            ..Default::default()
1542        }
1543    }
1544
1545    fn attr_value(attrs: &[KeyValue], key: &str) -> Option<String> {
1546        attrs
1547            .iter()
1548            .find(|kv| kv.key == key)
1549            .and_then(|kv| scalar_value_string(kv.value.as_ref()))
1550    }
1551
1552    fn gauge_request(
1553        resource_attrs: Vec<KeyValue>,
1554        metric_name: &str,
1555    ) -> ExportMetricsServiceRequest {
1556        use otel_arrow_rust::proto::opentelemetry::resource::v1::Resource;
1557        ExportMetricsServiceRequest {
1558            resource_metrics: vec![ResourceMetrics {
1559                resource: Some(Resource {
1560                    attributes: resource_attrs,
1561                    ..Default::default()
1562                }),
1563                scope_metrics: vec![ScopeMetrics {
1564                    metrics: vec![Metric {
1565                        name: metric_name.to_string(),
1566                        data: Some(metric::Data::Gauge(Gauge {
1567                            data_points: vec![NumberDataPoint {
1568                                time_unix_nano: 1_000_000,
1569                                value: Some(Value::AsInt(1)),
1570                                ..Default::default()
1571                            }],
1572                        })),
1573                        ..Default::default()
1574                    }],
1575                    ..Default::default()
1576                }],
1577                ..Default::default()
1578            }],
1579        }
1580    }
1581
1582    fn column_names(request: &RowInsertRequests, table: &str) -> Vec<String> {
1583        request
1584            .inserts
1585            .iter()
1586            .find(|r| r.table_name == table)
1587            .unwrap_or_else(|| panic!("missing table {table}"))
1588            .rows
1589            .as_ref()
1590            .unwrap()
1591            .schema
1592            .iter()
1593            .map(|c| c.column_name.clone())
1594            .collect()
1595    }
1596
1597    #[test]
1598    fn test_conversion_synthesizes_resource_descriptor() {
1599        set_default_prefix(None).unwrap();
1600        let request = gauge_request(
1601            vec![keyvalue("service.name", "api"), keyvalue("host.id", "h-1")],
1602            "my_gauge",
1603        );
1604        let conversion = to_grpc_insert_requests(request, &mut descriptor_ctx()).unwrap();
1605
1606        // descriptor keeps raw OTel keys while the metric table's labels went
1607        // through the (default underscore-escaping) translation strategy
1608        let resource_info = conversion.resource_info.expect("descriptor synthesized");
1609        let descriptor_cols = column_names(&resource_info, OTEL_RESOURCE_INFO_TABLE_NAME);
1610        assert!(descriptor_cols.contains(&"host.id".to_string()));
1611        assert!(descriptor_cols.contains(&"service.name".to_string()));
1612        assert!(descriptor_cols.contains(&"job".to_string()));
1613        let metric_cols = column_names(&conversion.requests, "my_gauge");
1614        assert!(metric_cols.contains(&"service_name".to_string()));
1615        assert!(!metric_cols.contains(&"service.name".to_string()));
1616        // host.id is not in the promote list: only the descriptor keeps it
1617        assert!(!metric_cols.contains(&"host_id".to_string()));
1618
1619        let decoded = decode(&conversion.semantic_index);
1620        let t = &decoded[OTEL_RESOURCE_INFO_TABLE_NAME];
1621        assert_eq!(
1622            t.get(SEMANTIC_METRIC_TYPE).map(String::as_str),
1623            Some("info")
1624        );
1625        assert_eq!(
1626            t.get(SEMANTIC_METRIC_METADATA_QUALITY).map(String::as_str),
1627            Some("declared")
1628        );
1629    }
1630
1631    #[test]
1632    fn test_conversion_skips_descriptor_for_legacy_mode() {
1633        set_default_prefix(None).unwrap();
1634        let request = gauge_request(
1635            vec![keyvalue("service.name", "api"), keyvalue("host.id", "h-1")],
1636            "my_gauge",
1637        );
1638        let mut ctx = OtlpMetricCtx {
1639            is_legacy: true,
1640            ..descriptor_ctx()
1641        };
1642        let conversion = to_grpc_insert_requests(request, &mut ctx).unwrap();
1643        assert!(conversion.resource_info.is_none());
1644
1645        // legacy tables predate job/instance and the promote filter; adding
1646        // either would alter the schema of tables already in use
1647        let cols = column_names(&conversion.requests, "my_gauge");
1648        assert!(!cols.contains(&"job".to_string()));
1649        assert!(cols.contains(&"host_id".to_string()));
1650    }
1651
1652    #[test]
1653    fn test_conversion_skips_descriptor_on_metric_name_collision() {
1654        set_default_prefix(None).unwrap();
1655        let request = gauge_request(
1656            vec![keyvalue("service.name", "api")],
1657            OTEL_RESOURCE_INFO_TABLE_NAME,
1658        );
1659        let conversion = to_grpc_insert_requests(request, &mut descriptor_ctx()).unwrap();
1660        assert!(conversion.resource_info.is_none());
1661        // the metric itself still goes through the main path
1662        assert!(
1663            conversion
1664                .requests
1665                .inserts
1666                .iter()
1667                .any(|r| r.table_name == OTEL_RESOURCE_INFO_TABLE_NAME)
1668        );
1669    }
1670
1671    #[test]
1672    fn test_job_composition_follows_service_namespace() {
1673        let mut attrs = vec![
1674            keyvalue("service.name", "api"),
1675            keyvalue("service.namespace", "shop"),
1676        ];
1677        process_resource_attrs(&mut attrs, &OtlpMetricCtx::default());
1678        assert_eq!(attr_value(&attrs, "job").as_deref(), Some("shop/api"));
1679
1680        let mut attrs = vec![keyvalue("service.name", "api")];
1681        process_resource_attrs(&mut attrs, &OtlpMetricCtx::default());
1682        assert_eq!(attr_value(&attrs, "job").as_deref(), Some("api"));
1683
1684        let mut attrs = vec![
1685            keyvalue("service.name", "api"),
1686            keyvalue("service.namespace", ""),
1687        ];
1688        process_resource_attrs(&mut attrs, &OtlpMetricCtx::default());
1689        assert_eq!(attr_value(&attrs, "job").as_deref(), Some("api"));
1690
1691        // no service.name: no job is fabricated
1692        let mut attrs = vec![
1693            keyvalue("service.namespace", "shop"),
1694            keyvalue("service.instance.id", "inst-1"),
1695        ];
1696        process_resource_attrs(&mut attrs, &OtlpMetricCtx::default());
1697        assert_eq!(attr_value(&attrs, "job"), None);
1698        assert_eq!(attr_value(&attrs, "instance").as_deref(), Some("inst-1"));
1699    }
1700
1701    #[test]
1702    fn test_encode_gauge() {
1703        let mut tables = MultiTableData::default();
1704
1705        let data_points = vec![
1706            NumberDataPoint {
1707                attributes: vec![keyvalue("host", "testsevrer")],
1708                time_unix_nano: 100,
1709                value: Some(Value::AsInt(100)),
1710                ..Default::default()
1711            },
1712            NumberDataPoint {
1713                attributes: vec![keyvalue("host", "testserver")],
1714                time_unix_nano: 105,
1715                value: Some(Value::AsInt(105)),
1716                ..Default::default()
1717            },
1718        ];
1719        let gauge = Gauge { data_points };
1720        encode_gauge(
1721            &mut tables,
1722            "datamon",
1723            &gauge,
1724            Some(&vec![]),
1725            Some(&vec![keyvalue("scope", "otel")]),
1726            &OtlpMetricCtx::default(),
1727        )
1728        .unwrap();
1729
1730        let table = tables.get_or_default_table_data("datamon", 0, 0);
1731        assert_eq!(table.num_rows(), 2);
1732        assert_eq!(table.num_columns(), 4);
1733        assert_eq!(
1734            table
1735                .columns()
1736                .iter()
1737                .map(|c| &c.column_name)
1738                .collect::<Vec<&String>>(),
1739            vec![
1740                "otel_scope_scope",
1741                "host",
1742                greptime_timestamp(),
1743                greptime_value()
1744            ]
1745        );
1746    }
1747
1748    #[test]
1749    fn test_encode_sum() {
1750        let mut tables = MultiTableData::default();
1751
1752        let data_points = vec![
1753            NumberDataPoint {
1754                attributes: vec![keyvalue("host", "testserver")],
1755                time_unix_nano: 100,
1756                value: Some(Value::AsInt(100)),
1757                ..Default::default()
1758            },
1759            NumberDataPoint {
1760                attributes: vec![keyvalue("host", "testserver")],
1761                time_unix_nano: 105,
1762                value: Some(Value::AsInt(0)),
1763                ..Default::default()
1764            },
1765        ];
1766        let sum = Sum {
1767            data_points,
1768            ..Default::default()
1769        };
1770        encode_sum(
1771            &mut tables,
1772            "datamon",
1773            &sum,
1774            Some(&vec![]),
1775            Some(&vec![keyvalue("scope", "otel")]),
1776            &OtlpMetricCtx::default(),
1777        )
1778        .unwrap();
1779
1780        let table = tables.get_or_default_table_data("datamon", 0, 0);
1781        assert_eq!(table.num_rows(), 2);
1782        assert_eq!(table.num_columns(), 4);
1783        assert_eq!(
1784            table
1785                .columns()
1786                .iter()
1787                .map(|c| &c.column_name)
1788                .collect::<Vec<&String>>(),
1789            vec![
1790                "otel_scope_scope",
1791                "host",
1792                greptime_timestamp(),
1793                greptime_value()
1794            ]
1795        );
1796    }
1797
1798    #[test]
1799    fn test_encode_summary() {
1800        let mut tables = MultiTableData::default();
1801
1802        let data_points = vec![SummaryDataPoint {
1803            attributes: vec![keyvalue("host", "testserver")],
1804            time_unix_nano: 100,
1805            count: 25,
1806            sum: 5400.0,
1807            quantile_values: vec![
1808                ValueAtQuantile {
1809                    quantile: 0.90,
1810                    value: 1000.0,
1811                },
1812                ValueAtQuantile {
1813                    quantile: 0.95,
1814                    value: 3030.0,
1815                },
1816            ],
1817            ..Default::default()
1818        }];
1819        let summary = Summary { data_points };
1820        encode_summary(
1821            &mut tables,
1822            "datamon",
1823            &summary,
1824            Some(&vec![]),
1825            Some(&vec![keyvalue("scope", "otel")]),
1826            &OtlpMetricCtx::default(),
1827        )
1828        .unwrap();
1829
1830        let table = tables.get_or_default_table_data("datamon", 0, 0);
1831        assert_eq!(table.num_rows(), 2);
1832        assert_eq!(table.num_columns(), 5);
1833        assert_eq!(
1834            table
1835                .columns()
1836                .iter()
1837                .map(|c| &c.column_name)
1838                .collect::<Vec<&String>>(),
1839            vec![
1840                "otel_scope_scope",
1841                "host",
1842                greptime_timestamp(),
1843                "quantile",
1844                greptime_value()
1845            ]
1846        );
1847
1848        let table = tables.get_or_default_table_data("datamon_count", 0, 0);
1849        assert_eq!(table.num_rows(), 1);
1850        assert_eq!(table.num_columns(), 4);
1851        assert_eq!(
1852            table
1853                .columns()
1854                .iter()
1855                .map(|c| &c.column_name)
1856                .collect::<Vec<&String>>(),
1857            vec![
1858                "otel_scope_scope",
1859                "host",
1860                greptime_timestamp(),
1861                greptime_value()
1862            ]
1863        );
1864
1865        let table = tables.get_or_default_table_data("datamon_sum", 0, 0);
1866        assert_eq!(table.num_rows(), 1);
1867        assert_eq!(table.num_columns(), 4);
1868        assert_eq!(
1869            table
1870                .columns()
1871                .iter()
1872                .map(|c| &c.column_name)
1873                .collect::<Vec<&String>>(),
1874            vec![
1875                "otel_scope_scope",
1876                "host",
1877                greptime_timestamp(),
1878                greptime_value()
1879            ]
1880        );
1881    }
1882
1883    #[test]
1884    fn test_encode_legacy_summary_keeps_legacy_column_names() {
1885        set_default_prefix(Some("custom")).unwrap();
1886        let mut tables = MultiTableData::default();
1887        let summary = Summary {
1888            data_points: vec![SummaryDataPoint {
1889                attributes: vec![keyvalue("host", "testserver")],
1890                time_unix_nano: 100,
1891                count: 25,
1892                quantile_values: vec![ValueAtQuantile {
1893                    quantile: 0.90,
1894                    value: 1000.0,
1895                }],
1896                ..Default::default()
1897            }],
1898        };
1899
1900        encode_summary(
1901            &mut tables,
1902            "datamon",
1903            &summary,
1904            None,
1905            None,
1906            &OtlpMetricCtx {
1907                is_legacy: true,
1908                ..Default::default()
1909            },
1910        )
1911        .unwrap();
1912
1913        let table = tables.get_or_default_table_data("datamon", 0, 0);
1914        assert_eq!(
1915            table
1916                .columns()
1917                .iter()
1918                .map(|column| column.column_name.as_str())
1919                .collect::<Vec<_>>(),
1920            vec!["host", "custom_timestamp", "greptime_p90", GREPTIME_COUNT,]
1921        );
1922    }
1923
1924    #[test]
1925    fn test_encode_histogram() {
1926        let mut tables = MultiTableData::default();
1927        let mut outcome = MetricsIngestOutcome::default();
1928
1929        let data_points = vec![HistogramDataPoint {
1930            attributes: vec![keyvalue("host", "testserver")],
1931            time_unix_nano: 100,
1932            start_time_unix_nano: 23,
1933            count: 25,
1934            sum: Some(100.),
1935            max: Some(200.),
1936            min: Some(0.03),
1937            bucket_counts: vec![2, 4, 6, 9, 4],
1938            explicit_bounds: vec![0.1, 1., 10., 100.],
1939            ..Default::default()
1940        }];
1941
1942        let histogram = Histogram {
1943            data_points,
1944            aggregation_temporality: AggregationTemporality::Delta.into(),
1945        };
1946        encode_histogram(
1947            &mut tables,
1948            "histo",
1949            &histogram,
1950            Some(&vec![]),
1951            Some(&vec![keyvalue("scope", "otel")]),
1952            &OtlpMetricCtx::default(),
1953            &mut outcome,
1954        )
1955        .unwrap();
1956
1957        assert_eq!(3, tables.num_tables());
1958        assert_eq!(1, outcome.accepted_data_points);
1959
1960        // bucket table
1961        let bucket_table = tables.get_or_default_table_data("histo_bucket", 0, 0);
1962        assert_eq!(bucket_table.num_rows(), 5);
1963        assert_eq!(bucket_table.num_columns(), 6);
1964        assert_eq!(
1965            bucket_table
1966                .columns()
1967                .iter()
1968                .map(|c| &c.column_name)
1969                .collect::<Vec<&String>>(),
1970            vec![
1971                "otel_scope_scope",
1972                "host",
1973                greptime_timestamp(),
1974                OTLP_AGGREGATION_TEMPORALITY_LABEL,
1975                "le",
1976                greptime_value(),
1977            ]
1978        );
1979
1980        let sum_table = tables.get_or_default_table_data("histo_sum", 0, 0);
1981        assert_eq!(sum_table.num_rows(), 1);
1982        assert_eq!(sum_table.num_columns(), 5);
1983        assert_eq!(
1984            sum_table
1985                .columns()
1986                .iter()
1987                .map(|c| &c.column_name)
1988                .collect::<Vec<&String>>(),
1989            vec![
1990                "otel_scope_scope",
1991                "host",
1992                greptime_timestamp(),
1993                OTLP_AGGREGATION_TEMPORALITY_LABEL,
1994                greptime_value()
1995            ]
1996        );
1997
1998        let count_table = tables.get_or_default_table_data("histo_count", 0, 0);
1999        assert_eq!(count_table.num_rows(), 1);
2000        assert_eq!(count_table.num_columns(), 5);
2001        assert_eq!(
2002            count_table
2003                .columns()
2004                .iter()
2005                .map(|c| &c.column_name)
2006                .collect::<Vec<&String>>(),
2007            vec![
2008                "otel_scope_scope",
2009                "host",
2010                greptime_timestamp(),
2011                OTLP_AGGREGATION_TEMPORALITY_LABEL,
2012                greptime_value()
2013            ]
2014        );
2015    }
2016
2017    use std::collections::BTreeMap;
2018
2019    use table::requests::validate_semantic_option;
2020
2021    fn decode(index: &SemanticIndex) -> BTreeMap<String, BTreeMap<String, String>> {
2022        let nested: BTreeMap<String, BTreeMap<String, BTreeMap<String, String>>> =
2023            serde_json::from_str(&index.encode("public").expect("non-empty index")).unwrap();
2024        nested.into_values().next().unwrap()
2025    }
2026
2027    fn record(metric: &Metric, metric_type: MetricType, name: &str) -> SemanticIndex {
2028        let ctx = OtlpMetricCtx {
2029            metric_type,
2030            ..Default::default()
2031        };
2032        let mut index = SemanticIndex::default();
2033        record_metric_semantics(&mut index, metric, name, &ctx);
2034        index
2035    }
2036
2037    #[test]
2038    fn test_metric_type_constants_validate() {
2039        for value in [
2040            METRIC_TYPE_COUNTER,
2041            METRIC_TYPE_UPDOWN_COUNTER,
2042            METRIC_TYPE_GAUGE,
2043            METRIC_TYPE_HISTOGRAM,
2044            METRIC_TYPE_SUMMARY,
2045        ] {
2046            assert!(
2047                validate_semantic_option(SEMANTIC_METRIC_TYPE, value),
2048                "metric.type value `{value}` must be in the vocabulary domain"
2049            );
2050        }
2051        for value in ["delta", "cumulative"] {
2052            assert!(validate_semantic_option(SEMANTIC_METRIC_TEMPORALITY, value));
2053        }
2054    }
2055
2056    #[test]
2057    fn test_record_monotonic_sum() {
2058        let metric = Metric {
2059            name: "claude_code.cost.usage".to_string(),
2060            unit: "USD".to_string(),
2061            data: Some(metric::Data::Sum(Sum {
2062                aggregation_temporality: AggregationTemporality::Delta as i32,
2063                is_monotonic: true,
2064                ..Default::default()
2065            })),
2066            ..Default::default()
2067        };
2068        let index = record(
2069            &metric,
2070            MetricType::MonotonicSum,
2071            "claude_code_cost_usage_USD_total",
2072        );
2073        let decoded = decode(&index);
2074        let t = &decoded["claude_code_cost_usage_USD_total"];
2075
2076        assert_eq!(
2077            t.get(SEMANTIC_METRIC_TYPE).map(String::as_str),
2078            Some("counter")
2079        );
2080        assert_eq!(
2081            t.get(SEMANTIC_METRIC_TEMPORALITY).map(String::as_str),
2082            Some("delta")
2083        );
2084        assert_eq!(t.get(SEMANTIC_METRIC_UNIT).map(String::as_str), Some("USD"));
2085        assert_eq!(
2086            t.get(SEMANTIC_METRIC_ORIGINAL_NAME).map(String::as_str),
2087            Some("claude_code.cost.usage")
2088        );
2089        assert_eq!(
2090            t.get(SEMANTIC_METRIC_METADATA_QUALITY).map(String::as_str),
2091            Some("declared")
2092        );
2093    }
2094
2095    #[test]
2096    fn test_record_non_monotonic_sum() {
2097        let metric = Metric {
2098            name: "queue_size".to_string(),
2099            data: Some(metric::Data::Sum(Sum {
2100                aggregation_temporality: AggregationTemporality::Cumulative as i32,
2101                is_monotonic: false,
2102                ..Default::default()
2103            })),
2104            ..Default::default()
2105        };
2106        let index = record(&metric, MetricType::NonMonotonicSum, "queue_size");
2107        let decoded = decode(&index);
2108        let t = &decoded["queue_size"];
2109        assert_eq!(
2110            t.get(SEMANTIC_METRIC_TYPE).map(String::as_str),
2111            Some("updown_counter")
2112        );
2113        assert_eq!(
2114            t.get(SEMANTIC_METRIC_TEMPORALITY).map(String::as_str),
2115            Some("cumulative")
2116        );
2117        // Name unchanged by translation -> no original_name.
2118        assert_eq!(t.get(SEMANTIC_METRIC_ORIGINAL_NAME), None);
2119    }
2120
2121    #[test]
2122    fn test_record_gauge_has_no_temporality() {
2123        let metric = Metric {
2124            name: "temperature".to_string(),
2125            data: Some(metric::Data::Gauge(Gauge::default())),
2126            ..Default::default()
2127        };
2128        let index = record(&metric, MetricType::Gauge, "temperature");
2129        let decoded = decode(&index);
2130        let t = &decoded["temperature"];
2131        assert_eq!(
2132            t.get(SEMANTIC_METRIC_TYPE).map(String::as_str),
2133            Some("gauge")
2134        );
2135        assert_eq!(t.get(SEMANTIC_METRIC_TEMPORALITY), None);
2136    }
2137
2138    #[test]
2139    fn test_record_histogram_fans_out_with_distinct_types() {
2140        let metric = Metric {
2141            name: "request.duration".to_string(),
2142            unit: "s".to_string(),
2143            data: Some(metric::Data::Histogram(Histogram {
2144                aggregation_temporality: AggregationTemporality::Cumulative as i32,
2145                ..Default::default()
2146            })),
2147            ..Default::default()
2148        };
2149        let index = record(&metric, MetricType::Histogram, "request_duration");
2150        let decoded = decode(&index);
2151
2152        let bucket = &decoded["request_duration_bucket"];
2153        assert_eq!(
2154            bucket.get(SEMANTIC_METRIC_TYPE).map(String::as_str),
2155            Some("histogram")
2156        );
2157        assert_eq!(
2158            bucket.get(SEMANTIC_METRIC_UNIT).map(String::as_str),
2159            Some("s")
2160        );
2161
2162        for companion in ["request_duration_sum", "request_duration_count"] {
2163            let t = &decoded[companion];
2164            assert_eq!(
2165                t.get(SEMANTIC_METRIC_TYPE).map(String::as_str),
2166                Some("counter")
2167            );
2168            assert_eq!(
2169                t.get(SEMANTIC_METRIC_TEMPORALITY).map(String::as_str),
2170                Some("cumulative")
2171            );
2172        }
2173    }
2174
2175    #[test]
2176    fn test_record_summary_fans_out() {
2177        let metric = Metric {
2178            name: "rpc.latency".to_string(),
2179            data: Some(metric::Data::Summary(Summary::default())),
2180            ..Default::default()
2181        };
2182        let index = record(&metric, MetricType::Summary, "rpc_latency");
2183        let decoded = decode(&index);
2184
2185        assert_eq!(
2186            decoded["rpc_latency"]
2187                .get(SEMANTIC_METRIC_TYPE)
2188                .map(String::as_str),
2189            Some("summary")
2190        );
2191        // Summary has no temporality.
2192        assert_eq!(
2193            decoded["rpc_latency"].get(SEMANTIC_METRIC_TEMPORALITY),
2194            None
2195        );
2196        for companion in ["rpc_latency_count", "rpc_latency_sum"] {
2197            assert_eq!(
2198                decoded[companion]
2199                    .get(SEMANTIC_METRIC_TYPE)
2200                    .map(String::as_str),
2201                Some("counter")
2202            );
2203        }
2204    }
2205
2206    fn exponential_buckets(
2207        offset: i32,
2208        bucket_counts: Vec<u64>,
2209    ) -> exponential_histogram_data_point::Buckets {
2210        exponential_histogram_data_point::Buckets {
2211            offset,
2212            bucket_counts,
2213        }
2214    }
2215
2216    fn exponential_point() -> ExponentialHistogramDataPoint {
2217        ExponentialHistogramDataPoint {
2218            start_time_unix_nano: 1_000_000,
2219            time_unix_nano: 2_000_000,
2220            count: 28,
2221            sum: None,
2222            scale: 9,
2223            zero_count: 7,
2224            positive: Some(exponential_buckets(-3, vec![1, 2, 3, 4, 5, 6])),
2225            zero_threshold: 0.0,
2226            ..Default::default()
2227        }
2228    }
2229
2230    fn native_field(value: &ValueData, name: &str) -> Option<ValueData> {
2231        let ValueData::StructValue(value) = value else {
2232            panic!("expected native histogram Struct value");
2233        };
2234        let index = common_query::native_histogram::NATIVE_HISTOGRAM_FIELD_NAMES
2235            .iter()
2236            .position(|field| *field == name)
2237            .unwrap();
2238        value.items[index].value_data.clone()
2239    }
2240
2241    fn i32_list(value: Option<ValueData>) -> Vec<i32> {
2242        let Some(ValueData::ListValue(value)) = value else {
2243            panic!("expected i32 list");
2244        };
2245        value
2246            .items
2247            .into_iter()
2248            .map(|item| match item.value_data {
2249                Some(ValueData::I32Value(value)) => value,
2250                _ => panic!("expected i32 value"),
2251            })
2252            .collect()
2253    }
2254
2255    fn i64_list(value: Option<ValueData>) -> Vec<i64> {
2256        let Some(ValueData::ListValue(value)) = value else {
2257            panic!("expected i64 list");
2258        };
2259        value
2260            .items
2261            .into_iter()
2262            .map(|item| match item.value_data {
2263                Some(ValueData::I64Value(value)) => value,
2264                _ => panic!("expected i64 value"),
2265            })
2266            .collect()
2267    }
2268
2269    #[test]
2270    fn test_downscale_bucket_index_uses_signed_floor_division() {
2271        assert_eq!(downscale_bucket_index(-3, 1).unwrap(), -2);
2272        assert_eq!(downscale_bucket_index(-2, 1).unwrap(), -1);
2273        assert_eq!(downscale_bucket_index(-1, 1).unwrap(), -1);
2274        assert_eq!(downscale_bucket_index(0, 1).unwrap(), 0);
2275        assert_eq!(downscale_bucket_index(1, 1).unwrap(), 0);
2276        assert_eq!(downscale_bucket_index(2, 1).unwrap(), 1);
2277        assert_eq!(downscale_bucket_index(i32::MIN, 32).unwrap(), -1);
2278        assert_eq!(downscale_bucket_index(i32::MAX, 32).unwrap(), 0);
2279    }
2280
2281    #[test]
2282    fn test_convert_bucket_range_downscales_before_prometheus_shift() {
2283        let buckets = exponential_buckets(-3, vec![1, 2, 3, 4, 5, 6]);
2284        let (spans, deltas, total) = convert_bucket_range("positive", Some(&buckets), 1).unwrap();
2285
2286        assert_eq!(
2287            spans,
2288            vec![BucketSpan {
2289                offset: -1,
2290                length: 4
2291            }]
2292        );
2293        assert_eq!(deltas, vec![1, 4, 4, -3]);
2294        assert_eq!(total, 21);
2295    }
2296
2297    #[test]
2298    fn test_exponential_histogram_value_uses_integer_family() {
2299        use common_query::native_histogram::{
2300            COUNT_F64_FIELD, COUNT_I64_FIELD, POSITIVE_BUCKETS_I64_FIELD,
2301            POSITIVE_SPAN_LENGTHS_FIELD, POSITIVE_SPAN_OFFSETS_FIELD, SCHEMA_FIELD, SUM_FIELD,
2302            ZERO_COUNT_I64_FIELD,
2303        };
2304
2305        let (value, timestamp_nanos) = exponential_histogram_value(&exponential_point()).unwrap();
2306
2307        assert_eq!(timestamp_nanos, 2_000_000);
2308        assert_eq!(
2309            native_field(&value, SCHEMA_FIELD),
2310            Some(ValueData::I32Value(8))
2311        );
2312        assert_eq!(
2313            native_field(&value, COUNT_I64_FIELD),
2314            Some(ValueData::I64Value(28))
2315        );
2316        assert_eq!(
2317            native_field(&value, ZERO_COUNT_I64_FIELD),
2318            Some(ValueData::I64Value(7))
2319        );
2320        assert_eq!(native_field(&value, COUNT_F64_FIELD), None);
2321        assert_eq!(
2322            i32_list(native_field(&value, POSITIVE_SPAN_OFFSETS_FIELD)),
2323            vec![-1]
2324        );
2325        assert_eq!(
2326            i32_list(native_field(&value, POSITIVE_SPAN_LENGTHS_FIELD)),
2327            vec![4]
2328        );
2329        assert_eq!(
2330            i64_list(native_field(&value, POSITIVE_BUCKETS_I64_FIELD)),
2331            vec![1, 5, 9, 6]
2332        );
2333        let Some(ValueData::F64Value(sum)) = native_field(&value, SUM_FIELD) else {
2334            panic!("expected histogram sum");
2335        };
2336        assert_eq!(sum.to_bits(), f64::NAN.to_bits());
2337        assert_ne!(sum.to_bits(), PROMETHEUS_STALE_NAN_BITS);
2338    }
2339
2340    #[test]
2341    fn test_no_recorded_value_ignores_other_value_fields() {
2342        use common_query::native_histogram::{
2343            COUNT_I64_FIELD, POSITIVE_BUCKETS_I64_FIELD, SCHEMA_FIELD, SUM_FIELD,
2344            ZERO_COUNT_I64_FIELD, ZERO_THRESHOLD_FIELD,
2345        };
2346
2347        let point = ExponentialHistogramDataPoint {
2348            start_time_unix_nano: 1_000_000,
2349            time_unix_nano: 2_000_000,
2350            count: u64::MAX,
2351            sum: Some(1.0),
2352            scale: i32::MIN,
2353            zero_count: u64::MAX,
2354            positive: Some(exponential_buckets(i32::MAX, vec![u64::MAX])),
2355            flags: DataPointFlags::NoRecordedValueMask as u32,
2356            zero_threshold: f64::NAN,
2357            ..Default::default()
2358        };
2359        let (value, _) = exponential_histogram_value(&point).unwrap();
2360
2361        assert_eq!(
2362            native_field(&value, SCHEMA_FIELD),
2363            Some(ValueData::I32Value(0))
2364        );
2365        assert_eq!(
2366            native_field(&value, ZERO_THRESHOLD_FIELD),
2367            Some(ValueData::F64Value(0.0))
2368        );
2369        assert_eq!(
2370            native_field(&value, COUNT_I64_FIELD),
2371            Some(ValueData::I64Value(0))
2372        );
2373        assert_eq!(
2374            native_field(&value, ZERO_COUNT_I64_FIELD),
2375            Some(ValueData::I64Value(0))
2376        );
2377        assert!(i64_list(native_field(&value, POSITIVE_BUCKETS_I64_FIELD)).is_empty());
2378        let Some(ValueData::F64Value(sum)) = native_field(&value, SUM_FIELD) else {
2379            panic!("expected histogram sum");
2380        };
2381        assert_eq!(sum.to_bits(), PROMETHEUS_STALE_NAN_BITS);
2382    }
2383
2384    #[test]
2385    fn test_non_flag_nan_sum_is_normalized() {
2386        use common_query::native_histogram::SUM_FIELD;
2387
2388        let point = ExponentialHistogramDataPoint {
2389            sum: Some(f64::from_bits(PROMETHEUS_STALE_NAN_BITS)),
2390            ..Default::default()
2391        };
2392        let (value, _) = exponential_histogram_value(&point).unwrap();
2393        let Some(ValueData::F64Value(sum)) = native_field(&value, SUM_FIELD) else {
2394            panic!("expected histogram sum");
2395        };
2396        assert_eq!(sum.to_bits(), f64::NAN.to_bits());
2397        assert_ne!(sum.to_bits(), PROMETHEUS_STALE_NAN_BITS);
2398    }
2399
2400    #[test]
2401    fn test_exponential_histogram_rejects_invalid_values() {
2402        let mut cases = Vec::new();
2403
2404        let mut point = exponential_point();
2405        point.scale = -5;
2406        cases.push((point, "scale -5 is unsupported"));
2407
2408        let mut point = exponential_point();
2409        point.zero_threshold = f64::INFINITY;
2410        cases.push((point, "must be finite and non-negative"));
2411
2412        let mut point = exponential_point();
2413        point.start_time_unix_nano = point.time_unix_nano + 1;
2414        cases.push((point, "start_time_unix_nano"));
2415
2416        let mut point = exponential_point();
2417        point.count = 27;
2418        cases.push((point, "declared count is 27"));
2419
2420        let point = ExponentialHistogramDataPoint {
2421            count: u64::MAX,
2422            zero_count: u64::MAX,
2423            ..Default::default()
2424        };
2425        cases.push((point, "count 18446744073709551615 overflows i64"));
2426
2427        let point = ExponentialHistogramDataPoint {
2428            count: 1,
2429            scale: 8,
2430            positive: Some(exponential_buckets(i32::MAX, vec![1])),
2431            ..Default::default()
2432        };
2433        cases.push((point, "shifted bucket index overflows i32"));
2434
2435        for (point, expected) in cases {
2436            let error = exponential_histogram_value(&point).unwrap_err();
2437            assert!(
2438                error.contains(expected),
2439                "expected {expected:?}, got {error}"
2440            );
2441        }
2442
2443        let buckets = exponential_buckets(0, vec![u64::MAX, 1]);
2444        let error = convert_bucket_range("positive", Some(&buckets), 1).unwrap_err();
2445        assert!(
2446            error.contains("merged bucket count overflows u64"),
2447            "{error}"
2448        );
2449
2450        let buckets = exponential_buckets(i32::MAX, vec![1, 1]);
2451        let error = convert_bucket_range("positive", Some(&buckets), 1).unwrap_err();
2452        assert!(error.contains("bucket index overflows i32"), "{error}");
2453    }
2454
2455    fn metrics_request(metrics: Vec<Metric>) -> ExportMetricsServiceRequest {
2456        ExportMetricsServiceRequest {
2457            resource_metrics: vec![ResourceMetrics {
2458                scope_metrics: vec![ScopeMetrics {
2459                    metrics,
2460                    ..Default::default()
2461                }],
2462                ..Default::default()
2463            }],
2464        }
2465    }
2466
2467    fn exponential_metric(
2468        name: impl Into<String>,
2469        data_points: Vec<ExponentialHistogramDataPoint>,
2470        temporality: AggregationTemporality,
2471    ) -> Metric {
2472        Metric {
2473            name: name.into(),
2474            data: Some(metric::Data::ExponentialHistogram(ExponentialHistogram {
2475                data_points,
2476                aggregation_temporality: temporality as i32,
2477            })),
2478            ..Default::default()
2479        }
2480    }
2481
2482    fn histogram_metric(name: impl Into<String>) -> Metric {
2483        Metric {
2484            name: name.into(),
2485            data: Some(metric::Data::Histogram(Histogram {
2486                data_points: vec![HistogramDataPoint {
2487                    start_time_unix_nano: 1_000_000,
2488                    time_unix_nano: 2_000_000,
2489                    count: 1,
2490                    sum: Some(1.0),
2491                    bucket_counts: vec![1],
2492                    ..Default::default()
2493                }],
2494                aggregation_temporality: AggregationTemporality::Cumulative as i32,
2495            })),
2496            ..Default::default()
2497        }
2498    }
2499
2500    #[test]
2501    fn test_exponential_histogram_gate_and_partial_outcome() {
2502        let request = metrics_request(vec![
2503            Metric {
2504                name: "temperature".to_string(),
2505                data: Some(metric::Data::Gauge(Gauge {
2506                    data_points: vec![NumberDataPoint::default()],
2507                })),
2508                ..Default::default()
2509            },
2510            exponential_metric(
2511                "latency",
2512                vec![exponential_point()],
2513                AggregationTemporality::Cumulative,
2514            ),
2515        ]);
2516        let MetricsConversion {
2517            requests,
2518            semantic_index,
2519            outcome,
2520            ..
2521        } = to_grpc_insert_requests(request, &mut OtlpMetricCtx::default()).unwrap();
2522
2523        assert_eq!(outcome.accepted_data_points, 1);
2524        assert_eq!(outcome.rejected_data_points, 1);
2525        assert!(
2526            outcome
2527                .error_message
2528                .as_deref()
2529                .unwrap()
2530                .contains("otlp.experimental_enable_exponential_histogram")
2531        );
2532        assert_eq!(requests.inserts.len(), 1);
2533        assert_eq!(requests.inserts[0].table_name, "temperature");
2534        let semantics = decode(&semantic_index);
2535        assert!(semantics.contains_key("temperature"));
2536        assert!(!semantics.contains_key("latency"));
2537
2538        let empty = metrics_request(vec![exponential_metric(
2539            "empty",
2540            vec![],
2541            AggregationTemporality::Cumulative,
2542        )]);
2543        let outcome = to_grpc_insert_requests(empty, &mut OtlpMetricCtx::default())
2544            .unwrap()
2545            .outcome;
2546        assert_eq!(outcome.rejected_data_points, 0);
2547        assert_eq!(outcome.error_message, None);
2548    }
2549
2550    #[test]
2551    fn test_exponential_histogram_cannot_share_table_with_scalar_metric() {
2552        let request = metrics_request(vec![
2553            Metric {
2554                name: "latency".to_string(),
2555                data: Some(metric::Data::Gauge(Gauge {
2556                    data_points: vec![NumberDataPoint {
2557                        value: Some(number_data_point::Value::AsDouble(1.0)),
2558                        ..Default::default()
2559                    }],
2560                })),
2561                ..Default::default()
2562            },
2563            exponential_metric(
2564                "latency",
2565                vec![exponential_point()],
2566                AggregationTemporality::Cumulative,
2567            ),
2568        ]);
2569        let mut ctx = OtlpMetricCtx {
2570            experimental_enable_exponential_histogram: true,
2571            ..Default::default()
2572        };
2573
2574        let error = to_grpc_insert_requests(request, &mut ctx).unwrap_err();
2575        assert!(
2576            error
2577                .to_string()
2578                .contains("cannot mix native histogram and float sample fields")
2579        );
2580    }
2581
2582    #[test]
2583    fn test_histogram_cannot_replace_exponential_histogram_table() {
2584        let request = metrics_request(vec![
2585            exponential_metric(
2586                "latency_bucket",
2587                vec![exponential_point()],
2588                AggregationTemporality::Cumulative,
2589            ),
2590            histogram_metric("latency"),
2591        ]);
2592        let mut ctx = OtlpMetricCtx {
2593            experimental_enable_exponential_histogram: true,
2594            ..Default::default()
2595        };
2596
2597        let error = to_grpc_insert_requests(request, &mut ctx).unwrap_err();
2598        assert!(
2599            error
2600                .to_string()
2601                .contains("cannot mix native histogram and float sample fields")
2602        );
2603    }
2604
2605    #[test]
2606    fn test_histograms_with_same_name_across_resources_are_merged() {
2607        let metric = histogram_metric("latency");
2608        let request = ExportMetricsServiceRequest {
2609            resource_metrics: ["service-a", "service-b"]
2610                .into_iter()
2611                .map(|service| ResourceMetrics {
2612                    resource: Some(Resource {
2613                        attributes: vec![keyvalue("service.name", service)],
2614                        ..Default::default()
2615                    }),
2616                    scope_metrics: vec![ScopeMetrics {
2617                        metrics: vec![metric.clone()],
2618                        ..Default::default()
2619                    }],
2620                    ..Default::default()
2621                })
2622                .collect(),
2623        };
2624
2625        let MetricsConversion {
2626            requests,
2627            rows,
2628            outcome,
2629            ..
2630        } = to_grpc_insert_requests(request, &mut OtlpMetricCtx::default()).unwrap();
2631
2632        assert_eq!(outcome.accepted_data_points, 2);
2633        assert_eq!(rows, 6);
2634        assert_eq!(requests.inserts.len(), 3);
2635        for request in requests.inserts {
2636            assert_eq!(
2637                request.rows.unwrap().rows.len(),
2638                2,
2639                "{}",
2640                request.table_name
2641            );
2642        }
2643    }
2644
2645    #[test]
2646    fn test_exponential_histogram_rejects_temporality_before_stale_point() {
2647        let stale = ExponentialHistogramDataPoint {
2648            flags: DataPointFlags::NoRecordedValueMask as u32,
2649            ..Default::default()
2650        };
2651        for temporality in [
2652            AggregationTemporality::Delta,
2653            AggregationTemporality::Unspecified,
2654        ] {
2655            let request = metrics_request(vec![exponential_metric(
2656                "latency",
2657                vec![stale.clone()],
2658                temporality,
2659            )]);
2660            let mut ctx = OtlpMetricCtx {
2661                experimental_enable_exponential_histogram: true,
2662                ..Default::default()
2663            };
2664            let MetricsConversion {
2665                requests,
2666                rows,
2667                semantic_index,
2668                outcome,
2669                ..
2670            } = to_grpc_insert_requests(request, &mut ctx).unwrap();
2671
2672            assert_eq!(outcome.accepted_data_points, 0);
2673            assert_eq!(outcome.rejected_data_points, 1);
2674            assert_eq!(rows, 0);
2675            assert!(requests.inserts.is_empty());
2676            assert!(semantic_index.is_empty());
2677        }
2678    }
2679
2680    #[test]
2681    fn test_exponential_histogram_legacy_and_new_modes_share_struct() {
2682        use common_query::prelude::greptime_native_histogram;
2683
2684        let mut point = exponential_point();
2685        point.sum = Some(42.0);
2686        let request = metrics_request(vec![exponential_metric(
2687            "request.duration",
2688            vec![point],
2689            AggregationTemporality::Cumulative,
2690        )]);
2691        let mut new_ctx = OtlpMetricCtx {
2692            experimental_enable_exponential_histogram: true,
2693            ..Default::default()
2694        };
2695        let new_requests = to_grpc_insert_requests(request.clone(), &mut new_ctx)
2696            .unwrap()
2697            .requests;
2698        let mut legacy_ctx = OtlpMetricCtx {
2699            experimental_enable_exponential_histogram: true,
2700            is_legacy: true,
2701            ..Default::default()
2702        };
2703        let legacy_requests = to_grpc_insert_requests(request, &mut legacy_ctx)
2704            .unwrap()
2705            .requests;
2706
2707        let new_insert = &new_requests.inserts[0];
2708        let legacy_insert = &legacy_requests.inserts[0];
2709        assert_eq!(new_insert.table_name, "request_duration");
2710        assert_eq!(legacy_insert.table_name, "request_duration");
2711        let new_rows = new_insert.rows.as_ref().unwrap();
2712        let legacy_rows = legacy_insert.rows.as_ref().unwrap();
2713        let field = greptime_native_histogram();
2714        let new_histogram = new_rows.rows[0].values[new_rows
2715            .schema
2716            .iter()
2717            .position(|column| column.column_name == field)
2718            .unwrap()]
2719        .clone();
2720        let legacy_histogram = legacy_rows.rows[0].values[legacy_rows
2721            .schema
2722            .iter()
2723            .position(|column| column.column_name == field)
2724            .unwrap()]
2725        .clone();
2726        assert_eq!(new_histogram, legacy_histogram);
2727        assert!(matches!(
2728            new_rows.rows[0].values[new_rows
2729                .schema
2730                .iter()
2731                .position(|column| column.column_name == greptime_timestamp())
2732                .unwrap()]
2733            .value_data,
2734            Some(ValueData::TimestampMillisecondValue(2))
2735        ));
2736        assert!(matches!(
2737            legacy_rows.rows[0].values[legacy_rows
2738                .schema
2739                .iter()
2740                .position(|column| column.column_name == greptime_timestamp())
2741                .unwrap()]
2742            .value_data,
2743            Some(ValueData::TimestampNanosecondValue(2_000_000))
2744        ));
2745    }
2746
2747    #[test]
2748    fn test_rejection_message_is_bounded() {
2749        let request = metrics_request(vec![exponential_metric(
2750            "x".repeat(1_000),
2751            vec![ExponentialHistogramDataPoint::default()],
2752            AggregationTemporality::Cumulative,
2753        )]);
2754        let outcome = to_grpc_insert_requests(request, &mut OtlpMetricCtx::default())
2755            .unwrap()
2756            .outcome;
2757
2758        assert_eq!(outcome.rejected_data_points, 1);
2759        assert!(outcome.error_message.unwrap().len() <= MAX_REJECTION_MESSAGE_BYTES);
2760    }
2761
2762    #[test]
2763    fn test_rejection_message_reason_is_lazy_after_cap() {
2764        let mut outcome = MetricsIngestOutcome {
2765            error_message: Some("x".repeat(MAX_REJECTION_MESSAGE_BYTES)),
2766            ..Default::default()
2767        };
2768        let mut reason_built = false;
2769
2770        reject_data_points(&mut outcome, 1, || {
2771            reason_built = true;
2772            "unused".to_string()
2773        })
2774        .unwrap();
2775
2776        assert_eq!(outcome.rejected_data_points, 1);
2777        assert!(!reason_built);
2778    }
2779}