Skip to main content

servers/otlp/
logs.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 std::collections::BTreeMap;
16
17use ahash::{HashMap, HashMapExt};
18use api::helper::ColumnDataTypeWrapper;
19use api::v1::column_data_type_extension::TypeExt;
20use api::v1::column_def::options_from_column_schema;
21use api::v1::value::ValueData;
22use api::v1::{
23    ColumnDataType, ColumnDataTypeExtension, ColumnOptions, ColumnSchema, JsonTypeExtension, Row,
24    RowInsertRequest, Rows, SemanticType, Value as GreptimeValue,
25};
26use bytes::Bytes;
27use common_telemetry::warn;
28use common_time::Timestamp;
29use common_time::timestamp::TimeUnit;
30use jsonb::{Number as JsonbNumber, Value as JsonbValue};
31use opentelemetry_proto::tonic::collector::logs::v1::ExportLogsServiceRequest;
32use opentelemetry_proto::tonic::common::v1::{AnyValue, InstrumentationScope, KeyValue, any_value};
33use opentelemetry_proto::tonic::logs::v1::{LogRecord, ResourceLogs, ScopeLogs};
34use pipeline::{
35    ContextReq, GreptimePipelineParams, PipelineContext, PipelineWay, SchemaInfo, SelectInfo,
36};
37use session::context::QueryContextRef;
38use snafu::ensure;
39use vrl::prelude::NotNan;
40use vrl::value::{KeyString, Value as VrlValue};
41
42use crate::error::{
43    Error, IncompatibleSchemaSnafu, InvalidParameterSnafu, NotSupportedSnafu, Result,
44    UnsupportedJsonDataTypeForTagSnafu,
45};
46use crate::http::event::PipelineIngestRequest;
47use crate::otlp::coerce::{coerce_value_data, is_supported_signed_to_unsigned_coercion};
48use crate::otlp::trace::attributes::OtlpAnyValue;
49use crate::otlp::utils::{bytes_to_hex_string, key_value_to_jsonb};
50use crate::pipeline::run_pipeline;
51use crate::query_handler::PipelineHandlerRef;
52
53pub const LOG_TABLE_NAME: &str = "opentelemetry_logs";
54
55/// Convert OpenTelemetry metrics to GreptimeDB insert requests
56///
57/// See
58/// <https://github.com/open-telemetry/opentelemetry-proto/blob/main/opentelemetry/proto/metrics/v1/metrics.proto>
59/// for data structure of OTLP metrics.
60///
61/// Returns `InsertRequests` and total number of rows to ingest
62pub async fn to_grpc_insert_requests(
63    request: ExportLogsServiceRequest,
64    pipeline: PipelineWay,
65    pipeline_params: GreptimePipelineParams,
66    table_name: String,
67    query_ctx: &QueryContextRef,
68    pipeline_handler: PipelineHandlerRef,
69) -> Result<ContextReq> {
70    match pipeline {
71        PipelineWay::OtlpLogDirect(select_info) => {
72            let table = pipeline_handler
73                .get_table(&table_name, query_ctx)
74                .await
75                .map_err(Error::from)?;
76            let existing_schema = table
77                .as_deref()
78                .map(ExistingLogSchema::try_from_table)
79                .transpose()?;
80            let rows = parse_export_logs_service_request_to_rows(
81                request,
82                select_info,
83                existing_schema.as_ref(),
84                &table_name,
85            )?;
86            let insert_request = RowInsertRequest {
87                rows: Some(rows),
88                table_name,
89            };
90
91            Ok(ContextReq::default_opt_with_reqs(vec![insert_request]))
92        }
93        PipelineWay::Pipeline(pipeline_def) => {
94            let array = parse_export_logs_service_request(request);
95
96            let pipeline_ctx =
97                PipelineContext::new(&pipeline_def, &pipeline_params, query_ctx.channel());
98            run_pipeline(
99                &pipeline_handler,
100                &pipeline_ctx,
101                PipelineIngestRequest {
102                    table: table_name,
103                    values: array,
104                },
105                query_ctx,
106                true,
107            )
108            .await
109        }
110        _ => NotSupportedSnafu {
111            feat: "Unsupported pipeline for logs",
112        }
113        .fail(),
114    }
115}
116
117fn scope_to_pipeline_value(scope: Option<InstrumentationScope>) -> (VrlValue, VrlValue, VrlValue) {
118    scope
119        .map(|x| {
120            (
121                VrlValue::Object(key_value_to_map(x.attributes)),
122                VrlValue::Bytes(x.version.into()),
123                VrlValue::Bytes(x.name.into()),
124            )
125        })
126        .unwrap_or((VrlValue::Null, VrlValue::Null, VrlValue::Null))
127}
128
129fn scope_to_jsonb(
130    scope: Option<InstrumentationScope>,
131) -> (JsonbValue<'static>, Option<String>, Option<String>) {
132    scope
133        .map(|x| {
134            (
135                key_value_to_jsonb(x.attributes),
136                Some(x.version),
137                Some(x.name),
138            )
139        })
140        .unwrap_or((JsonbValue::Null, None, None))
141}
142
143fn log_to_pipeline_value(
144    log: LogRecord,
145    resource_schema_url: VrlValue,
146    resource_attr: VrlValue,
147    scope_schema_url: VrlValue,
148    scope_name: VrlValue,
149    scope_version: VrlValue,
150    scope_attrs: VrlValue,
151) -> VrlValue {
152    let log_attrs = VrlValue::Object(key_value_to_map(log.attributes));
153    let mut map = BTreeMap::new();
154    map.insert(
155        "Timestamp".into(),
156        VrlValue::Integer(log.time_unix_nano as i64),
157    );
158    map.insert(
159        "ObservedTimestamp".into(),
160        VrlValue::Integer(log.observed_time_unix_nano as i64),
161    );
162
163    // need to be convert to string
164    map.insert(
165        "TraceId".into(),
166        VrlValue::Bytes(bytes_to_hex_string(&log.trace_id).into()),
167    );
168    map.insert(
169        "SpanId".into(),
170        VrlValue::Bytes(bytes_to_hex_string(&log.span_id).into()),
171    );
172    map.insert("TraceFlags".into(), VrlValue::Integer(log.flags as i64));
173    map.insert(
174        "SeverityText".into(),
175        VrlValue::Bytes(log.severity_text.into()),
176    );
177    map.insert(
178        "SeverityNumber".into(),
179        VrlValue::Integer(log.severity_number as i64),
180    );
181    // need to be convert to string
182    map.insert(
183        "Body".into(),
184        log.body
185            .as_ref()
186            .map(|x| VrlValue::Bytes(log_body_to_string(x).into()))
187            .unwrap_or(VrlValue::Null),
188    );
189    map.insert("ResourceSchemaUrl".into(), resource_schema_url);
190
191    map.insert("ResourceAttributes".into(), resource_attr);
192    map.insert("ScopeSchemaUrl".into(), scope_schema_url);
193    map.insert("ScopeName".into(), scope_name);
194    map.insert("ScopeVersion".into(), scope_version);
195    map.insert("ScopeAttributes".into(), scope_attrs);
196    map.insert("LogAttributes".into(), log_attrs);
197    VrlValue::Object(map)
198}
199
200fn build_otlp_logs_identity_schema() -> Vec<ColumnSchema> {
201    [
202        (
203            "timestamp",
204            ColumnDataType::TimestampNanosecond,
205            SemanticType::Timestamp,
206            None,
207            None,
208        ),
209        (
210            "trace_id",
211            ColumnDataType::String,
212            SemanticType::Field,
213            None,
214            None,
215        ),
216        (
217            "span_id",
218            ColumnDataType::String,
219            SemanticType::Field,
220            None,
221            None,
222        ),
223        (
224            "severity_text",
225            ColumnDataType::String,
226            SemanticType::Field,
227            None,
228            None,
229        ),
230        (
231            "severity_number",
232            ColumnDataType::Int32,
233            SemanticType::Field,
234            None,
235            None,
236        ),
237        (
238            "body",
239            ColumnDataType::String,
240            SemanticType::Field,
241            None,
242            Some(ColumnOptions {
243                options: std::collections::HashMap::from([(
244                    "fulltext".to_string(),
245                    r#"{"enable":true}"#.to_string(),
246                )]),
247            }),
248        ),
249        (
250            "log_attributes",
251            ColumnDataType::Binary,
252            SemanticType::Field,
253            Some(ColumnDataTypeExtension {
254                type_ext: Some(TypeExt::JsonType(JsonTypeExtension::JsonBinary.into())),
255            }),
256            None,
257        ),
258        (
259            "trace_flags",
260            ColumnDataType::Int32,
261            SemanticType::Field,
262            None,
263            None,
264        ),
265        (
266            "scope_name",
267            ColumnDataType::String,
268            SemanticType::Tag,
269            None,
270            None,
271        ),
272        (
273            "scope_version",
274            ColumnDataType::String,
275            SemanticType::Field,
276            None,
277            None,
278        ),
279        (
280            "scope_attributes",
281            ColumnDataType::Binary,
282            SemanticType::Field,
283            Some(ColumnDataTypeExtension {
284                type_ext: Some(TypeExt::JsonType(JsonTypeExtension::JsonBinary.into())),
285            }),
286            None,
287        ),
288        (
289            "scope_schema_url",
290            ColumnDataType::String,
291            SemanticType::Field,
292            None,
293            None,
294        ),
295        (
296            "resource_attributes",
297            ColumnDataType::Binary,
298            SemanticType::Field,
299            Some(ColumnDataTypeExtension {
300                type_ext: Some(TypeExt::JsonType(JsonTypeExtension::JsonBinary.into())),
301            }),
302            None,
303        ),
304        (
305            "resource_schema_url",
306            ColumnDataType::String,
307            SemanticType::Field,
308            None,
309            None,
310        ),
311    ]
312    .into_iter()
313    .map(
314        |(field_name, column_type, semantic_type, datatype_extension, options)| ColumnSchema {
315            column_name: field_name.to_string(),
316            datatype: column_type as i32,
317            semantic_type: semantic_type as i32,
318            datatype_extension,
319            options,
320        },
321    )
322    .collect::<Vec<ColumnSchema>>()
323}
324
325#[derive(Clone)]
326struct ExistingLogColumn {
327    schema: ColumnSchema,
328    datatype: ColumnDataType,
329}
330
331impl ExistingLogColumn {
332    fn schema_for_request_type(&self, request_type: ColumnDataType) -> ColumnSchema {
333        let mut schema = self.schema.clone();
334        if request_type == ColumnDataType::Binary && self.is_json_binary() {
335            schema.datatype = ColumnDataType::Binary as i32;
336        }
337        schema
338    }
339
340    fn is_json_binary(&self) -> bool {
341        self.datatype == ColumnDataType::Json
342            && matches!(
343                self.schema
344                    .datatype_extension
345                    .as_ref()
346                    .and_then(|datatype_extension| datatype_extension.type_ext.as_ref()),
347                Some(TypeExt::JsonType(json_type))
348                     if *json_type == JsonTypeExtension::JsonBinary as i32
349            )
350    }
351}
352
353#[derive(Default)]
354struct ExistingLogSchema {
355    columns: HashMap<String, ExistingLogColumn>,
356}
357
358impl ExistingLogSchema {
359    fn try_from_table(table: &table::Table) -> Result<Self> {
360        let table_info = table.table_info();
361        Self::try_from_schema_parts(
362            table.schema_ref().column_schemas(),
363            &table_info.meta.primary_key_indices,
364        )
365    }
366
367    fn try_from_schema_parts(
368        column_schemas: &[datatypes::schema::ColumnSchema],
369        primary_key_indices: &[usize],
370    ) -> Result<Self> {
371        let mut columns = HashMap::with_capacity(column_schemas.len());
372
373        for (index, column_schema) in column_schemas.iter().enumerate() {
374            let (datatype, datatype_extension) =
375                ColumnDataTypeWrapper::try_from(column_schema.data_type.clone())
376                    .map(|wrapper| wrapper.into_parts())
377                    .map_err(Error::from)?;
378            let semantic_type = if column_schema.is_time_index() {
379                SemanticType::Timestamp
380            } else if primary_key_indices.contains(&index) {
381                SemanticType::Tag
382            } else {
383                SemanticType::Field
384            };
385            let schema = ColumnSchema {
386                column_name: column_schema.name.clone(),
387                datatype: datatype as i32,
388                semantic_type: semantic_type as i32,
389                datatype_extension,
390                options: options_from_column_schema(column_schema),
391            };
392            columns.insert(
393                schema.column_name.clone(),
394                ExistingLogColumn { schema, datatype },
395            );
396        }
397
398        Ok(Self { columns })
399    }
400
401    fn get(&self, column_name: &str) -> Option<&ExistingLogColumn> {
402        self.columns.get(column_name)
403    }
404}
405
406fn build_otlp_build_in_row(
407    log: LogRecord,
408    parse_ctx: &mut ParseContext,
409) -> (Row, JsonbValue<'static>) {
410    let log_attr = key_value_to_jsonb(log.attributes);
411    let ts = if log.time_unix_nano != 0 {
412        log.time_unix_nano
413    } else {
414        log.observed_time_unix_nano
415    };
416
417    let row = vec![
418        GreptimeValue {
419            value_data: Some(ValueData::TimestampNanosecondValue(ts as i64)),
420        },
421        GreptimeValue {
422            value_data: Some(ValueData::StringValue(bytes_to_hex_string(&log.trace_id))),
423        },
424        GreptimeValue {
425            value_data: Some(ValueData::StringValue(bytes_to_hex_string(&log.span_id))),
426        },
427        GreptimeValue {
428            value_data: Some(ValueData::StringValue(log.severity_text)),
429        },
430        GreptimeValue {
431            value_data: Some(ValueData::I32Value(log.severity_number)),
432        },
433        GreptimeValue {
434            value_data: log
435                .body
436                .as_ref()
437                .map(|x| ValueData::StringValue(log_body_to_string(x))),
438        },
439        GreptimeValue {
440            value_data: Some(ValueData::BinaryValue(log_attr.to_vec())),
441        },
442        GreptimeValue {
443            value_data: Some(ValueData::I32Value(log.flags as i32)),
444        },
445        GreptimeValue {
446            value_data: parse_ctx.scope_name.clone().map(ValueData::StringValue),
447        },
448        GreptimeValue {
449            value_data: parse_ctx.scope_version.clone().map(ValueData::StringValue),
450        },
451        GreptimeValue {
452            value_data: Some(ValueData::BinaryValue(parse_ctx.scope_attrs.to_vec())),
453        },
454        GreptimeValue {
455            value_data: Some(ValueData::StringValue(parse_ctx.scope_url.clone())),
456        },
457        GreptimeValue {
458            value_data: Some(ValueData::BinaryValue(parse_ctx.resource_attr.to_vec())),
459        },
460        GreptimeValue {
461            value_data: Some(ValueData::StringValue(parse_ctx.resource_url.clone())),
462        },
463    ];
464    (Row { values: row }, log_attr)
465}
466
467fn extract_field_from_attr_and_combine_schema(
468    select_info: &SelectInfo,
469    select_schema: &mut SchemaInfo,
470    attrs: &jsonb::Value,
471    existing_schema: Option<&ExistingLogSchema>,
472    table_name: &str,
473) -> Result<Vec<GreptimeValue>> {
474    // note we use schema.len instead of select_keys.len
475    // because the len of the row value should always matches the len of the schema
476    let mut extracted_values = vec![GreptimeValue::default(); select_schema.schema.len()];
477
478    for key in select_info.keys.iter() {
479        let Some(value) = attrs.get_by_name_ignore_case(key).cloned() else {
480            continue;
481        };
482        let Some((schema, value)) =
483            decide_column_schema_and_convert_value(key, value, existing_schema, table_name)?
484        else {
485            continue;
486        };
487
488        if let Some(index) = select_schema.index.get(key) {
489            let column_schema = &select_schema.schema[*index];
490            let column_schema: ColumnSchema = column_schema.clone().try_into()?;
491            // datatype of the same column name should be the same
492            ensure!(
493                column_schema.datatype == schema.datatype,
494                IncompatibleSchemaSnafu {
495                    column_name: key,
496                    datatype: column_schema.datatype().as_str_name(),
497                    expected: column_schema.datatype,
498                    actual: schema.datatype,
499                }
500            );
501            extracted_values[*index] = value;
502        } else {
503            select_schema.schema.push(schema.into());
504            select_schema
505                .index
506                .insert(key.clone(), select_schema.schema.len() - 1);
507            extracted_values.push(value);
508        }
509    }
510
511    Ok(extracted_values)
512}
513
514fn decide_column_schema_and_convert_value(
515    column_name: &str,
516    value: JsonbValue,
517    existing_schema: Option<&ExistingLogSchema>,
518    table_name: &str,
519) -> Result<Option<(ColumnSchema, GreptimeValue)>> {
520    if let Some(existing_column) = existing_schema.and_then(|schema| schema.get(column_name)) {
521        return decide_existing_column_schema_and_convert_value(
522            column_name,
523            value,
524            existing_column,
525            table_name,
526        );
527    }
528
529    let column_info = match value {
530        JsonbValue::String(s) => Ok(Some((
531            GreptimeValue {
532                value_data: Some(ValueData::StringValue(s.into())),
533            },
534            ColumnDataType::String,
535            SemanticType::Tag,
536            None,
537        ))),
538        JsonbValue::Number(n) => match n {
539            JsonbNumber::Int64(i) => Ok(Some((
540                GreptimeValue {
541                    value_data: Some(ValueData::I64Value(i)),
542                },
543                ColumnDataType::Int64,
544                SemanticType::Tag,
545                None,
546            ))),
547            JsonbNumber::Float64(_) => UnsupportedJsonDataTypeForTagSnafu {
548                ty: "FLOAT".to_string(),
549                key: column_name,
550            }
551            .fail(),
552            JsonbNumber::UInt64(u) => {
553                let value = jsonb_uint64_to_log_value(u, column_name)?;
554                Ok(Some((
555                    GreptimeValue {
556                        value_data: Some(ValueData::I64Value(value)),
557                    },
558                    ColumnDataType::Int64,
559                    SemanticType::Tag,
560                    None,
561                )))
562            }
563        },
564        JsonbValue::Bool(b) => Ok(Some((
565            GreptimeValue {
566                value_data: Some(ValueData::BoolValue(b)),
567            },
568            ColumnDataType::Boolean,
569            SemanticType::Tag,
570            None,
571        ))),
572        JsonbValue::Array(_) | JsonbValue::Object(_) => UnsupportedJsonDataTypeForTagSnafu {
573            ty: "Json".to_string(),
574            key: column_name,
575        }
576        .fail(),
577        JsonbValue::Null => Ok(None),
578    };
579    column_info.map(|c| {
580        c.map(|(value, column_type, semantic_type, datatype_extension)| {
581            (
582                ColumnSchema {
583                    column_name: column_name.to_string(),
584                    datatype: column_type as i32,
585                    semantic_type: semantic_type as i32,
586                    datatype_extension,
587                    options: None,
588                },
589                value,
590            )
591        })
592    })
593}
594
595fn decide_existing_column_schema_and_convert_value(
596    column_name: &str,
597    value: JsonbValue,
598    existing_column: &ExistingLogColumn,
599    table_name: &str,
600) -> Result<Option<(ColumnSchema, GreptimeValue)>> {
601    let Some((value_data, request_type)) = jsonb_value_to_log_value_data(column_name, value, true)?
602    else {
603        return Ok(None);
604    };
605    let value_data = coerce_log_value_data(
606        Some(value_data),
607        existing_column.datatype,
608        existing_column.schema.semantic_type(),
609        request_type,
610        existing_column.is_json_binary(),
611        column_name,
612        table_name,
613    )?;
614
615    Ok(Some((
616        existing_column.schema.clone(),
617        GreptimeValue { value_data },
618    )))
619}
620
621/// Converts a JSON `UInt64` number into the signed value used by built-in
622/// log columns. `JsonbNumber::UInt64` only arises for values that do not fit
623/// in `i64` (see `decide_column_schema_and_convert_value`), so the conversion
624/// is fallible: wrapping to a negative number would silently corrupt the
625/// value (a counter reading back as negative), so out-of-range values are
626/// rejected instead.
627fn jsonb_uint64_to_log_value(u: u64, column_name: &str) -> Result<i64> {
628    i64::try_from(u).map_err(|_| InvalidParameterSnafu {
629        reason: format!(
630            "uint64 value {u} in column '{column_name}' exceeds the i64 range supported by built-in log columns"
631        ),
632    }
633    .build())
634}
635
636fn jsonb_value_to_log_value_data(
637    column_name: &str,
638    value: JsonbValue,
639    allow_float: bool,
640) -> Result<Option<(ValueData, ColumnDataType)>> {
641    match value {
642        JsonbValue::String(s) => Ok(Some((
643            ValueData::StringValue(s.into()),
644            ColumnDataType::String,
645        ))),
646        JsonbValue::Number(n) => match n {
647            JsonbNumber::Int64(i) => Ok(Some((ValueData::I64Value(i), ColumnDataType::Int64))),
648            JsonbNumber::Float64(f) if allow_float => {
649                Ok(Some((ValueData::F64Value(f), ColumnDataType::Float64)))
650            }
651            JsonbNumber::Float64(_) => UnsupportedJsonDataTypeForTagSnafu {
652                ty: "FLOAT".to_string(),
653                key: column_name,
654            }
655            .fail(),
656            JsonbNumber::UInt64(u) => Ok(Some((
657                ValueData::I64Value(jsonb_uint64_to_log_value(u, column_name)?),
658                ColumnDataType::Int64,
659            ))),
660        },
661        JsonbValue::Bool(b) => Ok(Some((ValueData::BoolValue(b), ColumnDataType::Boolean))),
662        JsonbValue::Array(_) | JsonbValue::Object(_) => UnsupportedJsonDataTypeForTagSnafu {
663            ty: "Json".to_string(),
664            key: column_name,
665        }
666        .fail(),
667        JsonbValue::Null => Ok(None),
668    }
669}
670
671fn align_rows_with_existing_schema(
672    schemas: &mut [ColumnSchema],
673    rows: &mut [Row],
674    existing_schema: Option<&ExistingLogSchema>,
675    table_name: &str,
676) -> Result<()> {
677    let Some(existing_schema) = existing_schema else {
678        return Ok(());
679    };
680
681    for (column_idx, schema) in schemas.iter_mut().enumerate() {
682        let request_type = schema.datatype();
683        let Some(existing_column) = existing_schema.get(&schema.column_name) else {
684            // Existing tables own their primary key definition; request-only
685            // columns must not expand it.
686            if schema.semantic_type() == SemanticType::Tag {
687                schema.semantic_type = SemanticType::Field as i32;
688            }
689            continue;
690        };
691
692        let target_type = existing_column.datatype;
693        let semantic_type = existing_column.schema.semantic_type();
694        let target_is_json_binary = existing_column.is_json_binary();
695        for row in rows.iter_mut() {
696            let Some(value) = row.values.get_mut(column_idx) else {
697                continue;
698            };
699            value.value_data = coerce_log_value_data(
700                value.value_data.take(),
701                target_type,
702                semantic_type,
703                request_type,
704                target_is_json_binary,
705                &schema.column_name,
706                table_name,
707            )?;
708        }
709        *schema = existing_column.schema_for_request_type(request_type);
710    }
711
712    Ok(())
713}
714
715fn coerce_log_value_data(
716    value_data: Option<ValueData>,
717    target_type: ColumnDataType,
718    _semantic_type: SemanticType,
719    request_type: ColumnDataType,
720    target_is_json_binary: bool,
721    column_name: &str,
722    table_name: &str,
723) -> Result<Option<ValueData>> {
724    let Some(value_data) = value_data else {
725        return Ok(None);
726    };
727
728    if request_type == target_type {
729        return Ok(Some(value_data));
730    }
731
732    if request_type == ColumnDataType::Binary && target_is_json_binary {
733        return Ok(Some(value_data));
734    }
735
736    if is_timestamp_type(request_type)
737        && let Some(target_unit) = timestamp_unit(target_type)
738    {
739        return align_timestamp_value(value_data, target_unit, column_name, table_name).map(Some);
740    }
741
742    // Lossless signed -> unsigned integer cast for built-in fields moving from
743    // unsigned to signed types (e.g. an existing UInt64/UInt32 log column
744    // receiving new Int64/Int32 ingest). Lets existing tables keep their
745    // unsigned columns without an `ALTER`. In the mutually-exclusive `else` of
746    // the String branch so the move stays local to non-String targets; the
747    // shared predicate keeps the supported pairs identical to the trace path
748    // (the generic String -> numeric coercions keep their pre-existing
749    // rejection on this path).
750    if target_type == ColumnDataType::String {
751        if let Ok(value_data) =
752            coerce_value_data(&Some(value_data.clone()), target_type, request_type)
753        {
754            return Ok(value_data);
755        }
756        if let Some(value_data) = stringify_scalar_value(value_data) {
757            return Ok(Some(value_data));
758        }
759    } else if is_supported_signed_to_unsigned_coercion(request_type, target_type)
760        && let Ok(Some(value_data)) =
761            coerce_value_data(&Some(value_data), target_type, request_type)
762    {
763        return Ok(Some(value_data));
764    }
765
766    InvalidParameterSnafu {
767        reason: format!(
768            "failed to align log column '{}' in table '{}' from {:?} to {:?}",
769            column_name, table_name, request_type, target_type
770        ),
771    }
772    .fail()
773}
774
775fn stringify_scalar_value(value_data: ValueData) -> Option<ValueData> {
776    let value = match value_data {
777        ValueData::StringValue(value) => value,
778        ValueData::BoolValue(value) => value.to_string(),
779        ValueData::I8Value(value) => value.to_string(),
780        ValueData::I16Value(value) => value.to_string(),
781        ValueData::I32Value(value) => value.to_string(),
782        ValueData::I64Value(value) => value.to_string(),
783        ValueData::U8Value(value) => value.to_string(),
784        ValueData::U16Value(value) => value.to_string(),
785        ValueData::U32Value(value) => value.to_string(),
786        ValueData::U64Value(value) => value.to_string(),
787        ValueData::F32Value(value) => value.to_string(),
788        ValueData::F64Value(value) => value.to_string(),
789        _ => return None,
790    };
791    Some(ValueData::StringValue(value))
792}
793
794fn align_timestamp_value(
795    value_data: ValueData,
796    target_unit: TimeUnit,
797    column_name: &str,
798    table_name: &str,
799) -> Result<ValueData> {
800    let timestamp = match value_data {
801        ValueData::TimestampSecondValue(value) => Timestamp::new_second(value),
802        ValueData::TimestampMillisecondValue(value) => Timestamp::new_millisecond(value),
803        ValueData::TimestampMicrosecondValue(value) => Timestamp::new_microsecond(value),
804        ValueData::TimestampNanosecondValue(value) => Timestamp::new_nanosecond(value),
805        value_data => {
806            return InvalidParameterSnafu {
807                reason: format!(
808                    "failed to align log column '{}' in table '{}' from non-timestamp value {:?}",
809                    column_name, table_name, value_data
810                ),
811            }
812            .fail();
813        }
814    };
815    let timestamp = timestamp.convert_to(target_unit).ok_or_else(|| {
816        InvalidParameterSnafu {
817            reason: format!(
818                "failed to align log column '{}' in table '{}' to timestamp unit {}",
819                column_name, table_name, target_unit
820            ),
821        }
822        .build()
823    })?;
824
825    Ok(match target_unit {
826        TimeUnit::Second => ValueData::TimestampSecondValue(timestamp.value()),
827        TimeUnit::Millisecond => ValueData::TimestampMillisecondValue(timestamp.value()),
828        TimeUnit::Microsecond => ValueData::TimestampMicrosecondValue(timestamp.value()),
829        TimeUnit::Nanosecond => ValueData::TimestampNanosecondValue(timestamp.value()),
830    })
831}
832
833fn is_timestamp_type(datatype: ColumnDataType) -> bool {
834    timestamp_unit(datatype).is_some()
835}
836
837fn timestamp_unit(datatype: ColumnDataType) -> Option<TimeUnit> {
838    match datatype {
839        ColumnDataType::TimestampSecond => Some(TimeUnit::Second),
840        ColumnDataType::TimestampMillisecond => Some(TimeUnit::Millisecond),
841        ColumnDataType::TimestampMicrosecond => Some(TimeUnit::Microsecond),
842        ColumnDataType::TimestampNanosecond => Some(TimeUnit::Nanosecond),
843        _ => None,
844    }
845}
846
847fn parse_export_logs_service_request_to_rows(
848    request: ExportLogsServiceRequest,
849    select_info: Box<SelectInfo>,
850    existing_schema: Option<&ExistingLogSchema>,
851    table_name: &str,
852) -> Result<Rows> {
853    let mut schemas = build_otlp_logs_identity_schema();
854
855    let mut parse_ctx = ParseContext::new(select_info, existing_schema, table_name);
856    let mut rows = parse_resource(&mut parse_ctx, request.resource_logs)?;
857
858    schemas.extend(parse_ctx.select_schema.column_schemas()?);
859    align_rows_with_existing_schema(&mut schemas, &mut rows, existing_schema, table_name)?;
860
861    rows.iter_mut().for_each(|row| {
862        row.values.resize(schemas.len(), GreptimeValue::default());
863    });
864
865    Ok(Rows {
866        schema: schemas,
867        rows,
868    })
869}
870
871fn parse_resource(
872    parse_ctx: &mut ParseContext,
873    resource_logs_vec: Vec<ResourceLogs>,
874) -> Result<Vec<Row>> {
875    let total_len = resource_logs_vec
876        .iter()
877        .flat_map(|r| r.scope_logs.iter())
878        .map(|s| s.log_records.len())
879        .sum();
880
881    let mut results = Vec::with_capacity(total_len);
882
883    for r in resource_logs_vec {
884        parse_ctx.resource_attr = r
885            .resource
886            .map(|resource| key_value_to_jsonb(resource.attributes))
887            .unwrap_or(JsonbValue::Null);
888
889        parse_ctx.resource_url = r.schema_url;
890
891        parse_ctx.resource_uplift_values = extract_field_from_attr_and_combine_schema(
892            &parse_ctx.select_info,
893            &mut parse_ctx.select_schema,
894            &parse_ctx.resource_attr,
895            parse_ctx.existing_schema,
896            parse_ctx.table_name,
897        )?;
898
899        let rows = parse_scope(r.scope_logs, parse_ctx)?;
900        results.extend(rows);
901    }
902    Ok(results)
903}
904
905struct ParseContext<'a> {
906    // input selected keys
907    select_info: Box<SelectInfo>,
908    existing_schema: Option<&'a ExistingLogSchema>,
909    table_name: &'a str,
910    // schema infos for selected keys from resource/scope/log for current request
911    // since the value override from bottom to top, the max capacity is the length of the keys
912    select_schema: SchemaInfo,
913
914    // extracted and uplifted values using select keys
915    resource_uplift_values: Vec<GreptimeValue>,
916    scope_uplift_values: Vec<GreptimeValue>,
917
918    // passdown values
919    resource_url: String,
920    resource_attr: JsonbValue<'a>,
921    scope_name: Option<String>,
922    scope_version: Option<String>,
923    scope_url: String,
924    scope_attrs: JsonbValue<'a>,
925}
926
927impl<'a> ParseContext<'a> {
928    pub fn new(
929        select_info: Box<SelectInfo>,
930        existing_schema: Option<&'a ExistingLogSchema>,
931        table_name: &'a str,
932    ) -> ParseContext<'a> {
933        let len = select_info.keys.len();
934        ParseContext {
935            select_info,
936            existing_schema,
937            table_name,
938            select_schema: SchemaInfo::with_capacity(len),
939            resource_uplift_values: vec![],
940            scope_uplift_values: vec![],
941            resource_url: String::new(),
942            resource_attr: JsonbValue::Null,
943            scope_name: None,
944            scope_version: None,
945            scope_url: String::new(),
946            scope_attrs: JsonbValue::Null,
947        }
948    }
949}
950
951fn parse_scope(scopes_log_vec: Vec<ScopeLogs>, parse_ctx: &mut ParseContext) -> Result<Vec<Row>> {
952    let len = scopes_log_vec.iter().map(|l| l.log_records.len()).sum();
953    let mut results = Vec::with_capacity(len);
954
955    for scope_logs in scopes_log_vec {
956        let (scope_attrs, scope_version, scope_name) = scope_to_jsonb(scope_logs.scope);
957        parse_ctx.scope_name = scope_name;
958        parse_ctx.scope_version = scope_version;
959        parse_ctx.scope_url = scope_logs.schema_url;
960        parse_ctx.scope_attrs = scope_attrs;
961
962        parse_ctx.scope_uplift_values = extract_field_from_attr_and_combine_schema(
963            &parse_ctx.select_info,
964            &mut parse_ctx.select_schema,
965            &parse_ctx.scope_attrs,
966            parse_ctx.existing_schema,
967            parse_ctx.table_name,
968        )?;
969
970        let rows = parse_log(scope_logs.log_records, parse_ctx)?;
971        results.extend(rows);
972    }
973    Ok(results)
974}
975
976fn parse_log(log_records: Vec<LogRecord>, parse_ctx: &mut ParseContext) -> Result<Vec<Row>> {
977    let mut result = Vec::with_capacity(log_records.len());
978
979    for log in log_records {
980        let (mut row, log_attr) = build_otlp_build_in_row(log, parse_ctx);
981
982        let log_values = extract_field_from_attr_and_combine_schema(
983            &parse_ctx.select_info,
984            &mut parse_ctx.select_schema,
985            &log_attr,
986            parse_ctx.existing_schema,
987            parse_ctx.table_name,
988        )?;
989
990        let extracted_values = merge_values(
991            log_values,
992            &parse_ctx.scope_uplift_values,
993            &parse_ctx.resource_uplift_values,
994        );
995
996        row.values.extend(extracted_values);
997
998        result.push(row);
999    }
1000    Ok(result)
1001}
1002
1003fn merge_values(
1004    log: Vec<GreptimeValue>,
1005    scope: &[GreptimeValue],
1006    resource: &[GreptimeValue],
1007) -> Vec<GreptimeValue> {
1008    log.into_iter()
1009        .enumerate()
1010        .map(|(i, value)| GreptimeValue {
1011            value_data: value
1012                .value_data
1013                .or_else(|| scope.get(i).and_then(|x| x.value_data.clone()))
1014                .or_else(|| resource.get(i).and_then(|x| x.value_data.clone())),
1015        })
1016        .collect()
1017}
1018
1019/// transform otlp logs request to pipeline value
1020/// https://opentelemetry.io/docs/concepts/signals/logs/
1021fn parse_export_logs_service_request(request: ExportLogsServiceRequest) -> Vec<VrlValue> {
1022    let mut result = Vec::new();
1023    for r in request.resource_logs {
1024        let resource_attr = r
1025            .resource
1026            .map(|x| VrlValue::Object(key_value_to_map(x.attributes)))
1027            .unwrap_or(VrlValue::Null);
1028        let resource_schema_url = VrlValue::Bytes(r.schema_url.into());
1029        for scope_logs in r.scope_logs {
1030            let (scope_attrs, scope_version, scope_name) =
1031                scope_to_pipeline_value(scope_logs.scope);
1032            let scope_schema_url = VrlValue::Bytes(scope_logs.schema_url.into());
1033            for log in scope_logs.log_records {
1034                let value = log_to_pipeline_value(
1035                    log,
1036                    resource_schema_url.clone(),
1037                    resource_attr.clone(),
1038                    scope_schema_url.clone(),
1039                    scope_name.clone(),
1040                    scope_version.clone(),
1041                    scope_attrs.clone(),
1042                );
1043                result.push(value);
1044            }
1045        }
1046    }
1047    result
1048}
1049
1050// convert AnyValue to pipeline value
1051fn any_value_to_vrl_value(value: any_value::Value) -> VrlValue {
1052    match value {
1053        any_value::Value::StringValue(s) => VrlValue::Bytes(s.into()),
1054        any_value::Value::IntValue(i) => VrlValue::Integer(i),
1055        any_value::Value::DoubleValue(d) => VrlValue::Float(NotNan::new(d).unwrap()),
1056        any_value::Value::BoolValue(b) => VrlValue::Boolean(b),
1057        any_value::Value::ArrayValue(array_value) => {
1058            let values = array_value
1059                .values
1060                .into_iter()
1061                .filter_map(|v| v.value.map(any_value_to_vrl_value))
1062                .collect();
1063            VrlValue::Array(values)
1064        }
1065        any_value::Value::KvlistValue(key_value_list) => {
1066            VrlValue::Object(key_value_to_map(key_value_list.values))
1067        }
1068        any_value::Value::BytesValue(items) => VrlValue::Bytes(Bytes::from(items)),
1069        // `StringValueStrindex` references the Profiling signal's
1070        // `ProfilesDictionary.string_table`, which is unavailable to logs/traces.
1071        // Per the OTLP spec, non-Profiling receivers must treat it as a non-fatal
1072        // issue and process the value as if it were absent.
1073        any_value::Value::StringValueStrindex(_) => {
1074            warn!(
1075                "encountered a profiling-only `StringValueStrindex` value in a non-profiling signal; ignoring"
1076            );
1077            VrlValue::Null
1078        }
1079    }
1080}
1081
1082// convert otlp keyValue vec to map
1083fn key_value_to_map(key_values: Vec<KeyValue>) -> BTreeMap<KeyString, VrlValue> {
1084    let mut map = BTreeMap::new();
1085    for kv in key_values {
1086        let value = match kv.value {
1087            Some(value) => match value.value {
1088                Some(value) => any_value_to_vrl_value(value),
1089                None => VrlValue::Null,
1090            },
1091            None => VrlValue::Null,
1092        };
1093        map.insert(kv.key.into(), value);
1094    }
1095    map
1096}
1097
1098fn log_body_to_string(body: &AnyValue) -> String {
1099    let otlp_value = OtlpAnyValue::from(body);
1100    otlp_value.to_string()
1101}
1102
1103#[cfg(test)]
1104mod tests {
1105    use datatypes::prelude::ConcreteDataType;
1106    use datatypes::schema::ColumnSchema as DatatypesColumnSchema;
1107    use opentelemetry_proto::tonic::common::v1::any_value::Value as OtlpValue;
1108
1109    use super::*;
1110
1111    fn time_column(datatype: ConcreteDataType) -> DatatypesColumnSchema {
1112        DatatypesColumnSchema::new("timestamp", datatype, false).with_time_index(true)
1113    }
1114
1115    fn column(name: &str, datatype: ConcreteDataType) -> DatatypesColumnSchema {
1116        DatatypesColumnSchema::new(name, datatype, true)
1117    }
1118
1119    fn existing_schema(
1120        columns: Vec<DatatypesColumnSchema>,
1121        primary_key_indices: &[usize],
1122    ) -> ExistingLogSchema {
1123        ExistingLogSchema::try_from_schema_parts(&columns, primary_key_indices).unwrap()
1124    }
1125
1126    fn existing_uint32_trace_flags_schema() -> ExistingLogSchema {
1127        existing_schema(
1128            vec![
1129                time_column(ConcreteDataType::timestamp_nanosecond_datatype()),
1130                column("trace_flags", ConcreteDataType::uint32_datatype()),
1131            ],
1132            &[],
1133        )
1134    }
1135
1136    fn kv(key: &str, value: OtlpValue) -> KeyValue {
1137        KeyValue {
1138            key: key.to_string(),
1139            value: Some(AnyValue { value: Some(value) }),
1140            ..Default::default()
1141        }
1142    }
1143
1144    fn request_with_log_attrs(attrs: Vec<KeyValue>) -> ExportLogsServiceRequest {
1145        request_with_log_attrs_and_flags(attrs, 0)
1146    }
1147
1148    fn request_with_log_attrs_and_flags(
1149        attrs: Vec<KeyValue>,
1150        flags: u32,
1151    ) -> ExportLogsServiceRequest {
1152        ExportLogsServiceRequest {
1153            resource_logs: vec![ResourceLogs {
1154                scope_logs: vec![ScopeLogs {
1155                    log_records: vec![LogRecord {
1156                        time_unix_nano: 1_234_000_000,
1157                        trace_id: vec![1; 16],
1158                        flags,
1159                        attributes: attrs,
1160                        ..Default::default()
1161                    }],
1162                    ..Default::default()
1163                }],
1164                ..Default::default()
1165            }],
1166        }
1167    }
1168
1169    fn parse_with_select(
1170        request: ExportLogsServiceRequest,
1171        select: &str,
1172        existing_schema: Option<&ExistingLogSchema>,
1173    ) -> Result<Rows> {
1174        parse_export_logs_service_request_to_rows(
1175            request,
1176            Box::new(SelectInfo::from(select.to_string())),
1177            existing_schema,
1178            "test_logs",
1179        )
1180    }
1181
1182    fn column_index(rows: &Rows, name: &str) -> usize {
1183        rows.schema
1184            .iter()
1185            .position(|schema| schema.column_name == name)
1186            .unwrap()
1187    }
1188
1189    #[test]
1190    fn test_no_existing_table_preserves_direct_schema() {
1191        let rows = parse_with_select(request_with_log_attrs(vec![]), "", None).unwrap();
1192
1193        assert_eq!(rows.schema[0].column_name, "timestamp");
1194        assert_eq!(
1195            rows.schema[0].datatype,
1196            ColumnDataType::TimestampNanosecond as i32
1197        );
1198        assert_eq!(rows.schema[0].semantic_type, SemanticType::Timestamp as i32);
1199        let scope_name_idx = column_index(&rows, "scope_name");
1200        assert_eq!(
1201            rows.schema[scope_name_idx].semantic_type,
1202            SemanticType::Tag as i32
1203        );
1204    }
1205
1206    #[test]
1207    fn test_fresh_table_trace_flags_is_int32() {
1208        // Phase 1 of the unsigned -> signed transition: a fresh log table now
1209        // creates `trace_flags` as Int32. Existing tables keep UInt32 (covered
1210        // by the coercion test above); new tables start signed.
1211        let rows = parse_with_select(request_with_log_attrs(vec![]), "", None).unwrap();
1212        let idx = column_index(&rows, "trace_flags");
1213
1214        assert_eq!(rows.schema[idx].datatype, ColumnDataType::Int32 as i32);
1215        assert_eq!(
1216            rows.rows[0].values[idx].value_data,
1217            Some(ValueData::I32Value(0))
1218        );
1219    }
1220
1221    #[test]
1222    fn test_existing_uint32_trace_flags_keeps_type_and_coerces_int32_request() {
1223        // An existing UInt32 `trace_flags` column keeps its type when new
1224        // signed (Int32) ingest arrives, without an `ALTER`.
1225        let existing = existing_uint32_trace_flags_schema();
1226
1227        let rows = parse_with_select(request_with_log_attrs(vec![]), "", Some(&existing)).unwrap();
1228        let idx = column_index(&rows, "trace_flags");
1229
1230        assert_eq!(rows.schema[idx].datatype, ColumnDataType::Uint32 as i32);
1231        assert_eq!(
1232            rows.rows[0].values[idx].value_data,
1233            Some(ValueData::U32Value(0))
1234        );
1235    }
1236
1237    #[test]
1238    fn test_existing_uint32_trace_flags_preserves_nonzero_flags_value() {
1239        // A non-zero trace_flags bit pattern (e.g. the W3C sampled flag as sent
1240        // by common encoders) round-trips exactly through the Int32 -> UInt32
1241        // coercion into an existing unsigned column. Guards that the cast is
1242        // bit-preserving for realistic values, not just the default 0.
1243        let existing = existing_uint32_trace_flags_schema();
1244
1245        let rows = parse_with_select(
1246            request_with_log_attrs_and_flags(vec![], 256),
1247            "",
1248            Some(&existing),
1249        )
1250        .unwrap();
1251        let idx = column_index(&rows, "trace_flags");
1252
1253        assert_eq!(rows.schema[idx].datatype, ColumnDataType::Uint32 as i32);
1254        assert_eq!(
1255            rows.rows[0].values[idx].value_data,
1256            Some(ValueData::U32Value(256))
1257        );
1258    }
1259
1260    #[test]
1261    fn test_existing_uint32_trace_flags_preserves_high_bit_flags_value() {
1262        // The full u32 range round-trips: a value with the sign bit set proves
1263        // the Int32 -> UInt32 coercion is bit-exact (`as i32` then `as u32`
1264        // preserve the pattern), so no flags value is corrupted on the
1265        // unsigned -> signed transition.
1266        let existing = existing_uint32_trace_flags_schema();
1267
1268        let rows = parse_with_select(
1269            request_with_log_attrs_and_flags(vec![], 0x8000_0000),
1270            "",
1271            Some(&existing),
1272        )
1273        .unwrap();
1274        let idx = column_index(&rows, "trace_flags");
1275
1276        assert_eq!(rows.schema[idx].datatype, ColumnDataType::Uint32 as i32);
1277        assert_eq!(
1278            rows.rows[0].values[idx].value_data,
1279            Some(ValueData::U32Value(0x8000_0000))
1280        );
1281    }
1282
1283    #[test]
1284    fn test_existing_primary_key_updates_builtin_column_semantic_type() {
1285        let existing = existing_schema(
1286            vec![
1287                time_column(ConcreteDataType::timestamp_nanosecond_datatype()),
1288                column("trace_id", ConcreteDataType::string_datatype()),
1289            ],
1290            &[1],
1291        );
1292
1293        let rows = parse_with_select(request_with_log_attrs(vec![]), "", Some(&existing)).unwrap();
1294        let trace_id_idx = column_index(&rows, "trace_id");
1295
1296        assert_eq!(
1297            rows.schema[trace_id_idx].semantic_type,
1298            SemanticType::Tag as i32
1299        );
1300    }
1301
1302    #[test]
1303    fn test_existing_string_primary_key_stringifies_selected_scalar_values() {
1304        let existing = existing_schema(
1305            vec![
1306                time_column(ConcreteDataType::timestamp_nanosecond_datatype()),
1307                column("host", ConcreteDataType::string_datatype()),
1308            ],
1309            &[1],
1310        );
1311        let rows = parse_with_select(
1312            request_with_log_attrs(vec![kv("host", OtlpValue::IntValue(42))]),
1313            "host",
1314            Some(&existing),
1315        )
1316        .unwrap();
1317        let host_idx = column_index(&rows, "host");
1318
1319        assert_eq!(
1320            rows.schema[host_idx].datatype,
1321            ColumnDataType::String as i32
1322        );
1323        assert_eq!(
1324            rows.schema[host_idx].semantic_type,
1325            SemanticType::Tag as i32
1326        );
1327        assert_eq!(
1328            rows.rows[0].values[host_idx].value_data,
1329            Some(ValueData::StringValue("42".to_string()))
1330        );
1331    }
1332
1333    #[test]
1334    fn test_existing_string_field_stringifies_selected_scalar_values() {
1335        let existing = existing_schema(
1336            vec![
1337                time_column(ConcreteDataType::timestamp_nanosecond_datatype()),
1338                column("host", ConcreteDataType::string_datatype()),
1339            ],
1340            &[],
1341        );
1342        let rows = parse_with_select(
1343            request_with_log_attrs(vec![kv("host", OtlpValue::IntValue(42))]),
1344            "host",
1345            Some(&existing),
1346        )
1347        .unwrap();
1348        let host_idx = column_index(&rows, "host");
1349
1350        assert_eq!(
1351            rows.schema[host_idx].datatype,
1352            ColumnDataType::String as i32
1353        );
1354        assert_eq!(
1355            rows.schema[host_idx].semantic_type,
1356            SemanticType::Field as i32
1357        );
1358        assert_eq!(
1359            rows.rows[0].values[host_idx].value_data,
1360            Some(ValueData::StringValue("42".to_string()))
1361        );
1362    }
1363
1364    #[test]
1365    fn test_existing_non_string_primary_key_rejects_incompatible_selected_value() {
1366        let existing = existing_schema(
1367            vec![
1368                time_column(ConcreteDataType::timestamp_nanosecond_datatype()),
1369                column("host", ConcreteDataType::int64_datatype()),
1370            ],
1371            &[1],
1372        );
1373        let err = parse_with_select(
1374            request_with_log_attrs(vec![kv(
1375                "host",
1376                OtlpValue::StringValue("node-a".to_string()),
1377            )]),
1378            "host",
1379            Some(&existing),
1380        )
1381        .unwrap_err();
1382
1383        assert!(
1384            err.to_string()
1385                .contains("failed to align log column 'host'")
1386        );
1387    }
1388
1389    #[test]
1390    fn test_existing_timestamp_unit_is_respected() {
1391        let existing = existing_schema(
1392            vec![time_column(
1393                ConcreteDataType::timestamp_millisecond_datatype(),
1394            )],
1395            &[],
1396        );
1397        let rows = parse_with_select(request_with_log_attrs(vec![]), "", Some(&existing)).unwrap();
1398
1399        assert_eq!(
1400            rows.schema[0].datatype,
1401            ColumnDataType::TimestampMillisecond as i32
1402        );
1403        assert_eq!(
1404            rows.rows[0].values[0].value_data,
1405            Some(ValueData::TimestampMillisecondValue(1234))
1406        );
1407    }
1408
1409    #[test]
1410    fn test_missing_existing_primary_key_is_not_generated() {
1411        let existing = existing_schema(
1412            vec![
1413                time_column(ConcreteDataType::timestamp_nanosecond_datatype()),
1414                column("host", ConcreteDataType::string_datatype()),
1415            ],
1416            &[1],
1417        );
1418        let rows = parse_with_select(request_with_log_attrs(vec![]), "", Some(&existing)).unwrap();
1419
1420        assert!(
1421            !rows
1422                .schema
1423                .iter()
1424                .any(|schema| schema.column_name == "host")
1425        );
1426    }
1427
1428    #[test]
1429    fn test_existing_table_keeps_new_generated_columns_as_fields() {
1430        let existing = existing_schema(
1431            vec![
1432                time_column(ConcreteDataType::timestamp_nanosecond_datatype()),
1433                column("trace_id", ConcreteDataType::string_datatype()),
1434            ],
1435            &[1],
1436        );
1437        let rows = parse_with_select(
1438            request_with_log_attrs(vec![kv(
1439                "host",
1440                OtlpValue::StringValue("node-a".to_string()),
1441            )]),
1442            "host",
1443            Some(&existing),
1444        )
1445        .unwrap();
1446        let host_idx = column_index(&rows, "host");
1447        let scope_name_idx = column_index(&rows, "scope_name");
1448
1449        assert_eq!(
1450            rows.schema[host_idx].semantic_type,
1451            SemanticType::Field as i32
1452        );
1453        assert_eq!(
1454            rows.schema[scope_name_idx].semantic_type,
1455            SemanticType::Field as i32
1456        );
1457    }
1458
1459    #[test]
1460    fn test_existing_uint64_column_keeps_type_and_coerces_int64_request() {
1461        // An existing UInt64 column keeps its type when new signed (Int64)
1462        // ingest arrives, without an `ALTER`. The built-in models move to
1463        // signed while existing unsigned tables stay byte-for-byte unchanged.
1464        let existing = existing_schema(
1465            vec![
1466                time_column(ConcreteDataType::timestamp_nanosecond_datatype()),
1467                column("counter", ConcreteDataType::uint64_datatype()),
1468            ],
1469            &[],
1470        );
1471
1472        let rows = parse_with_select(
1473            request_with_log_attrs(vec![kv("counter", OtlpValue::IntValue(42))]),
1474            "counter",
1475            Some(&existing),
1476        )
1477        .unwrap();
1478        let idx = column_index(&rows, "counter");
1479
1480        assert_eq!(rows.schema[idx].datatype, ColumnDataType::Uint64 as i32);
1481        assert_eq!(
1482            rows.rows[0].values[idx].value_data,
1483            Some(ValueData::U64Value(42))
1484        );
1485    }
1486
1487    #[test]
1488    fn test_jsonb_uint64_exceeding_i64_range_rejected() {
1489        // A JSON number that only fits in u64 must be rejected, not wrapped:
1490        // storing it as a negative i64 would silently corrupt the value. The
1491        // unsigned -> signed transition is only lossless for values that fit
1492        // the signed range.
1493        let err = jsonb_value_to_log_value_data(
1494            "counter",
1495            JsonbValue::Number(JsonbNumber::UInt64(u64::MAX)),
1496            false,
1497        )
1498        .unwrap_err();
1499
1500        assert!(
1501            err.to_string()
1502                .contains("exceeds the i64 range supported by built-in log columns")
1503        );
1504    }
1505
1506    #[test]
1507    fn test_existing_int64_column_rejects_numeric_string_value() {
1508        // Regression guard for the signed -> unsigned transition scope: even a
1509        // parseable numeric string must keep its pre-existing rejection when
1510        // targeting a non-String column. Only the signed -> unsigned integer
1511        // pairs gained coercion on this path, not the generic String ->
1512        // numeric ones.
1513        let existing = existing_schema(
1514            vec![
1515                time_column(ConcreteDataType::timestamp_nanosecond_datatype()),
1516                column("counter", ConcreteDataType::int64_datatype()),
1517            ],
1518            &[],
1519        );
1520
1521        let err = parse_with_select(
1522            request_with_log_attrs(vec![kv(
1523                "counter",
1524                OtlpValue::StringValue("42".to_string()),
1525            )]),
1526            "counter",
1527            Some(&existing),
1528        )
1529        .unwrap_err();
1530
1531        assert!(
1532            err.to_string()
1533                .contains("failed to align log column 'counter'")
1534        );
1535    }
1536}