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::Decimal64(_) | JsonbNumber::Decimal128(_) | JsonbNumber::Decimal256(_) => {
553                UnsupportedJsonDataTypeForTagSnafu {
554                    ty: "DECIMAL".to_string(),
555                    key: column_name,
556                }
557                .fail()
558            }
559            JsonbNumber::UInt64(u) => {
560                let value = jsonb_uint64_to_log_value(u, column_name)?;
561                Ok(Some((
562                    GreptimeValue {
563                        value_data: Some(ValueData::I64Value(value)),
564                    },
565                    ColumnDataType::Int64,
566                    SemanticType::Tag,
567                    None,
568                )))
569            }
570        },
571        JsonbValue::Bool(b) => Ok(Some((
572            GreptimeValue {
573                value_data: Some(ValueData::BoolValue(b)),
574            },
575            ColumnDataType::Boolean,
576            SemanticType::Tag,
577            None,
578        ))),
579        JsonbValue::Array(_) | JsonbValue::Object(_) => UnsupportedJsonDataTypeForTagSnafu {
580            ty: "Json".to_string(),
581            key: column_name,
582        }
583        .fail(),
584        JsonbValue::Binary(_) => UnsupportedJsonDataTypeForTagSnafu {
585            ty: "Binary".to_string(),
586            key: column_name,
587        }
588        .fail(),
589        JsonbValue::Date(_) => UnsupportedJsonDataTypeForTagSnafu {
590            ty: "Date".to_string(),
591            key: column_name,
592        }
593        .fail(),
594        JsonbValue::Timestamp(_) => UnsupportedJsonDataTypeForTagSnafu {
595            ty: "Timestamp".to_string(),
596            key: column_name,
597        }
598        .fail(),
599        JsonbValue::TimestampTz(_) => UnsupportedJsonDataTypeForTagSnafu {
600            ty: "TimestampTz".to_string(),
601            key: column_name,
602        }
603        .fail(),
604        JsonbValue::Interval(_) => UnsupportedJsonDataTypeForTagSnafu {
605            ty: "Interval".to_string(),
606            key: column_name,
607        }
608        .fail(),
609        JsonbValue::Null => Ok(None),
610    };
611    column_info.map(|c| {
612        c.map(|(value, column_type, semantic_type, datatype_extension)| {
613            (
614                ColumnSchema {
615                    column_name: column_name.to_string(),
616                    datatype: column_type as i32,
617                    semantic_type: semantic_type as i32,
618                    datatype_extension,
619                    options: None,
620                },
621                value,
622            )
623        })
624    })
625}
626
627fn decide_existing_column_schema_and_convert_value(
628    column_name: &str,
629    value: JsonbValue,
630    existing_column: &ExistingLogColumn,
631    table_name: &str,
632) -> Result<Option<(ColumnSchema, GreptimeValue)>> {
633    let Some((value_data, request_type)) = jsonb_value_to_log_value_data(column_name, value, true)?
634    else {
635        return Ok(None);
636    };
637    let value_data = coerce_log_value_data(
638        Some(value_data),
639        existing_column.datatype,
640        existing_column.schema.semantic_type(),
641        request_type,
642        existing_column.is_json_binary(),
643        column_name,
644        table_name,
645    )?;
646
647    Ok(Some((
648        existing_column.schema.clone(),
649        GreptimeValue { value_data },
650    )))
651}
652
653/// Converts a JSON `UInt64` number into the signed value used by built-in
654/// log columns. `JsonbNumber::UInt64` only arises for values that do not fit
655/// in `i64` (see `decide_column_schema_and_convert_value`), so the conversion
656/// is fallible: wrapping to a negative number would silently corrupt the
657/// value (a counter reading back as negative), so out-of-range values are
658/// rejected instead.
659fn jsonb_uint64_to_log_value(u: u64, column_name: &str) -> Result<i64> {
660    i64::try_from(u).map_err(|_| InvalidParameterSnafu {
661        reason: format!(
662            "uint64 value {u} in column '{column_name}' exceeds the i64 range supported by built-in log columns"
663        ),
664    }
665    .build())
666}
667
668fn jsonb_value_to_log_value_data(
669    column_name: &str,
670    value: JsonbValue,
671    allow_float: bool,
672) -> Result<Option<(ValueData, ColumnDataType)>> {
673    match value {
674        JsonbValue::String(s) => Ok(Some((
675            ValueData::StringValue(s.into()),
676            ColumnDataType::String,
677        ))),
678        JsonbValue::Number(n) => match n {
679            JsonbNumber::Int64(i) => Ok(Some((ValueData::I64Value(i), ColumnDataType::Int64))),
680            JsonbNumber::Float64(f) if allow_float => {
681                Ok(Some((ValueData::F64Value(f), ColumnDataType::Float64)))
682            }
683            JsonbNumber::Float64(_) => UnsupportedJsonDataTypeForTagSnafu {
684                ty: "FLOAT".to_string(),
685                key: column_name,
686            }
687            .fail(),
688            JsonbNumber::Decimal64(_) | JsonbNumber::Decimal128(_) | JsonbNumber::Decimal256(_) => {
689                UnsupportedJsonDataTypeForTagSnafu {
690                    ty: "DECIMAL".to_string(),
691                    key: column_name,
692                }
693                .fail()
694            }
695            JsonbNumber::UInt64(u) => Ok(Some((
696                ValueData::I64Value(jsonb_uint64_to_log_value(u, column_name)?),
697                ColumnDataType::Int64,
698            ))),
699        },
700        JsonbValue::Bool(b) => Ok(Some((ValueData::BoolValue(b), ColumnDataType::Boolean))),
701        JsonbValue::Array(_) | JsonbValue::Object(_) => UnsupportedJsonDataTypeForTagSnafu {
702            ty: "Json".to_string(),
703            key: column_name,
704        }
705        .fail(),
706        JsonbValue::Binary(_) => UnsupportedJsonDataTypeForTagSnafu {
707            ty: "Binary".to_string(),
708            key: column_name,
709        }
710        .fail(),
711        JsonbValue::Date(_) => UnsupportedJsonDataTypeForTagSnafu {
712            ty: "Date".to_string(),
713            key: column_name,
714        }
715        .fail(),
716        JsonbValue::Timestamp(_) => UnsupportedJsonDataTypeForTagSnafu {
717            ty: "Timestamp".to_string(),
718            key: column_name,
719        }
720        .fail(),
721        JsonbValue::TimestampTz(_) => UnsupportedJsonDataTypeForTagSnafu {
722            ty: "TimestampTz".to_string(),
723            key: column_name,
724        }
725        .fail(),
726        JsonbValue::Interval(_) => UnsupportedJsonDataTypeForTagSnafu {
727            ty: "Interval".to_string(),
728            key: column_name,
729        }
730        .fail(),
731        JsonbValue::Null => Ok(None),
732    }
733}
734
735fn align_rows_with_existing_schema(
736    schemas: &mut [ColumnSchema],
737    rows: &mut [Row],
738    existing_schema: Option<&ExistingLogSchema>,
739    table_name: &str,
740) -> Result<()> {
741    let Some(existing_schema) = existing_schema else {
742        return Ok(());
743    };
744
745    for (column_idx, schema) in schemas.iter_mut().enumerate() {
746        let request_type = schema.datatype();
747        let Some(existing_column) = existing_schema.get(&schema.column_name) else {
748            // Existing tables own their primary key definition; request-only
749            // columns must not expand it.
750            if schema.semantic_type() == SemanticType::Tag {
751                schema.semantic_type = SemanticType::Field as i32;
752            }
753            continue;
754        };
755
756        let target_type = existing_column.datatype;
757        let semantic_type = existing_column.schema.semantic_type();
758        let target_is_json_binary = existing_column.is_json_binary();
759        for row in rows.iter_mut() {
760            let Some(value) = row.values.get_mut(column_idx) else {
761                continue;
762            };
763            value.value_data = coerce_log_value_data(
764                value.value_data.take(),
765                target_type,
766                semantic_type,
767                request_type,
768                target_is_json_binary,
769                &schema.column_name,
770                table_name,
771            )?;
772        }
773        *schema = existing_column.schema_for_request_type(request_type);
774    }
775
776    Ok(())
777}
778
779fn coerce_log_value_data(
780    value_data: Option<ValueData>,
781    target_type: ColumnDataType,
782    _semantic_type: SemanticType,
783    request_type: ColumnDataType,
784    target_is_json_binary: bool,
785    column_name: &str,
786    table_name: &str,
787) -> Result<Option<ValueData>> {
788    let Some(value_data) = value_data else {
789        return Ok(None);
790    };
791
792    if request_type == target_type {
793        return Ok(Some(value_data));
794    }
795
796    if request_type == ColumnDataType::Binary && target_is_json_binary {
797        return Ok(Some(value_data));
798    }
799
800    if is_timestamp_type(request_type)
801        && let Some(target_unit) = timestamp_unit(target_type)
802    {
803        return align_timestamp_value(value_data, target_unit, column_name, table_name).map(Some);
804    }
805
806    // Lossless signed -> unsigned integer cast for built-in fields moving from
807    // unsigned to signed types (e.g. an existing UInt64/UInt32 log column
808    // receiving new Int64/Int32 ingest). Lets existing tables keep their
809    // unsigned columns without an `ALTER`. In the mutually-exclusive `else` of
810    // the String branch so the move stays local to non-String targets; the
811    // shared predicate keeps the supported pairs identical to the trace path
812    // (the generic String -> numeric coercions keep their pre-existing
813    // rejection on this path).
814    if target_type == ColumnDataType::String {
815        if let Ok(value_data) =
816            coerce_value_data(&Some(value_data.clone()), target_type, request_type)
817        {
818            return Ok(value_data);
819        }
820        if let Some(value_data) = stringify_scalar_value(value_data) {
821            return Ok(Some(value_data));
822        }
823    } else if is_supported_signed_to_unsigned_coercion(request_type, target_type)
824        && let Ok(Some(value_data)) =
825            coerce_value_data(&Some(value_data), target_type, request_type)
826    {
827        return Ok(Some(value_data));
828    }
829
830    InvalidParameterSnafu {
831        reason: format!(
832            "failed to align log column '{}' in table '{}' from {:?} to {:?}",
833            column_name, table_name, request_type, target_type
834        ),
835    }
836    .fail()
837}
838
839fn stringify_scalar_value(value_data: ValueData) -> Option<ValueData> {
840    let value = match value_data {
841        ValueData::StringValue(value) => value,
842        ValueData::BoolValue(value) => value.to_string(),
843        ValueData::I8Value(value) => value.to_string(),
844        ValueData::I16Value(value) => value.to_string(),
845        ValueData::I32Value(value) => value.to_string(),
846        ValueData::I64Value(value) => value.to_string(),
847        ValueData::U8Value(value) => value.to_string(),
848        ValueData::U16Value(value) => value.to_string(),
849        ValueData::U32Value(value) => value.to_string(),
850        ValueData::U64Value(value) => value.to_string(),
851        ValueData::F32Value(value) => value.to_string(),
852        ValueData::F64Value(value) => value.to_string(),
853        _ => return None,
854    };
855    Some(ValueData::StringValue(value))
856}
857
858fn align_timestamp_value(
859    value_data: ValueData,
860    target_unit: TimeUnit,
861    column_name: &str,
862    table_name: &str,
863) -> Result<ValueData> {
864    let timestamp = match value_data {
865        ValueData::TimestampSecondValue(value) => Timestamp::new_second(value),
866        ValueData::TimestampMillisecondValue(value) => Timestamp::new_millisecond(value),
867        ValueData::TimestampMicrosecondValue(value) => Timestamp::new_microsecond(value),
868        ValueData::TimestampNanosecondValue(value) => Timestamp::new_nanosecond(value),
869        value_data => {
870            return InvalidParameterSnafu {
871                reason: format!(
872                    "failed to align log column '{}' in table '{}' from non-timestamp value {:?}",
873                    column_name, table_name, value_data
874                ),
875            }
876            .fail();
877        }
878    };
879    let timestamp = timestamp.convert_to(target_unit).ok_or_else(|| {
880        InvalidParameterSnafu {
881            reason: format!(
882                "failed to align log column '{}' in table '{}' to timestamp unit {}",
883                column_name, table_name, target_unit
884            ),
885        }
886        .build()
887    })?;
888
889    Ok(match target_unit {
890        TimeUnit::Second => ValueData::TimestampSecondValue(timestamp.value()),
891        TimeUnit::Millisecond => ValueData::TimestampMillisecondValue(timestamp.value()),
892        TimeUnit::Microsecond => ValueData::TimestampMicrosecondValue(timestamp.value()),
893        TimeUnit::Nanosecond => ValueData::TimestampNanosecondValue(timestamp.value()),
894    })
895}
896
897fn is_timestamp_type(datatype: ColumnDataType) -> bool {
898    timestamp_unit(datatype).is_some()
899}
900
901fn timestamp_unit(datatype: ColumnDataType) -> Option<TimeUnit> {
902    match datatype {
903        ColumnDataType::TimestampSecond => Some(TimeUnit::Second),
904        ColumnDataType::TimestampMillisecond => Some(TimeUnit::Millisecond),
905        ColumnDataType::TimestampMicrosecond => Some(TimeUnit::Microsecond),
906        ColumnDataType::TimestampNanosecond => Some(TimeUnit::Nanosecond),
907        _ => None,
908    }
909}
910
911fn parse_export_logs_service_request_to_rows(
912    request: ExportLogsServiceRequest,
913    select_info: Box<SelectInfo>,
914    existing_schema: Option<&ExistingLogSchema>,
915    table_name: &str,
916) -> Result<Rows> {
917    let mut schemas = build_otlp_logs_identity_schema();
918
919    let mut parse_ctx = ParseContext::new(select_info, existing_schema, table_name);
920    let mut rows = parse_resource(&mut parse_ctx, request.resource_logs)?;
921
922    schemas.extend(parse_ctx.select_schema.column_schemas()?);
923    align_rows_with_existing_schema(&mut schemas, &mut rows, existing_schema, table_name)?;
924
925    rows.iter_mut().for_each(|row| {
926        row.values.resize(schemas.len(), GreptimeValue::default());
927    });
928
929    Ok(Rows {
930        schema: schemas,
931        rows,
932    })
933}
934
935fn parse_resource(
936    parse_ctx: &mut ParseContext,
937    resource_logs_vec: Vec<ResourceLogs>,
938) -> Result<Vec<Row>> {
939    let total_len = resource_logs_vec
940        .iter()
941        .flat_map(|r| r.scope_logs.iter())
942        .map(|s| s.log_records.len())
943        .sum();
944
945    let mut results = Vec::with_capacity(total_len);
946
947    for r in resource_logs_vec {
948        parse_ctx.resource_attr = r
949            .resource
950            .map(|resource| key_value_to_jsonb(resource.attributes))
951            .unwrap_or(JsonbValue::Null);
952
953        parse_ctx.resource_url = r.schema_url;
954
955        parse_ctx.resource_uplift_values = extract_field_from_attr_and_combine_schema(
956            &parse_ctx.select_info,
957            &mut parse_ctx.select_schema,
958            &parse_ctx.resource_attr,
959            parse_ctx.existing_schema,
960            parse_ctx.table_name,
961        )?;
962
963        let rows = parse_scope(r.scope_logs, parse_ctx)?;
964        results.extend(rows);
965    }
966    Ok(results)
967}
968
969struct ParseContext<'a> {
970    // input selected keys
971    select_info: Box<SelectInfo>,
972    existing_schema: Option<&'a ExistingLogSchema>,
973    table_name: &'a str,
974    // schema infos for selected keys from resource/scope/log for current request
975    // since the value override from bottom to top, the max capacity is the length of the keys
976    select_schema: SchemaInfo,
977
978    // extracted and uplifted values using select keys
979    resource_uplift_values: Vec<GreptimeValue>,
980    scope_uplift_values: Vec<GreptimeValue>,
981
982    // passdown values
983    resource_url: String,
984    resource_attr: JsonbValue<'a>,
985    scope_name: Option<String>,
986    scope_version: Option<String>,
987    scope_url: String,
988    scope_attrs: JsonbValue<'a>,
989}
990
991impl<'a> ParseContext<'a> {
992    pub fn new(
993        select_info: Box<SelectInfo>,
994        existing_schema: Option<&'a ExistingLogSchema>,
995        table_name: &'a str,
996    ) -> ParseContext<'a> {
997        let len = select_info.keys.len();
998        ParseContext {
999            select_info,
1000            existing_schema,
1001            table_name,
1002            select_schema: SchemaInfo::with_capacity(len),
1003            resource_uplift_values: vec![],
1004            scope_uplift_values: vec![],
1005            resource_url: String::new(),
1006            resource_attr: JsonbValue::Null,
1007            scope_name: None,
1008            scope_version: None,
1009            scope_url: String::new(),
1010            scope_attrs: JsonbValue::Null,
1011        }
1012    }
1013}
1014
1015fn parse_scope(scopes_log_vec: Vec<ScopeLogs>, parse_ctx: &mut ParseContext) -> Result<Vec<Row>> {
1016    let len = scopes_log_vec.iter().map(|l| l.log_records.len()).sum();
1017    let mut results = Vec::with_capacity(len);
1018
1019    for scope_logs in scopes_log_vec {
1020        let (scope_attrs, scope_version, scope_name) = scope_to_jsonb(scope_logs.scope);
1021        parse_ctx.scope_name = scope_name;
1022        parse_ctx.scope_version = scope_version;
1023        parse_ctx.scope_url = scope_logs.schema_url;
1024        parse_ctx.scope_attrs = scope_attrs;
1025
1026        parse_ctx.scope_uplift_values = extract_field_from_attr_and_combine_schema(
1027            &parse_ctx.select_info,
1028            &mut parse_ctx.select_schema,
1029            &parse_ctx.scope_attrs,
1030            parse_ctx.existing_schema,
1031            parse_ctx.table_name,
1032        )?;
1033
1034        let rows = parse_log(scope_logs.log_records, parse_ctx)?;
1035        results.extend(rows);
1036    }
1037    Ok(results)
1038}
1039
1040fn parse_log(log_records: Vec<LogRecord>, parse_ctx: &mut ParseContext) -> Result<Vec<Row>> {
1041    let mut result = Vec::with_capacity(log_records.len());
1042
1043    for log in log_records {
1044        let (mut row, log_attr) = build_otlp_build_in_row(log, parse_ctx);
1045
1046        let log_values = extract_field_from_attr_and_combine_schema(
1047            &parse_ctx.select_info,
1048            &mut parse_ctx.select_schema,
1049            &log_attr,
1050            parse_ctx.existing_schema,
1051            parse_ctx.table_name,
1052        )?;
1053
1054        let extracted_values = merge_values(
1055            log_values,
1056            &parse_ctx.scope_uplift_values,
1057            &parse_ctx.resource_uplift_values,
1058        );
1059
1060        row.values.extend(extracted_values);
1061
1062        result.push(row);
1063    }
1064    Ok(result)
1065}
1066
1067fn merge_values(
1068    log: Vec<GreptimeValue>,
1069    scope: &[GreptimeValue],
1070    resource: &[GreptimeValue],
1071) -> Vec<GreptimeValue> {
1072    log.into_iter()
1073        .enumerate()
1074        .map(|(i, value)| GreptimeValue {
1075            value_data: value
1076                .value_data
1077                .or_else(|| scope.get(i).and_then(|x| x.value_data.clone()))
1078                .or_else(|| resource.get(i).and_then(|x| x.value_data.clone())),
1079        })
1080        .collect()
1081}
1082
1083/// transform otlp logs request to pipeline value
1084/// https://opentelemetry.io/docs/concepts/signals/logs/
1085fn parse_export_logs_service_request(request: ExportLogsServiceRequest) -> Vec<VrlValue> {
1086    let mut result = Vec::new();
1087    for r in request.resource_logs {
1088        let resource_attr = r
1089            .resource
1090            .map(|x| VrlValue::Object(key_value_to_map(x.attributes)))
1091            .unwrap_or(VrlValue::Null);
1092        let resource_schema_url = VrlValue::Bytes(r.schema_url.into());
1093        for scope_logs in r.scope_logs {
1094            let (scope_attrs, scope_version, scope_name) =
1095                scope_to_pipeline_value(scope_logs.scope);
1096            let scope_schema_url = VrlValue::Bytes(scope_logs.schema_url.into());
1097            for log in scope_logs.log_records {
1098                let value = log_to_pipeline_value(
1099                    log,
1100                    resource_schema_url.clone(),
1101                    resource_attr.clone(),
1102                    scope_schema_url.clone(),
1103                    scope_name.clone(),
1104                    scope_version.clone(),
1105                    scope_attrs.clone(),
1106                );
1107                result.push(value);
1108            }
1109        }
1110    }
1111    result
1112}
1113
1114// convert AnyValue to pipeline value
1115fn any_value_to_vrl_value(value: any_value::Value) -> VrlValue {
1116    match value {
1117        any_value::Value::StringValue(s) => VrlValue::Bytes(s.into()),
1118        any_value::Value::IntValue(i) => VrlValue::Integer(i),
1119        any_value::Value::DoubleValue(d) => VrlValue::Float(NotNan::new(d).unwrap()),
1120        any_value::Value::BoolValue(b) => VrlValue::Boolean(b),
1121        any_value::Value::ArrayValue(array_value) => {
1122            let values = array_value
1123                .values
1124                .into_iter()
1125                .filter_map(|v| v.value.map(any_value_to_vrl_value))
1126                .collect();
1127            VrlValue::Array(values)
1128        }
1129        any_value::Value::KvlistValue(key_value_list) => {
1130            VrlValue::Object(key_value_to_map(key_value_list.values))
1131        }
1132        any_value::Value::BytesValue(items) => VrlValue::Bytes(Bytes::from(items)),
1133        // `StringValueStrindex` references the Profiling signal's
1134        // `ProfilesDictionary.string_table`, which is unavailable to logs/traces.
1135        // Per the OTLP spec, non-Profiling receivers must treat it as a non-fatal
1136        // issue and process the value as if it were absent.
1137        any_value::Value::StringValueStrindex(_) => {
1138            warn!(
1139                "encountered a profiling-only `StringValueStrindex` value in a non-profiling signal; ignoring"
1140            );
1141            VrlValue::Null
1142        }
1143    }
1144}
1145
1146// convert otlp keyValue vec to map
1147fn key_value_to_map(key_values: Vec<KeyValue>) -> BTreeMap<KeyString, VrlValue> {
1148    let mut map = BTreeMap::new();
1149    for kv in key_values {
1150        let value = match kv.value {
1151            Some(value) => match value.value {
1152                Some(value) => any_value_to_vrl_value(value),
1153                None => VrlValue::Null,
1154            },
1155            None => VrlValue::Null,
1156        };
1157        map.insert(kv.key.into(), value);
1158    }
1159    map
1160}
1161
1162fn log_body_to_string(body: &AnyValue) -> String {
1163    let otlp_value = OtlpAnyValue::from(body);
1164    otlp_value.to_string()
1165}
1166
1167#[cfg(test)]
1168mod tests {
1169    use datatypes::prelude::ConcreteDataType;
1170    use datatypes::schema::ColumnSchema as DatatypesColumnSchema;
1171    use opentelemetry_proto::tonic::common::v1::any_value::Value as OtlpValue;
1172
1173    use super::*;
1174
1175    fn time_column(datatype: ConcreteDataType) -> DatatypesColumnSchema {
1176        DatatypesColumnSchema::new("timestamp", datatype, false).with_time_index(true)
1177    }
1178
1179    fn column(name: &str, datatype: ConcreteDataType) -> DatatypesColumnSchema {
1180        DatatypesColumnSchema::new(name, datatype, true)
1181    }
1182
1183    fn existing_schema(
1184        columns: Vec<DatatypesColumnSchema>,
1185        primary_key_indices: &[usize],
1186    ) -> ExistingLogSchema {
1187        ExistingLogSchema::try_from_schema_parts(&columns, primary_key_indices).unwrap()
1188    }
1189
1190    fn existing_uint32_trace_flags_schema() -> ExistingLogSchema {
1191        existing_schema(
1192            vec![
1193                time_column(ConcreteDataType::timestamp_nanosecond_datatype()),
1194                column("trace_flags", ConcreteDataType::uint32_datatype()),
1195            ],
1196            &[],
1197        )
1198    }
1199
1200    fn kv(key: &str, value: OtlpValue) -> KeyValue {
1201        KeyValue {
1202            key: key.to_string(),
1203            value: Some(AnyValue { value: Some(value) }),
1204            ..Default::default()
1205        }
1206    }
1207
1208    fn request_with_log_attrs(attrs: Vec<KeyValue>) -> ExportLogsServiceRequest {
1209        request_with_log_attrs_and_flags(attrs, 0)
1210    }
1211
1212    fn request_with_log_attrs_and_flags(
1213        attrs: Vec<KeyValue>,
1214        flags: u32,
1215    ) -> ExportLogsServiceRequest {
1216        ExportLogsServiceRequest {
1217            resource_logs: vec![ResourceLogs {
1218                scope_logs: vec![ScopeLogs {
1219                    log_records: vec![LogRecord {
1220                        time_unix_nano: 1_234_000_000,
1221                        trace_id: vec![1; 16],
1222                        flags,
1223                        attributes: attrs,
1224                        ..Default::default()
1225                    }],
1226                    ..Default::default()
1227                }],
1228                ..Default::default()
1229            }],
1230        }
1231    }
1232
1233    fn parse_with_select(
1234        request: ExportLogsServiceRequest,
1235        select: &str,
1236        existing_schema: Option<&ExistingLogSchema>,
1237    ) -> Result<Rows> {
1238        parse_export_logs_service_request_to_rows(
1239            request,
1240            Box::new(SelectInfo::from(select.to_string())),
1241            existing_schema,
1242            "test_logs",
1243        )
1244    }
1245
1246    fn column_index(rows: &Rows, name: &str) -> usize {
1247        rows.schema
1248            .iter()
1249            .position(|schema| schema.column_name == name)
1250            .unwrap()
1251    }
1252
1253    #[test]
1254    fn test_no_existing_table_preserves_direct_schema() {
1255        let rows = parse_with_select(request_with_log_attrs(vec![]), "", None).unwrap();
1256
1257        assert_eq!(rows.schema[0].column_name, "timestamp");
1258        assert_eq!(
1259            rows.schema[0].datatype,
1260            ColumnDataType::TimestampNanosecond as i32
1261        );
1262        assert_eq!(rows.schema[0].semantic_type, SemanticType::Timestamp as i32);
1263        let scope_name_idx = column_index(&rows, "scope_name");
1264        assert_eq!(
1265            rows.schema[scope_name_idx].semantic_type,
1266            SemanticType::Tag as i32
1267        );
1268    }
1269
1270    #[test]
1271    fn test_fresh_table_trace_flags_is_int32() {
1272        // Phase 1 of the unsigned -> signed transition: a fresh log table now
1273        // creates `trace_flags` as Int32. Existing tables keep UInt32 (covered
1274        // by the coercion test above); new tables start signed.
1275        let rows = parse_with_select(request_with_log_attrs(vec![]), "", None).unwrap();
1276        let idx = column_index(&rows, "trace_flags");
1277
1278        assert_eq!(rows.schema[idx].datatype, ColumnDataType::Int32 as i32);
1279        assert_eq!(
1280            rows.rows[0].values[idx].value_data,
1281            Some(ValueData::I32Value(0))
1282        );
1283    }
1284
1285    #[test]
1286    fn test_existing_uint32_trace_flags_keeps_type_and_coerces_int32_request() {
1287        // An existing UInt32 `trace_flags` column keeps its type when new
1288        // signed (Int32) ingest arrives, without an `ALTER`.
1289        let existing = existing_uint32_trace_flags_schema();
1290
1291        let rows = parse_with_select(request_with_log_attrs(vec![]), "", Some(&existing)).unwrap();
1292        let idx = column_index(&rows, "trace_flags");
1293
1294        assert_eq!(rows.schema[idx].datatype, ColumnDataType::Uint32 as i32);
1295        assert_eq!(
1296            rows.rows[0].values[idx].value_data,
1297            Some(ValueData::U32Value(0))
1298        );
1299    }
1300
1301    #[test]
1302    fn test_existing_uint32_trace_flags_preserves_nonzero_flags_value() {
1303        // A non-zero trace_flags bit pattern (e.g. the W3C sampled flag as sent
1304        // by common encoders) round-trips exactly through the Int32 -> UInt32
1305        // coercion into an existing unsigned column. Guards that the cast is
1306        // bit-preserving for realistic values, not just the default 0.
1307        let existing = existing_uint32_trace_flags_schema();
1308
1309        let rows = parse_with_select(
1310            request_with_log_attrs_and_flags(vec![], 256),
1311            "",
1312            Some(&existing),
1313        )
1314        .unwrap();
1315        let idx = column_index(&rows, "trace_flags");
1316
1317        assert_eq!(rows.schema[idx].datatype, ColumnDataType::Uint32 as i32);
1318        assert_eq!(
1319            rows.rows[0].values[idx].value_data,
1320            Some(ValueData::U32Value(256))
1321        );
1322    }
1323
1324    #[test]
1325    fn test_existing_uint32_trace_flags_preserves_high_bit_flags_value() {
1326        // The full u32 range round-trips: a value with the sign bit set proves
1327        // the Int32 -> UInt32 coercion is bit-exact (`as i32` then `as u32`
1328        // preserve the pattern), so no flags value is corrupted on the
1329        // unsigned -> signed transition.
1330        let existing = existing_uint32_trace_flags_schema();
1331
1332        let rows = parse_with_select(
1333            request_with_log_attrs_and_flags(vec![], 0x8000_0000),
1334            "",
1335            Some(&existing),
1336        )
1337        .unwrap();
1338        let idx = column_index(&rows, "trace_flags");
1339
1340        assert_eq!(rows.schema[idx].datatype, ColumnDataType::Uint32 as i32);
1341        assert_eq!(
1342            rows.rows[0].values[idx].value_data,
1343            Some(ValueData::U32Value(0x8000_0000))
1344        );
1345    }
1346
1347    #[test]
1348    fn test_existing_primary_key_updates_builtin_column_semantic_type() {
1349        let existing = existing_schema(
1350            vec![
1351                time_column(ConcreteDataType::timestamp_nanosecond_datatype()),
1352                column("trace_id", ConcreteDataType::string_datatype()),
1353            ],
1354            &[1],
1355        );
1356
1357        let rows = parse_with_select(request_with_log_attrs(vec![]), "", Some(&existing)).unwrap();
1358        let trace_id_idx = column_index(&rows, "trace_id");
1359
1360        assert_eq!(
1361            rows.schema[trace_id_idx].semantic_type,
1362            SemanticType::Tag as i32
1363        );
1364    }
1365
1366    #[test]
1367    fn test_existing_string_primary_key_stringifies_selected_scalar_values() {
1368        let existing = existing_schema(
1369            vec![
1370                time_column(ConcreteDataType::timestamp_nanosecond_datatype()),
1371                column("host", ConcreteDataType::string_datatype()),
1372            ],
1373            &[1],
1374        );
1375        let rows = parse_with_select(
1376            request_with_log_attrs(vec![kv("host", OtlpValue::IntValue(42))]),
1377            "host",
1378            Some(&existing),
1379        )
1380        .unwrap();
1381        let host_idx = column_index(&rows, "host");
1382
1383        assert_eq!(
1384            rows.schema[host_idx].datatype,
1385            ColumnDataType::String as i32
1386        );
1387        assert_eq!(
1388            rows.schema[host_idx].semantic_type,
1389            SemanticType::Tag as i32
1390        );
1391        assert_eq!(
1392            rows.rows[0].values[host_idx].value_data,
1393            Some(ValueData::StringValue("42".to_string()))
1394        );
1395    }
1396
1397    #[test]
1398    fn test_existing_string_field_stringifies_selected_scalar_values() {
1399        let existing = existing_schema(
1400            vec![
1401                time_column(ConcreteDataType::timestamp_nanosecond_datatype()),
1402                column("host", ConcreteDataType::string_datatype()),
1403            ],
1404            &[],
1405        );
1406        let rows = parse_with_select(
1407            request_with_log_attrs(vec![kv("host", OtlpValue::IntValue(42))]),
1408            "host",
1409            Some(&existing),
1410        )
1411        .unwrap();
1412        let host_idx = column_index(&rows, "host");
1413
1414        assert_eq!(
1415            rows.schema[host_idx].datatype,
1416            ColumnDataType::String as i32
1417        );
1418        assert_eq!(
1419            rows.schema[host_idx].semantic_type,
1420            SemanticType::Field as i32
1421        );
1422        assert_eq!(
1423            rows.rows[0].values[host_idx].value_data,
1424            Some(ValueData::StringValue("42".to_string()))
1425        );
1426    }
1427
1428    #[test]
1429    fn test_existing_non_string_primary_key_rejects_incompatible_selected_value() {
1430        let existing = existing_schema(
1431            vec![
1432                time_column(ConcreteDataType::timestamp_nanosecond_datatype()),
1433                column("host", ConcreteDataType::int64_datatype()),
1434            ],
1435            &[1],
1436        );
1437        let err = 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_err();
1446
1447        assert!(
1448            err.to_string()
1449                .contains("failed to align log column 'host'")
1450        );
1451    }
1452
1453    #[test]
1454    fn test_existing_timestamp_unit_is_respected() {
1455        let existing = existing_schema(
1456            vec![time_column(
1457                ConcreteDataType::timestamp_millisecond_datatype(),
1458            )],
1459            &[],
1460        );
1461        let rows = parse_with_select(request_with_log_attrs(vec![]), "", Some(&existing)).unwrap();
1462
1463        assert_eq!(
1464            rows.schema[0].datatype,
1465            ColumnDataType::TimestampMillisecond as i32
1466        );
1467        assert_eq!(
1468            rows.rows[0].values[0].value_data,
1469            Some(ValueData::TimestampMillisecondValue(1234))
1470        );
1471    }
1472
1473    #[test]
1474    fn test_missing_existing_primary_key_is_not_generated() {
1475        let existing = existing_schema(
1476            vec![
1477                time_column(ConcreteDataType::timestamp_nanosecond_datatype()),
1478                column("host", ConcreteDataType::string_datatype()),
1479            ],
1480            &[1],
1481        );
1482        let rows = parse_with_select(request_with_log_attrs(vec![]), "", Some(&existing)).unwrap();
1483
1484        assert!(
1485            !rows
1486                .schema
1487                .iter()
1488                .any(|schema| schema.column_name == "host")
1489        );
1490    }
1491
1492    #[test]
1493    fn test_existing_table_keeps_new_generated_columns_as_fields() {
1494        let existing = existing_schema(
1495            vec![
1496                time_column(ConcreteDataType::timestamp_nanosecond_datatype()),
1497                column("trace_id", ConcreteDataType::string_datatype()),
1498            ],
1499            &[1],
1500        );
1501        let rows = parse_with_select(
1502            request_with_log_attrs(vec![kv(
1503                "host",
1504                OtlpValue::StringValue("node-a".to_string()),
1505            )]),
1506            "host",
1507            Some(&existing),
1508        )
1509        .unwrap();
1510        let host_idx = column_index(&rows, "host");
1511        let scope_name_idx = column_index(&rows, "scope_name");
1512
1513        assert_eq!(
1514            rows.schema[host_idx].semantic_type,
1515            SemanticType::Field as i32
1516        );
1517        assert_eq!(
1518            rows.schema[scope_name_idx].semantic_type,
1519            SemanticType::Field as i32
1520        );
1521    }
1522
1523    #[test]
1524    fn test_existing_uint64_column_keeps_type_and_coerces_int64_request() {
1525        // An existing UInt64 column keeps its type when new signed (Int64)
1526        // ingest arrives, without an `ALTER`. The built-in models move to
1527        // signed while existing unsigned tables stay byte-for-byte unchanged.
1528        let existing = existing_schema(
1529            vec![
1530                time_column(ConcreteDataType::timestamp_nanosecond_datatype()),
1531                column("counter", ConcreteDataType::uint64_datatype()),
1532            ],
1533            &[],
1534        );
1535
1536        let rows = parse_with_select(
1537            request_with_log_attrs(vec![kv("counter", OtlpValue::IntValue(42))]),
1538            "counter",
1539            Some(&existing),
1540        )
1541        .unwrap();
1542        let idx = column_index(&rows, "counter");
1543
1544        assert_eq!(rows.schema[idx].datatype, ColumnDataType::Uint64 as i32);
1545        assert_eq!(
1546            rows.rows[0].values[idx].value_data,
1547            Some(ValueData::U64Value(42))
1548        );
1549    }
1550
1551    #[test]
1552    fn test_jsonb_extended_log_values_rejected() {
1553        for (value, expected_type) in [
1554            (
1555                JsonbValue::Number(JsonbNumber::Decimal64(jsonb::Decimal64 {
1556                    value: 123,
1557                    scale: 2,
1558                })),
1559                "DECIMAL",
1560            ),
1561            (JsonbValue::Binary(&[1, 2, 3]), "Binary"),
1562            (JsonbValue::Date(jsonb::Date { value: 0 }), "Date"),
1563            (
1564                JsonbValue::Timestamp(jsonb::Timestamp { value: 0 }),
1565                "Timestamp",
1566            ),
1567            (
1568                JsonbValue::TimestampTz(jsonb::TimestampTz {
1569                    value: 0,
1570                    offset: 0,
1571                }),
1572                "TimestampTz",
1573            ),
1574            (
1575                JsonbValue::Interval(jsonb::Interval {
1576                    months: 0,
1577                    days: 0,
1578                    micros: 0,
1579                }),
1580                "Interval",
1581            ),
1582        ] {
1583            let schema_error =
1584                decide_column_schema_and_convert_value("attr", value.clone(), None, "logs")
1585                    .expect_err("extended JSONB type must be rejected");
1586            let value_error = jsonb_value_to_log_value_data("attr", value, true)
1587                .expect_err("extended JSONB type must be rejected");
1588            for error in [schema_error, value_error] {
1589                assert!(matches!(error,
1590                    Error::UnsupportedJsonDataTypeForTag { key, ty, .. }
1591                        if key == "attr" && ty == expected_type
1592                ));
1593            }
1594        }
1595    }
1596
1597    #[test]
1598    fn test_jsonb_uint64_exceeding_i64_range_rejected() {
1599        // A JSON number that only fits in u64 must be rejected, not wrapped:
1600        // storing it as a negative i64 would silently corrupt the value. The
1601        // unsigned -> signed transition is only lossless for values that fit
1602        // the signed range.
1603        let err = jsonb_value_to_log_value_data(
1604            "counter",
1605            JsonbValue::Number(JsonbNumber::UInt64(u64::MAX)),
1606            false,
1607        )
1608        .unwrap_err();
1609
1610        assert!(
1611            err.to_string()
1612                .contains("exceeds the i64 range supported by built-in log columns")
1613        );
1614    }
1615
1616    #[test]
1617    fn test_existing_int64_column_rejects_numeric_string_value() {
1618        // Regression guard for the signed -> unsigned transition scope: even a
1619        // parseable numeric string must keep its pre-existing rejection when
1620        // targeting a non-String column. Only the signed -> unsigned integer
1621        // pairs gained coercion on this path, not the generic String ->
1622        // numeric ones.
1623        let existing = existing_schema(
1624            vec![
1625                time_column(ConcreteDataType::timestamp_nanosecond_datatype()),
1626                column("counter", ConcreteDataType::int64_datatype()),
1627            ],
1628            &[],
1629        );
1630
1631        let err = parse_with_select(
1632            request_with_log_attrs(vec![kv(
1633                "counter",
1634                OtlpValue::StringValue("42".to_string()),
1635            )]),
1636            "counter",
1637            Some(&existing),
1638        )
1639        .unwrap_err();
1640
1641        assert!(
1642            err.to_string()
1643                .contains("failed to align log column 'counter'")
1644        );
1645    }
1646}