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, timestamp_unit};
19use api::v1::column_data_type_extension::TypeExt;
20use api::v1::column_def::{options_from_column_schema, options_from_skipping};
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_catalog::consts::SERVICE_NAME_COLUMN;
28use common_telemetry::warn;
29use common_time::Timestamp;
30use common_time::timestamp::TimeUnit;
31use datatypes::schema::SkippingIndexOptions;
32use jsonb::{Number as JsonbNumber, Value as JsonbValue};
33use opentelemetry_proto::tonic::collector::logs::v1::ExportLogsServiceRequest;
34use opentelemetry_proto::tonic::common::v1::{AnyValue, InstrumentationScope, KeyValue, any_value};
35use opentelemetry_proto::tonic::logs::v1::{LogRecord, ResourceLogs, ScopeLogs};
36use pipeline::{
37    ContextReq, GreptimePipelineParams, PipelineContext, PipelineWay, SchemaInfo, SelectInfo,
38};
39use session::context::QueryContextRef;
40use snafu::ensure;
41use table::requests::TABLE_DATA_MODEL_LOG_V1;
42use vrl::prelude::NotNan;
43use vrl::value::{KeyString, Value as VrlValue};
44
45use crate::error::{
46    Error, IncompatibleSchemaSnafu, InvalidParameterSnafu, NotSupportedSnafu, Result,
47    UnsupportedJsonDataTypeForTagSnafu,
48};
49use crate::http::event::PipelineIngestRequest;
50use crate::otlp::coerce::{coerce_value_data, is_supported_signed_to_unsigned_coercion};
51use crate::otlp::trace::attributes::OtlpAnyValue;
52use crate::otlp::utils::{bytes_to_hex_string, key_value_to_jsonb, resource_service_name};
53use crate::pipeline::run_pipeline;
54use crate::query_handler::PipelineHandlerRef;
55
56pub const LOG_TABLE_NAME: &str = "opentelemetry_logs";
57const EVENT_NAME_COLUMN: &str = "event_name";
58const OBSERVED_TIMESTAMP_COLUMN: &str = "observed_timestamp";
59
60/// Convert OpenTelemetry metrics to GreptimeDB insert requests
61///
62/// See
63/// <https://github.com/open-telemetry/opentelemetry-proto/blob/main/opentelemetry/proto/metrics/v1/metrics.proto>
64/// for data structure of OTLP metrics.
65///
66/// Returns `InsertRequests` and total number of rows to ingest
67pub async fn to_grpc_insert_requests(
68    request: ExportLogsServiceRequest,
69    pipeline: PipelineWay,
70    pipeline_params: GreptimePipelineParams,
71    table_name: String,
72    query_ctx: &QueryContextRef,
73    pipeline_handler: PipelineHandlerRef,
74) -> Result<ContextReq> {
75    match pipeline {
76        PipelineWay::OtlpLogDirect(select_info) => {
77            let table = pipeline_handler
78                .get_table(&table_name, query_ctx)
79                .await
80                .map_err(Error::from)?;
81            // New tables get the v1 schema. Existing tables without the v1 marker keep
82            // the legacy schema, so upgrading never alters their columns or keys.
83            let v1_schema = table.as_deref().is_none_or(|table| {
84                table.table_info().meta.options.data_model() == Some(TABLE_DATA_MODEL_LOG_V1)
85            });
86            let existing_schema = table
87                .as_deref()
88                .map(ExistingLogSchema::try_from_table)
89                .transpose()?;
90            let rows = parse_export_logs_service_request_to_rows(
91                request,
92                select_info,
93                v1_schema,
94                existing_schema.as_ref(),
95                &table_name,
96            )?;
97            let insert_request = RowInsertRequest {
98                rows: Some(rows),
99                table_name,
100            };
101
102            Ok(ContextReq::default_opt_with_reqs(vec![insert_request]))
103        }
104        PipelineWay::Pipeline(pipeline_def) => {
105            let array = parse_export_logs_service_request(request);
106
107            let pipeline_ctx =
108                PipelineContext::new(&pipeline_def, &pipeline_params, query_ctx.channel());
109            run_pipeline(
110                &pipeline_handler,
111                &pipeline_ctx,
112                PipelineIngestRequest {
113                    table: table_name,
114                    values: array,
115                },
116                query_ctx,
117                true,
118            )
119            .await
120        }
121        _ => NotSupportedSnafu {
122            feat: "Unsupported pipeline for logs",
123        }
124        .fail(),
125    }
126}
127
128fn scope_to_pipeline_value(scope: Option<InstrumentationScope>) -> (VrlValue, VrlValue, VrlValue) {
129    scope
130        .map(|x| {
131            (
132                VrlValue::Object(key_value_to_map(x.attributes)),
133                VrlValue::Bytes(x.version.into()),
134                VrlValue::Bytes(x.name.into()),
135            )
136        })
137        .unwrap_or((VrlValue::Null, VrlValue::Null, VrlValue::Null))
138}
139
140fn scope_to_jsonb(
141    scope: Option<InstrumentationScope>,
142) -> (JsonbValue<'static>, Option<String>, Option<String>) {
143    scope
144        .map(|x| {
145            (
146                key_value_to_jsonb(x.attributes),
147                Some(x.version),
148                Some(x.name),
149            )
150        })
151        .unwrap_or((JsonbValue::Null, None, None))
152}
153
154fn log_to_pipeline_value(
155    log: LogRecord,
156    resource_schema_url: VrlValue,
157    resource_attr: VrlValue,
158    scope_schema_url: VrlValue,
159    scope_name: VrlValue,
160    scope_version: VrlValue,
161    scope_attrs: VrlValue,
162) -> VrlValue {
163    let log_attrs = VrlValue::Object(key_value_to_map(log.attributes));
164    let mut map = BTreeMap::new();
165    map.insert(
166        "Timestamp".into(),
167        VrlValue::Integer(log.time_unix_nano as i64),
168    );
169    map.insert(
170        "ObservedTimestamp".into(),
171        VrlValue::Integer(log.observed_time_unix_nano as i64),
172    );
173
174    // need to be convert to string
175    map.insert(
176        "TraceId".into(),
177        VrlValue::Bytes(bytes_to_hex_string(&log.trace_id).into()),
178    );
179    map.insert(
180        "SpanId".into(),
181        VrlValue::Bytes(bytes_to_hex_string(&log.span_id).into()),
182    );
183    map.insert("TraceFlags".into(), VrlValue::Integer(log.flags as i64));
184    map.insert(
185        "SeverityText".into(),
186        VrlValue::Bytes(log.severity_text.into()),
187    );
188    map.insert(
189        "SeverityNumber".into(),
190        VrlValue::Integer(log.severity_number as i64),
191    );
192    // need to be convert to string
193    map.insert(
194        "Body".into(),
195        log.body
196            .as_ref()
197            .map(|x| VrlValue::Bytes(log_body_to_string(x).into()))
198            .unwrap_or(VrlValue::Null),
199    );
200    map.insert("ResourceSchemaUrl".into(), resource_schema_url);
201
202    map.insert("ResourceAttributes".into(), resource_attr);
203    map.insert("ScopeSchemaUrl".into(), scope_schema_url);
204    map.insert("ScopeName".into(), scope_name);
205    map.insert("ScopeVersion".into(), scope_version);
206    map.insert("ScopeAttributes".into(), scope_attrs);
207    map.insert("LogAttributes".into(), log_attrs);
208    VrlValue::Object(map)
209}
210
211/// Built-in columns of a direct OTLP log row, in row order.
212///
213/// The v1 schema puts `service_name` before `scope_name` because auto-create
214/// orders the primary key by column order.
215fn build_otlp_logs_identity_schema(v1_schema: bool) -> Result<Vec<ColumnSchema>> {
216    let trace_id_options = if v1_schema {
217        options_from_skipping(&SkippingIndexOptions::default())?
218    } else {
219        None
220    };
221    let mut columns = vec![
222        (
223            "timestamp",
224            ColumnDataType::TimestampNanosecond,
225            SemanticType::Timestamp,
226            None,
227            None,
228        ),
229        (
230            "trace_id",
231            ColumnDataType::String,
232            SemanticType::Field,
233            None,
234            trace_id_options,
235        ),
236        (
237            "span_id",
238            ColumnDataType::String,
239            SemanticType::Field,
240            None,
241            None,
242        ),
243        (
244            "severity_text",
245            ColumnDataType::String,
246            SemanticType::Field,
247            None,
248            None,
249        ),
250        (
251            "severity_number",
252            ColumnDataType::Int32,
253            SemanticType::Field,
254            None,
255            None,
256        ),
257        (
258            "body",
259            ColumnDataType::String,
260            SemanticType::Field,
261            None,
262            Some(ColumnOptions {
263                options: std::collections::HashMap::from([(
264                    "fulltext".to_string(),
265                    r#"{"enable":true}"#.to_string(),
266                )]),
267            }),
268        ),
269        (
270            "log_attributes",
271            ColumnDataType::Binary,
272            SemanticType::Field,
273            Some(ColumnDataTypeExtension {
274                type_ext: Some(TypeExt::JsonType(JsonTypeExtension::JsonBinary.into())),
275            }),
276            None,
277        ),
278        (
279            "trace_flags",
280            ColumnDataType::Int32,
281            SemanticType::Field,
282            None,
283            None,
284        ),
285    ];
286    if v1_schema {
287        columns.push((
288            SERVICE_NAME_COLUMN,
289            ColumnDataType::String,
290            SemanticType::Tag,
291            None,
292            None,
293        ));
294    }
295    columns.extend([
296        (
297            "scope_name",
298            ColumnDataType::String,
299            SemanticType::Tag,
300            None,
301            None,
302        ),
303        (
304            "scope_version",
305            ColumnDataType::String,
306            SemanticType::Field,
307            None,
308            None,
309        ),
310        (
311            "scope_attributes",
312            ColumnDataType::Binary,
313            SemanticType::Field,
314            Some(ColumnDataTypeExtension {
315                type_ext: Some(TypeExt::JsonType(JsonTypeExtension::JsonBinary.into())),
316            }),
317            None,
318        ),
319        (
320            "scope_schema_url",
321            ColumnDataType::String,
322            SemanticType::Field,
323            None,
324            None,
325        ),
326        (
327            "resource_attributes",
328            ColumnDataType::Binary,
329            SemanticType::Field,
330            Some(ColumnDataTypeExtension {
331                type_ext: Some(TypeExt::JsonType(JsonTypeExtension::JsonBinary.into())),
332            }),
333            None,
334        ),
335        (
336            "resource_schema_url",
337            ColumnDataType::String,
338            SemanticType::Field,
339            None,
340            None,
341        ),
342    ]);
343    if v1_schema {
344        columns.extend([
345            (
346                EVENT_NAME_COLUMN,
347                ColumnDataType::String,
348                SemanticType::Field,
349                None,
350                None,
351            ),
352            (
353                OBSERVED_TIMESTAMP_COLUMN,
354                ColumnDataType::TimestampNanosecond,
355                SemanticType::Field,
356                None,
357                None,
358            ),
359        ]);
360    }
361
362    Ok(columns
363        .into_iter()
364        .map(
365            |(field_name, column_type, semantic_type, datatype_extension, options)| ColumnSchema {
366                column_name: field_name.to_string(),
367                datatype: column_type as i32,
368                semantic_type: semantic_type as i32,
369                datatype_extension,
370                options,
371            },
372        )
373        .collect())
374}
375
376#[derive(Clone)]
377struct ExistingLogColumn {
378    schema: ColumnSchema,
379    datatype: ColumnDataType,
380}
381
382impl ExistingLogColumn {
383    fn schema_for_request_type(&self, request_type: ColumnDataType) -> ColumnSchema {
384        let mut schema = self.schema.clone();
385        if request_type == ColumnDataType::Binary && self.is_json_binary() {
386            schema.datatype = ColumnDataType::Binary as i32;
387        }
388        schema
389    }
390
391    fn is_json_binary(&self) -> bool {
392        self.datatype == ColumnDataType::Json
393            && matches!(
394                self.schema
395                    .datatype_extension
396                    .as_ref()
397                    .and_then(|datatype_extension| datatype_extension.type_ext.as_ref()),
398                Some(TypeExt::JsonType(json_type))
399                     if *json_type == JsonTypeExtension::JsonBinary as i32
400            )
401    }
402}
403
404#[derive(Default)]
405struct ExistingLogSchema {
406    columns: HashMap<String, ExistingLogColumn>,
407}
408
409impl ExistingLogSchema {
410    fn try_from_table(table: &table::Table) -> Result<Self> {
411        let table_info = table.table_info();
412        Self::try_from_schema_parts(
413            table.schema_ref().column_schemas(),
414            &table_info.meta.primary_key_indices,
415        )
416    }
417
418    fn try_from_schema_parts(
419        column_schemas: &[datatypes::schema::ColumnSchema],
420        primary_key_indices: &[usize],
421    ) -> Result<Self> {
422        let mut columns = HashMap::with_capacity(column_schemas.len());
423
424        for (index, column_schema) in column_schemas.iter().enumerate() {
425            let (datatype, datatype_extension) =
426                ColumnDataTypeWrapper::try_from(column_schema.data_type.clone())
427                    .map(|wrapper| wrapper.into_parts())
428                    .map_err(Error::from)?;
429            let semantic_type = if column_schema.is_time_index() {
430                SemanticType::Timestamp
431            } else if primary_key_indices.contains(&index) {
432                SemanticType::Tag
433            } else {
434                SemanticType::Field
435            };
436            let schema = ColumnSchema {
437                column_name: column_schema.name.clone(),
438                datatype: datatype as i32,
439                semantic_type: semantic_type as i32,
440                datatype_extension,
441                options: options_from_column_schema(column_schema),
442            };
443            columns.insert(
444                schema.column_name.clone(),
445                ExistingLogColumn { schema, datatype },
446            );
447        }
448
449        Ok(Self { columns })
450    }
451
452    fn get(&self, column_name: &str) -> Option<&ExistingLogColumn> {
453        self.columns.get(column_name)
454    }
455}
456
457fn build_otlp_build_in_row(
458    log: LogRecord,
459    log_attr: &JsonbValue,
460    service_name: Option<String>,
461    parse_ctx: &ParseContext,
462) -> Row {
463    let ts = if log.time_unix_nano != 0 {
464        log.time_unix_nano
465    } else {
466        log.observed_time_unix_nano
467    };
468
469    let mut row = vec![
470        GreptimeValue {
471            value_data: Some(ValueData::TimestampNanosecondValue(ts as i64)),
472        },
473        GreptimeValue {
474            value_data: Some(ValueData::StringValue(bytes_to_hex_string(&log.trace_id))),
475        },
476        GreptimeValue {
477            value_data: Some(ValueData::StringValue(bytes_to_hex_string(&log.span_id))),
478        },
479        GreptimeValue {
480            value_data: Some(ValueData::StringValue(log.severity_text)),
481        },
482        GreptimeValue {
483            value_data: Some(ValueData::I32Value(log.severity_number)),
484        },
485        GreptimeValue {
486            value_data: log
487                .body
488                .as_ref()
489                .map(|x| ValueData::StringValue(log_body_to_string(x))),
490        },
491        GreptimeValue {
492            value_data: Some(ValueData::BinaryValue(log_attr.to_vec())),
493        },
494        GreptimeValue {
495            value_data: Some(ValueData::I32Value(log.flags as i32)),
496        },
497    ];
498    if parse_ctx.v1_schema {
499        row.push(GreptimeValue {
500            value_data: service_name.map(ValueData::StringValue),
501        });
502    }
503    row.extend([
504        GreptimeValue {
505            value_data: parse_ctx.scope_name.clone().map(ValueData::StringValue),
506        },
507        GreptimeValue {
508            value_data: parse_ctx.scope_version.clone().map(ValueData::StringValue),
509        },
510        GreptimeValue {
511            value_data: Some(ValueData::BinaryValue(parse_ctx.scope_attrs.to_vec())),
512        },
513        GreptimeValue {
514            value_data: Some(ValueData::StringValue(parse_ctx.scope_url.clone())),
515        },
516        GreptimeValue {
517            value_data: Some(ValueData::BinaryValue(parse_ctx.resource_attr.to_vec())),
518        },
519        GreptimeValue {
520            value_data: Some(ValueData::StringValue(parse_ctx.resource_url.clone())),
521        },
522    ]);
523    if parse_ctx.v1_schema {
524        row.extend([
525            GreptimeValue {
526                value_data: (!log.event_name.is_empty())
527                    .then_some(ValueData::StringValue(log.event_name)),
528            },
529            GreptimeValue {
530                value_data: (log.observed_time_unix_nano != 0).then_some(
531                    ValueData::TimestampNanosecondValue(log.observed_time_unix_nano as i64),
532                ),
533            },
534        ]);
535    }
536    Row { values: row }
537}
538
539/// Reads a `service_name` value the request asked to extract from `attrs`, with
540/// the same key matching and type rules as other extract keys.
541fn extracted_service_name(attrs: &JsonbValue) -> Result<Option<String>> {
542    attrs
543        .get_by_name_ignore_case(SERVICE_NAME_COLUMN)
544        .cloned()
545        .map(|value| scalar_attr_to_string(SERVICE_NAME_COLUMN, value))
546        .transpose()
547        .map(Option::flatten)
548}
549
550fn scalar_attr_to_string(name: &str, value: JsonbValue) -> Result<Option<String>> {
551    Ok(jsonb_value_to_log_value_data(name, value, true)?
552        .and_then(|(value_data, _)| stringify_scalar_value(value_data))
553        .and_then(|value_data| match value_data {
554            ValueData::StringValue(value) => Some(value),
555            _ => None,
556        }))
557}
558
559fn extract_field_from_attr_and_combine_schema(
560    select_info: &SelectInfo,
561    select_schema: &mut SchemaInfo,
562    attrs: &jsonb::Value,
563    existing_schema: Option<&ExistingLogSchema>,
564    table_name: &str,
565) -> Result<Vec<GreptimeValue>> {
566    // note we use schema.len instead of select_keys.len
567    // because the len of the row value should always matches the len of the schema
568    let mut extracted_values = vec![GreptimeValue::default(); select_schema.schema.len()];
569
570    for key in select_info.keys.iter() {
571        let Some(value) = attrs.get_by_name_ignore_case(key).cloned() else {
572            continue;
573        };
574        let Some((schema, value)) =
575            decide_column_schema_and_convert_value(key, value, existing_schema, table_name)?
576        else {
577            continue;
578        };
579
580        if let Some(index) = select_schema.index.get(key) {
581            let column_schema = &select_schema.schema[*index];
582            let column_schema: ColumnSchema = column_schema.clone().try_into()?;
583            // datatype of the same column name should be the same
584            ensure!(
585                column_schema.datatype == schema.datatype,
586                IncompatibleSchemaSnafu {
587                    column_name: key,
588                    datatype: column_schema.datatype().as_str_name(),
589                    expected: column_schema.datatype,
590                    actual: schema.datatype,
591                }
592            );
593            extracted_values[*index] = value;
594        } else {
595            select_schema.schema.push(schema.into());
596            select_schema
597                .index
598                .insert(key.clone(), select_schema.schema.len() - 1);
599            extracted_values.push(value);
600        }
601    }
602
603    Ok(extracted_values)
604}
605
606fn decide_column_schema_and_convert_value(
607    column_name: &str,
608    value: JsonbValue,
609    existing_schema: Option<&ExistingLogSchema>,
610    table_name: &str,
611) -> Result<Option<(ColumnSchema, GreptimeValue)>> {
612    if let Some(existing_column) = existing_schema.and_then(|schema| schema.get(column_name)) {
613        return decide_existing_column_schema_and_convert_value(
614            column_name,
615            value,
616            existing_column,
617            table_name,
618        );
619    }
620
621    let column_info = match value {
622        JsonbValue::String(s) => Ok(Some((
623            GreptimeValue {
624                value_data: Some(ValueData::StringValue(s.into())),
625            },
626            ColumnDataType::String,
627            SemanticType::Tag,
628            None,
629        ))),
630        JsonbValue::Number(n) => match n {
631            JsonbNumber::Int64(i) => Ok(Some((
632                GreptimeValue {
633                    value_data: Some(ValueData::I64Value(i)),
634                },
635                ColumnDataType::Int64,
636                SemanticType::Tag,
637                None,
638            ))),
639            JsonbNumber::Float64(_) => UnsupportedJsonDataTypeForTagSnafu {
640                ty: "FLOAT".to_string(),
641                key: column_name,
642            }
643            .fail(),
644            JsonbNumber::Decimal64(_) | JsonbNumber::Decimal128(_) | JsonbNumber::Decimal256(_) => {
645                UnsupportedJsonDataTypeForTagSnafu {
646                    ty: "DECIMAL".to_string(),
647                    key: column_name,
648                }
649                .fail()
650            }
651            JsonbNumber::UInt64(u) => {
652                let value = jsonb_uint64_to_log_value(u, column_name)?;
653                Ok(Some((
654                    GreptimeValue {
655                        value_data: Some(ValueData::I64Value(value)),
656                    },
657                    ColumnDataType::Int64,
658                    SemanticType::Tag,
659                    None,
660                )))
661            }
662        },
663        JsonbValue::Bool(b) => Ok(Some((
664            GreptimeValue {
665                value_data: Some(ValueData::BoolValue(b)),
666            },
667            ColumnDataType::Boolean,
668            SemanticType::Tag,
669            None,
670        ))),
671        JsonbValue::Array(_) | JsonbValue::Object(_) => UnsupportedJsonDataTypeForTagSnafu {
672            ty: "Json".to_string(),
673            key: column_name,
674        }
675        .fail(),
676        JsonbValue::Binary(_) => UnsupportedJsonDataTypeForTagSnafu {
677            ty: "Binary".to_string(),
678            key: column_name,
679        }
680        .fail(),
681        JsonbValue::Date(_) => UnsupportedJsonDataTypeForTagSnafu {
682            ty: "Date".to_string(),
683            key: column_name,
684        }
685        .fail(),
686        JsonbValue::Timestamp(_) => UnsupportedJsonDataTypeForTagSnafu {
687            ty: "Timestamp".to_string(),
688            key: column_name,
689        }
690        .fail(),
691        JsonbValue::TimestampTz(_) => UnsupportedJsonDataTypeForTagSnafu {
692            ty: "TimestampTz".to_string(),
693            key: column_name,
694        }
695        .fail(),
696        JsonbValue::Interval(_) => UnsupportedJsonDataTypeForTagSnafu {
697            ty: "Interval".to_string(),
698            key: column_name,
699        }
700        .fail(),
701        JsonbValue::Null => Ok(None),
702    };
703    column_info.map(|c| {
704        c.map(|(value, column_type, semantic_type, datatype_extension)| {
705            (
706                ColumnSchema {
707                    column_name: column_name.to_string(),
708                    datatype: column_type as i32,
709                    semantic_type: semantic_type as i32,
710                    datatype_extension,
711                    options: None,
712                },
713                value,
714            )
715        })
716    })
717}
718
719fn decide_existing_column_schema_and_convert_value(
720    column_name: &str,
721    value: JsonbValue,
722    existing_column: &ExistingLogColumn,
723    table_name: &str,
724) -> Result<Option<(ColumnSchema, GreptimeValue)>> {
725    let Some((value_data, request_type)) = jsonb_value_to_log_value_data(column_name, value, true)?
726    else {
727        return Ok(None);
728    };
729    let value_data = coerce_log_value_data(
730        Some(value_data),
731        existing_column.datatype,
732        existing_column.schema.semantic_type(),
733        request_type,
734        existing_column.is_json_binary(),
735        column_name,
736        table_name,
737    )?;
738
739    Ok(Some((
740        existing_column.schema.clone(),
741        GreptimeValue { value_data },
742    )))
743}
744
745/// Converts a JSON `UInt64` number into the signed value used by built-in
746/// log columns. `JsonbNumber::UInt64` only arises for values that do not fit
747/// in `i64` (see `decide_column_schema_and_convert_value`), so the conversion
748/// is fallible: wrapping to a negative number would silently corrupt the
749/// value (a counter reading back as negative), so out-of-range values are
750/// rejected instead.
751fn jsonb_uint64_to_log_value(u: u64, column_name: &str) -> Result<i64> {
752    i64::try_from(u).map_err(|_| InvalidParameterSnafu {
753        reason: format!(
754            "uint64 value {u} in column '{column_name}' exceeds the i64 range supported by built-in log columns"
755        ),
756    }
757    .build())
758}
759
760fn jsonb_value_to_log_value_data(
761    column_name: &str,
762    value: JsonbValue,
763    allow_float: bool,
764) -> Result<Option<(ValueData, ColumnDataType)>> {
765    match value {
766        JsonbValue::String(s) => Ok(Some((
767            ValueData::StringValue(s.into()),
768            ColumnDataType::String,
769        ))),
770        JsonbValue::Number(n) => match n {
771            JsonbNumber::Int64(i) => Ok(Some((ValueData::I64Value(i), ColumnDataType::Int64))),
772            JsonbNumber::Float64(f) if allow_float => {
773                Ok(Some((ValueData::F64Value(f), ColumnDataType::Float64)))
774            }
775            JsonbNumber::Float64(_) => UnsupportedJsonDataTypeForTagSnafu {
776                ty: "FLOAT".to_string(),
777                key: column_name,
778            }
779            .fail(),
780            JsonbNumber::Decimal64(_) | JsonbNumber::Decimal128(_) | JsonbNumber::Decimal256(_) => {
781                UnsupportedJsonDataTypeForTagSnafu {
782                    ty: "DECIMAL".to_string(),
783                    key: column_name,
784                }
785                .fail()
786            }
787            JsonbNumber::UInt64(u) => Ok(Some((
788                ValueData::I64Value(jsonb_uint64_to_log_value(u, column_name)?),
789                ColumnDataType::Int64,
790            ))),
791        },
792        JsonbValue::Bool(b) => Ok(Some((ValueData::BoolValue(b), ColumnDataType::Boolean))),
793        JsonbValue::Array(_) | JsonbValue::Object(_) => UnsupportedJsonDataTypeForTagSnafu {
794            ty: "Json".to_string(),
795            key: column_name,
796        }
797        .fail(),
798        JsonbValue::Binary(_) => UnsupportedJsonDataTypeForTagSnafu {
799            ty: "Binary".to_string(),
800            key: column_name,
801        }
802        .fail(),
803        JsonbValue::Date(_) => UnsupportedJsonDataTypeForTagSnafu {
804            ty: "Date".to_string(),
805            key: column_name,
806        }
807        .fail(),
808        JsonbValue::Timestamp(_) => UnsupportedJsonDataTypeForTagSnafu {
809            ty: "Timestamp".to_string(),
810            key: column_name,
811        }
812        .fail(),
813        JsonbValue::TimestampTz(_) => UnsupportedJsonDataTypeForTagSnafu {
814            ty: "TimestampTz".to_string(),
815            key: column_name,
816        }
817        .fail(),
818        JsonbValue::Interval(_) => UnsupportedJsonDataTypeForTagSnafu {
819            ty: "Interval".to_string(),
820            key: column_name,
821        }
822        .fail(),
823        JsonbValue::Null => Ok(None),
824    }
825}
826
827fn align_rows_with_existing_schema(
828    schemas: &mut [ColumnSchema],
829    rows: &mut [Row],
830    existing_schema: Option<&ExistingLogSchema>,
831    table_name: &str,
832) -> Result<()> {
833    let Some(existing_schema) = existing_schema else {
834        return Ok(());
835    };
836
837    for (column_idx, schema) in schemas.iter_mut().enumerate() {
838        let request_type = schema.datatype();
839        let Some(existing_column) = existing_schema.get(&schema.column_name) else {
840            // Existing tables own their primary key definition; request-only
841            // columns must not expand it.
842            if schema.semantic_type() == SemanticType::Tag {
843                schema.semantic_type = SemanticType::Field as i32;
844            }
845            continue;
846        };
847
848        let target_type = existing_column.datatype;
849        let semantic_type = existing_column.schema.semantic_type();
850        let target_is_json_binary = existing_column.is_json_binary();
851        for row in rows.iter_mut() {
852            let Some(value) = row.values.get_mut(column_idx) else {
853                continue;
854            };
855            value.value_data = coerce_log_value_data(
856                value.value_data.take(),
857                target_type,
858                semantic_type,
859                request_type,
860                target_is_json_binary,
861                &schema.column_name,
862                table_name,
863            )?;
864        }
865        *schema = existing_column.schema_for_request_type(request_type);
866    }
867
868    Ok(())
869}
870
871fn coerce_log_value_data(
872    value_data: Option<ValueData>,
873    target_type: ColumnDataType,
874    _semantic_type: SemanticType,
875    request_type: ColumnDataType,
876    target_is_json_binary: bool,
877    column_name: &str,
878    table_name: &str,
879) -> Result<Option<ValueData>> {
880    let Some(value_data) = value_data else {
881        return Ok(None);
882    };
883
884    if request_type == target_type {
885        return Ok(Some(value_data));
886    }
887
888    if request_type == ColumnDataType::Binary && target_is_json_binary {
889        return Ok(Some(value_data));
890    }
891
892    if is_timestamp_type(request_type)
893        && let Some(target_unit) = timestamp_unit(target_type)
894    {
895        return align_timestamp_value(value_data, target_unit, column_name, table_name).map(Some);
896    }
897
898    // Lossless signed -> unsigned integer cast for built-in fields moving from
899    // unsigned to signed types (e.g. an existing UInt64/UInt32 log column
900    // receiving new Int64/Int32 ingest). Lets existing tables keep their
901    // unsigned columns without an `ALTER`. In the mutually-exclusive `else` of
902    // the String branch so the move stays local to non-String targets; the
903    // shared predicate keeps the supported pairs identical to the trace path
904    // (the generic String -> numeric coercions keep their pre-existing
905    // rejection on this path).
906    if target_type == ColumnDataType::String {
907        if let Ok(value_data) =
908            coerce_value_data(&Some(value_data.clone()), target_type, request_type)
909        {
910            return Ok(value_data);
911        }
912        if let Some(value_data) = stringify_scalar_value(value_data) {
913            return Ok(Some(value_data));
914        }
915    } else if is_supported_signed_to_unsigned_coercion(request_type, target_type)
916        && let Ok(Some(value_data)) =
917            coerce_value_data(&Some(value_data), target_type, request_type)
918    {
919        return Ok(Some(value_data));
920    }
921
922    InvalidParameterSnafu {
923        reason: format!(
924            "failed to align log column '{}' in table '{}' from {:?} to {:?}",
925            column_name, table_name, request_type, target_type
926        ),
927    }
928    .fail()
929}
930
931fn stringify_scalar_value(value_data: ValueData) -> Option<ValueData> {
932    let value = match value_data {
933        ValueData::StringValue(value) => value,
934        ValueData::BoolValue(value) => value.to_string(),
935        ValueData::I8Value(value) => value.to_string(),
936        ValueData::I16Value(value) => value.to_string(),
937        ValueData::I32Value(value) => value.to_string(),
938        ValueData::I64Value(value) => value.to_string(),
939        ValueData::U8Value(value) => value.to_string(),
940        ValueData::U16Value(value) => value.to_string(),
941        ValueData::U32Value(value) => value.to_string(),
942        ValueData::U64Value(value) => value.to_string(),
943        ValueData::F32Value(value) => value.to_string(),
944        ValueData::F64Value(value) => value.to_string(),
945        _ => return None,
946    };
947    Some(ValueData::StringValue(value))
948}
949
950fn align_timestamp_value(
951    value_data: ValueData,
952    target_unit: TimeUnit,
953    column_name: &str,
954    table_name: &str,
955) -> Result<ValueData> {
956    let timestamp = match value_data {
957        ValueData::TimestampSecondValue(value) => Timestamp::new_second(value),
958        ValueData::TimestampMillisecondValue(value) => Timestamp::new_millisecond(value),
959        ValueData::TimestampMicrosecondValue(value) => Timestamp::new_microsecond(value),
960        ValueData::TimestampNanosecondValue(value) => Timestamp::new_nanosecond(value),
961        value_data => {
962            return InvalidParameterSnafu {
963                reason: format!(
964                    "failed to align log column '{}' in table '{}' from non-timestamp value {:?}",
965                    column_name, table_name, value_data
966                ),
967            }
968            .fail();
969        }
970    };
971    let timestamp = timestamp.convert_to(target_unit).ok_or_else(|| {
972        InvalidParameterSnafu {
973            reason: format!(
974                "failed to align log column '{}' in table '{}' to timestamp unit {}",
975                column_name, table_name, target_unit
976            ),
977        }
978        .build()
979    })?;
980
981    Ok(match target_unit {
982        TimeUnit::Second => ValueData::TimestampSecondValue(timestamp.value()),
983        TimeUnit::Millisecond => ValueData::TimestampMillisecondValue(timestamp.value()),
984        TimeUnit::Microsecond => ValueData::TimestampMicrosecondValue(timestamp.value()),
985        TimeUnit::Nanosecond => ValueData::TimestampNanosecondValue(timestamp.value()),
986    })
987}
988
989fn is_timestamp_type(datatype: ColumnDataType) -> bool {
990    timestamp_unit(datatype).is_some()
991}
992
993fn parse_export_logs_service_request_to_rows(
994    request: ExportLogsServiceRequest,
995    mut select_info: Box<SelectInfo>,
996    v1_schema: bool,
997    existing_schema: Option<&ExistingLogSchema>,
998    table_name: &str,
999) -> Result<Rows> {
1000    let mut schemas = build_otlp_logs_identity_schema(v1_schema)?;
1001
1002    // Extract keys named after v1 built-in columns would add duplicate columns. A
1003    // `service_name` key fills the built-in column; `event_name` and
1004    // `observed_timestamp` keep the LogRecord values, and the attribute stays in
1005    // `log_attributes`.
1006    let extract_service_name = v1_schema
1007        && select_info
1008            .keys
1009            .iter()
1010            .any(|key| key == SERVICE_NAME_COLUMN);
1011    if v1_schema {
1012        select_info.keys.retain(|key| {
1013            !matches!(
1014                key.as_str(),
1015                SERVICE_NAME_COLUMN | EVENT_NAME_COLUMN | OBSERVED_TIMESTAMP_COLUMN
1016            )
1017        });
1018    }
1019    let mut parse_ctx = ParseContext::new(
1020        select_info,
1021        v1_schema,
1022        extract_service_name,
1023        existing_schema,
1024        table_name,
1025    );
1026    let mut rows = parse_resource(&mut parse_ctx, request.resource_logs)?;
1027
1028    schemas.extend(parse_ctx.select_schema.column_schemas()?);
1029    align_rows_with_existing_schema(&mut schemas, &mut rows, existing_schema, table_name)?;
1030
1031    rows.iter_mut().for_each(|row| {
1032        row.values.resize(schemas.len(), GreptimeValue::default());
1033    });
1034
1035    Ok(Rows {
1036        schema: schemas,
1037        rows,
1038    })
1039}
1040
1041fn parse_resource(
1042    parse_ctx: &mut ParseContext,
1043    resource_logs_vec: Vec<ResourceLogs>,
1044) -> Result<Vec<Row>> {
1045    let total_len = resource_logs_vec
1046        .iter()
1047        .flat_map(|r| r.scope_logs.iter())
1048        .map(|s| s.log_records.len())
1049        .sum();
1050
1051    let mut results = Vec::with_capacity(total_len);
1052
1053    for r in resource_logs_vec {
1054        let mut resource_service = None;
1055        parse_ctx.resource_attr = match r.resource {
1056            Some(resource) => {
1057                if parse_ctx.v1_schema {
1058                    resource_service = resource_service_name(&resource.attributes);
1059                }
1060                key_value_to_jsonb(resource.attributes)
1061            }
1062            None => JsonbValue::Null,
1063        };
1064
1065        parse_ctx.resource_url = r.schema_url;
1066        if parse_ctx.v1_schema {
1067            let extracted = if parse_ctx.extract_service_name {
1068                extracted_service_name(&parse_ctx.resource_attr)?
1069            } else {
1070                None
1071            };
1072            parse_ctx.resource_service_name = extracted.or(resource_service);
1073        }
1074
1075        parse_ctx.resource_uplift_values = extract_field_from_attr_and_combine_schema(
1076            &parse_ctx.select_info,
1077            &mut parse_ctx.select_schema,
1078            &parse_ctx.resource_attr,
1079            parse_ctx.existing_schema,
1080            parse_ctx.table_name,
1081        )?;
1082
1083        let rows = parse_scope(r.scope_logs, parse_ctx)?;
1084        results.extend(rows);
1085    }
1086    Ok(results)
1087}
1088
1089struct ParseContext<'a> {
1090    // input selected keys
1091    select_info: Box<SelectInfo>,
1092    v1_schema: bool,
1093    extract_service_name: bool,
1094    existing_schema: Option<&'a ExistingLogSchema>,
1095    table_name: &'a str,
1096    // schema infos for selected keys from resource/scope/log for current request
1097    // since the value override from bottom to top, the max capacity is the length of the keys
1098    select_schema: SchemaInfo,
1099
1100    // extracted and uplifted values using select keys
1101    resource_uplift_values: Vec<GreptimeValue>,
1102    scope_uplift_values: Vec<GreptimeValue>,
1103
1104    // passdown values
1105    resource_url: String,
1106    resource_attr: JsonbValue<'a>,
1107    scope_name: Option<String>,
1108    scope_version: Option<String>,
1109    scope_url: String,
1110    scope_attrs: JsonbValue<'a>,
1111    // `service_name` values resolved at the resource and scope levels; a log
1112    // record falls back to them when its own attributes don't carry one
1113    resource_service_name: Option<String>,
1114    scope_service_name: Option<String>,
1115}
1116
1117impl<'a> ParseContext<'a> {
1118    pub fn new(
1119        select_info: Box<SelectInfo>,
1120        v1_schema: bool,
1121        extract_service_name: bool,
1122        existing_schema: Option<&'a ExistingLogSchema>,
1123        table_name: &'a str,
1124    ) -> ParseContext<'a> {
1125        let len = select_info.keys.len();
1126        ParseContext {
1127            select_info,
1128            v1_schema,
1129            extract_service_name,
1130            existing_schema,
1131            table_name,
1132            select_schema: SchemaInfo::with_capacity(len),
1133            resource_uplift_values: vec![],
1134            scope_uplift_values: vec![],
1135            resource_url: String::new(),
1136            resource_attr: JsonbValue::Null,
1137            scope_name: None,
1138            scope_version: None,
1139            scope_url: String::new(),
1140            scope_attrs: JsonbValue::Null,
1141            resource_service_name: None,
1142            scope_service_name: None,
1143        }
1144    }
1145}
1146
1147fn parse_scope(scopes_log_vec: Vec<ScopeLogs>, parse_ctx: &mut ParseContext) -> Result<Vec<Row>> {
1148    let len = scopes_log_vec.iter().map(|l| l.log_records.len()).sum();
1149    let mut results = Vec::with_capacity(len);
1150
1151    for scope_logs in scopes_log_vec {
1152        let (scope_attrs, scope_version, scope_name) = scope_to_jsonb(scope_logs.scope);
1153        parse_ctx.scope_name = scope_name;
1154        parse_ctx.scope_version = scope_version;
1155        parse_ctx.scope_url = scope_logs.schema_url;
1156        parse_ctx.scope_attrs = scope_attrs;
1157        parse_ctx.scope_service_name = if parse_ctx.extract_service_name {
1158            extracted_service_name(&parse_ctx.scope_attrs)?
1159        } else {
1160            None
1161        };
1162
1163        parse_ctx.scope_uplift_values = extract_field_from_attr_and_combine_schema(
1164            &parse_ctx.select_info,
1165            &mut parse_ctx.select_schema,
1166            &parse_ctx.scope_attrs,
1167            parse_ctx.existing_schema,
1168            parse_ctx.table_name,
1169        )?;
1170
1171        let rows = parse_log(scope_logs.log_records, parse_ctx)?;
1172        results.extend(rows);
1173    }
1174    Ok(results)
1175}
1176
1177fn parse_log(log_records: Vec<LogRecord>, parse_ctx: &mut ParseContext) -> Result<Vec<Row>> {
1178    let mut result = Vec::with_capacity(log_records.len());
1179
1180    for mut log in log_records {
1181        let log_attr = key_value_to_jsonb(std::mem::take(&mut log.attributes));
1182        let service_name = if parse_ctx.v1_schema {
1183            let extracted = if parse_ctx.extract_service_name {
1184                extracted_service_name(&log_attr)?
1185            } else {
1186                None
1187            };
1188            extracted
1189                .or_else(|| parse_ctx.scope_service_name.clone())
1190                .or_else(|| parse_ctx.resource_service_name.clone())
1191        } else {
1192            None
1193        };
1194        let mut row = build_otlp_build_in_row(log, &log_attr, service_name, parse_ctx);
1195
1196        let log_values = extract_field_from_attr_and_combine_schema(
1197            &parse_ctx.select_info,
1198            &mut parse_ctx.select_schema,
1199            &log_attr,
1200            parse_ctx.existing_schema,
1201            parse_ctx.table_name,
1202        )?;
1203
1204        let extracted_values = merge_values(
1205            log_values,
1206            &parse_ctx.scope_uplift_values,
1207            &parse_ctx.resource_uplift_values,
1208        );
1209
1210        row.values.extend(extracted_values);
1211
1212        result.push(row);
1213    }
1214    Ok(result)
1215}
1216
1217fn merge_values(
1218    log: Vec<GreptimeValue>,
1219    scope: &[GreptimeValue],
1220    resource: &[GreptimeValue],
1221) -> Vec<GreptimeValue> {
1222    log.into_iter()
1223        .enumerate()
1224        .map(|(i, value)| GreptimeValue {
1225            value_data: value
1226                .value_data
1227                .or_else(|| scope.get(i).and_then(|x| x.value_data.clone()))
1228                .or_else(|| resource.get(i).and_then(|x| x.value_data.clone())),
1229        })
1230        .collect()
1231}
1232
1233/// transform otlp logs request to pipeline value
1234/// https://opentelemetry.io/docs/concepts/signals/logs/
1235fn parse_export_logs_service_request(request: ExportLogsServiceRequest) -> Vec<VrlValue> {
1236    let mut result = Vec::new();
1237    for r in request.resource_logs {
1238        let resource_attr = r
1239            .resource
1240            .map(|x| VrlValue::Object(key_value_to_map(x.attributes)))
1241            .unwrap_or(VrlValue::Null);
1242        let resource_schema_url = VrlValue::Bytes(r.schema_url.into());
1243        for scope_logs in r.scope_logs {
1244            let (scope_attrs, scope_version, scope_name) =
1245                scope_to_pipeline_value(scope_logs.scope);
1246            let scope_schema_url = VrlValue::Bytes(scope_logs.schema_url.into());
1247            for log in scope_logs.log_records {
1248                let value = log_to_pipeline_value(
1249                    log,
1250                    resource_schema_url.clone(),
1251                    resource_attr.clone(),
1252                    scope_schema_url.clone(),
1253                    scope_name.clone(),
1254                    scope_version.clone(),
1255                    scope_attrs.clone(),
1256                );
1257                result.push(value);
1258            }
1259        }
1260    }
1261    result
1262}
1263
1264// convert AnyValue to pipeline value
1265fn any_value_to_vrl_value(value: any_value::Value) -> VrlValue {
1266    match value {
1267        any_value::Value::StringValue(s) => VrlValue::Bytes(s.into()),
1268        any_value::Value::IntValue(i) => VrlValue::Integer(i),
1269        any_value::Value::DoubleValue(d) => VrlValue::Float(NotNan::new(d).unwrap()),
1270        any_value::Value::BoolValue(b) => VrlValue::Boolean(b),
1271        any_value::Value::ArrayValue(array_value) => {
1272            let values = array_value
1273                .values
1274                .into_iter()
1275                .filter_map(|v| v.value.map(any_value_to_vrl_value))
1276                .collect();
1277            VrlValue::Array(values)
1278        }
1279        any_value::Value::KvlistValue(key_value_list) => {
1280            VrlValue::Object(key_value_to_map(key_value_list.values))
1281        }
1282        any_value::Value::BytesValue(items) => VrlValue::Bytes(Bytes::from(items)),
1283        // `StringValueStrindex` references the Profiling signal's
1284        // `ProfilesDictionary.string_table`, which is unavailable to logs/traces.
1285        // Per the OTLP spec, non-Profiling receivers must treat it as a non-fatal
1286        // issue and process the value as if it were absent.
1287        any_value::Value::StringValueStrindex(_) => {
1288            warn!(
1289                "encountered a profiling-only `StringValueStrindex` value in a non-profiling signal; ignoring"
1290            );
1291            VrlValue::Null
1292        }
1293    }
1294}
1295
1296// convert otlp keyValue vec to map
1297fn key_value_to_map(key_values: Vec<KeyValue>) -> BTreeMap<KeyString, VrlValue> {
1298    let mut map = BTreeMap::new();
1299    for kv in key_values {
1300        let value = match kv.value {
1301            Some(value) => match value.value {
1302                Some(value) => any_value_to_vrl_value(value),
1303                None => VrlValue::Null,
1304            },
1305            None => VrlValue::Null,
1306        };
1307        map.insert(kv.key.into(), value);
1308    }
1309    map
1310}
1311
1312fn log_body_to_string(body: &AnyValue) -> String {
1313    let otlp_value = OtlpAnyValue::from(body);
1314    otlp_value.to_string()
1315}
1316
1317#[cfg(test)]
1318mod tests {
1319    use datatypes::prelude::ConcreteDataType;
1320    use datatypes::schema::ColumnSchema as DatatypesColumnSchema;
1321    use opentelemetry_proto::tonic::common::v1::any_value::Value as OtlpValue;
1322
1323    use super::*;
1324    use crate::otlp::trace::KEY_SERVICE_NAME;
1325
1326    fn time_column(datatype: ConcreteDataType) -> DatatypesColumnSchema {
1327        DatatypesColumnSchema::new("timestamp", datatype, false).with_time_index(true)
1328    }
1329
1330    fn column(name: &str, datatype: ConcreteDataType) -> DatatypesColumnSchema {
1331        DatatypesColumnSchema::new(name, datatype, true)
1332    }
1333
1334    fn existing_schema(
1335        columns: Vec<DatatypesColumnSchema>,
1336        primary_key_indices: &[usize],
1337    ) -> ExistingLogSchema {
1338        ExistingLogSchema::try_from_schema_parts(&columns, primary_key_indices).unwrap()
1339    }
1340
1341    fn existing_uint32_trace_flags_schema() -> ExistingLogSchema {
1342        existing_schema(
1343            vec![
1344                time_column(ConcreteDataType::timestamp_nanosecond_datatype()),
1345                column("trace_flags", ConcreteDataType::uint32_datatype()),
1346            ],
1347            &[],
1348        )
1349    }
1350
1351    fn kv(key: &str, value: OtlpValue) -> KeyValue {
1352        KeyValue {
1353            key: key.to_string(),
1354            value: Some(AnyValue { value: Some(value) }),
1355            ..Default::default()
1356        }
1357    }
1358
1359    fn request_with_log_attrs(attrs: Vec<KeyValue>) -> ExportLogsServiceRequest {
1360        request_with_log_attrs_and_flags(attrs, 0)
1361    }
1362
1363    fn request_with_log_attrs_and_flags(
1364        attrs: Vec<KeyValue>,
1365        flags: u32,
1366    ) -> ExportLogsServiceRequest {
1367        ExportLogsServiceRequest {
1368            resource_logs: vec![ResourceLogs {
1369                scope_logs: vec![ScopeLogs {
1370                    log_records: vec![LogRecord {
1371                        time_unix_nano: 1_234_000_000,
1372                        trace_id: vec![1; 16],
1373                        flags,
1374                        attributes: attrs,
1375                        ..Default::default()
1376                    }],
1377                    ..Default::default()
1378                }],
1379                ..Default::default()
1380            }],
1381        }
1382    }
1383
1384    fn parse_with_select(
1385        request: ExportLogsServiceRequest,
1386        select: &str,
1387        existing_schema: Option<&ExistingLogSchema>,
1388    ) -> Result<Rows> {
1389        // Mirrors production: only new tables get the v1 schema, and the existing
1390        // schemas built here carry no v1 marker.
1391        parse_export_logs_service_request_to_rows(
1392            request,
1393            Box::new(SelectInfo::from(select.to_string())),
1394            existing_schema.is_none(),
1395            existing_schema,
1396            "test_logs",
1397        )
1398    }
1399
1400    fn column_index(rows: &Rows, name: &str) -> usize {
1401        rows.schema
1402            .iter()
1403            .position(|schema| schema.column_name == name)
1404            .unwrap()
1405    }
1406
1407    #[test]
1408    fn test_no_existing_table_preserves_direct_schema() {
1409        let rows = parse_with_select(request_with_log_attrs(vec![]), "", None).unwrap();
1410
1411        assert_eq!(rows.schema[0].column_name, "timestamp");
1412        assert_eq!(
1413            rows.schema[0].datatype,
1414            ColumnDataType::TimestampNanosecond as i32
1415        );
1416        assert_eq!(rows.schema[0].semantic_type, SemanticType::Timestamp as i32);
1417        let scope_name_idx = column_index(&rows, "scope_name");
1418        assert_eq!(
1419            rows.schema[scope_name_idx].semantic_type,
1420            SemanticType::Tag as i32
1421        );
1422    }
1423
1424    fn request_with_resource_and_logs(
1425        resource_attrs: Vec<KeyValue>,
1426        log_records: Vec<LogRecord>,
1427    ) -> ExportLogsServiceRequest {
1428        ExportLogsServiceRequest {
1429            resource_logs: vec![ResourceLogs {
1430                resource: Some(opentelemetry_proto::tonic::resource::v1::Resource {
1431                    attributes: resource_attrs,
1432                    ..Default::default()
1433                }),
1434                scope_logs: vec![ScopeLogs {
1435                    scope: Some(InstrumentationScope {
1436                        name: "com.example.Checkout".to_string(),
1437                        ..Default::default()
1438                    }),
1439                    log_records,
1440                    ..Default::default()
1441                }],
1442                ..Default::default()
1443            }],
1444        }
1445    }
1446
1447    fn value_at(rows: &Rows, row: usize, column: &str) -> Option<ValueData> {
1448        rows.rows[row].values[column_index(rows, column)]
1449            .value_data
1450            .clone()
1451    }
1452
1453    #[test]
1454    fn test_new_table_uses_v1_schema() {
1455        let request = request_with_resource_and_logs(
1456            vec![kv(
1457                KEY_SERVICE_NAME,
1458                OtlpValue::StringValue("checkout".to_string()),
1459            )],
1460            vec![
1461                LogRecord {
1462                    time_unix_nano: 1_000,
1463                    observed_time_unix_nano: 2_000,
1464                    event_name: "gen_ai.choice".to_string(),
1465                    ..Default::default()
1466                },
1467                LogRecord {
1468                    time_unix_nano: 3_000,
1469                    ..Default::default()
1470                },
1471            ],
1472        );
1473        let rows = parse_with_select(request, "", None).unwrap();
1474
1475        // Auto-create orders the primary key by column order.
1476        let tags: Vec<_> = rows
1477            .schema
1478            .iter()
1479            .filter(|schema| schema.semantic_type == SemanticType::Tag as i32)
1480            .map(|schema| schema.column_name.as_str())
1481            .collect();
1482        assert_eq!(tags, [SERVICE_NAME_COLUMN, "scope_name"]);
1483        let trace_id = &rows.schema[column_index(&rows, "trace_id")];
1484        assert!(
1485            trace_id
1486                .options
1487                .as_ref()
1488                .is_some_and(|options| options.options.contains_key("skipping_index"))
1489        );
1490
1491        assert_eq!(
1492            value_at(&rows, 0, SERVICE_NAME_COLUMN),
1493            Some(ValueData::StringValue("checkout".to_string()))
1494        );
1495        assert_eq!(
1496            value_at(&rows, 0, EVENT_NAME_COLUMN),
1497            Some(ValueData::StringValue("gen_ai.choice".to_string()))
1498        );
1499        assert_eq!(
1500            value_at(&rows, 0, OBSERVED_TIMESTAMP_COLUMN),
1501            Some(ValueData::TimestampNanosecondValue(2_000))
1502        );
1503        // Empty event name and zero observed time mean "absent" in OTLP.
1504        assert_eq!(value_at(&rows, 1, EVENT_NAME_COLUMN), None);
1505        assert_eq!(value_at(&rows, 1, OBSERVED_TIMESTAMP_COLUMN), None);
1506    }
1507
1508    #[test]
1509    fn test_extracted_service_name_overrides_resource_service_name() {
1510        let mut request = request_with_resource_and_logs(
1511            vec![kv(
1512                KEY_SERVICE_NAME,
1513                OtlpValue::StringValue("from-resource".to_string()),
1514            )],
1515            vec![
1516                LogRecord {
1517                    attributes: vec![
1518                        kv(
1519                            SERVICE_NAME_COLUMN,
1520                            OtlpValue::StringValue("from-log".to_string()),
1521                        ),
1522                        kv(
1523                            EVENT_NAME_COLUMN,
1524                            OtlpValue::StringValue("from-attr".to_string()),
1525                        ),
1526                    ],
1527                    event_name: "from-record".to_string(),
1528                    ..Default::default()
1529                },
1530                LogRecord::default(),
1531            ],
1532        );
1533        let mut scope_logs = request.resource_logs[0].scope_logs[0].clone();
1534        scope_logs.scope.as_mut().unwrap().attributes = vec![kv(
1535            SERVICE_NAME_COLUMN,
1536            OtlpValue::StringValue("from-scope".to_string()),
1537        )];
1538        scope_logs.log_records = vec![LogRecord::default()];
1539        request.resource_logs[0].scope_logs.push(scope_logs);
1540        let rows = parse_with_select(request, "service_name,event_name,host", None).unwrap();
1541
1542        for column in [SERVICE_NAME_COLUMN, EVENT_NAME_COLUMN] {
1543            assert_eq!(
1544                rows.schema
1545                    .iter()
1546                    .filter(|schema| schema.column_name == column)
1547                    .count(),
1548                1
1549            );
1550        }
1551        assert_eq!(
1552            value_at(&rows, 0, SERVICE_NAME_COLUMN),
1553            Some(ValueData::StringValue("from-log".to_string()))
1554        );
1555        assert_eq!(
1556            value_at(&rows, 0, EVENT_NAME_COLUMN),
1557            Some(ValueData::StringValue("from-record".to_string()))
1558        );
1559        assert_eq!(
1560            value_at(&rows, 1, SERVICE_NAME_COLUMN),
1561            Some(ValueData::StringValue("from-resource".to_string()))
1562        );
1563        assert_eq!(
1564            value_at(&rows, 2, SERVICE_NAME_COLUMN),
1565            Some(ValueData::StringValue("from-scope".to_string()))
1566        );
1567    }
1568
1569    #[test]
1570    fn test_non_scalar_resource_service_name_is_not_an_error() {
1571        let request = request_with_resource_and_logs(
1572            vec![kv(
1573                KEY_SERVICE_NAME,
1574                OtlpValue::ArrayValue(Default::default()),
1575            )],
1576            vec![LogRecord::default()],
1577        );
1578        let rows = parse_with_select(request, "", None).unwrap();
1579
1580        assert_eq!(value_at(&rows, 0, SERVICE_NAME_COLUMN), None);
1581    }
1582
1583    #[test]
1584    fn test_fresh_table_trace_flags_is_int32() {
1585        // Phase 1 of the unsigned -> signed transition: a fresh log table now
1586        // creates `trace_flags` as Int32. Existing tables keep UInt32 (covered
1587        // by the coercion test above); new tables start signed.
1588        let rows = parse_with_select(request_with_log_attrs(vec![]), "", None).unwrap();
1589        let idx = column_index(&rows, "trace_flags");
1590
1591        assert_eq!(rows.schema[idx].datatype, ColumnDataType::Int32 as i32);
1592        assert_eq!(
1593            rows.rows[0].values[idx].value_data,
1594            Some(ValueData::I32Value(0))
1595        );
1596    }
1597
1598    #[test]
1599    fn test_existing_uint32_trace_flags_keeps_type_and_coerces_int32_request() {
1600        // An existing UInt32 `trace_flags` column keeps its type when new
1601        // signed (Int32) ingest arrives, without an `ALTER`.
1602        let existing = existing_uint32_trace_flags_schema();
1603
1604        let rows = parse_with_select(request_with_log_attrs(vec![]), "", Some(&existing)).unwrap();
1605        let idx = column_index(&rows, "trace_flags");
1606
1607        assert_eq!(rows.schema[idx].datatype, ColumnDataType::Uint32 as i32);
1608        assert_eq!(
1609            rows.rows[0].values[idx].value_data,
1610            Some(ValueData::U32Value(0))
1611        );
1612    }
1613
1614    #[test]
1615    fn test_existing_uint32_trace_flags_preserves_nonzero_flags_value() {
1616        // A non-zero trace_flags bit pattern (e.g. the W3C sampled flag as sent
1617        // by common encoders) round-trips exactly through the Int32 -> UInt32
1618        // coercion into an existing unsigned column. Guards that the cast is
1619        // bit-preserving for realistic values, not just the default 0.
1620        let existing = existing_uint32_trace_flags_schema();
1621
1622        let rows = parse_with_select(
1623            request_with_log_attrs_and_flags(vec![], 256),
1624            "",
1625            Some(&existing),
1626        )
1627        .unwrap();
1628        let idx = column_index(&rows, "trace_flags");
1629
1630        assert_eq!(rows.schema[idx].datatype, ColumnDataType::Uint32 as i32);
1631        assert_eq!(
1632            rows.rows[0].values[idx].value_data,
1633            Some(ValueData::U32Value(256))
1634        );
1635    }
1636
1637    #[test]
1638    fn test_existing_uint32_trace_flags_preserves_high_bit_flags_value() {
1639        // The full u32 range round-trips: a value with the sign bit set proves
1640        // the Int32 -> UInt32 coercion is bit-exact (`as i32` then `as u32`
1641        // preserve the pattern), so no flags value is corrupted on the
1642        // unsigned -> signed transition.
1643        let existing = existing_uint32_trace_flags_schema();
1644
1645        let rows = parse_with_select(
1646            request_with_log_attrs_and_flags(vec![], 0x8000_0000),
1647            "",
1648            Some(&existing),
1649        )
1650        .unwrap();
1651        let idx = column_index(&rows, "trace_flags");
1652
1653        assert_eq!(rows.schema[idx].datatype, ColumnDataType::Uint32 as i32);
1654        assert_eq!(
1655            rows.rows[0].values[idx].value_data,
1656            Some(ValueData::U32Value(0x8000_0000))
1657        );
1658    }
1659
1660    #[test]
1661    fn test_existing_primary_key_updates_builtin_column_semantic_type() {
1662        let existing = existing_schema(
1663            vec![
1664                time_column(ConcreteDataType::timestamp_nanosecond_datatype()),
1665                column("trace_id", ConcreteDataType::string_datatype()),
1666            ],
1667            &[1],
1668        );
1669
1670        let rows = parse_with_select(request_with_log_attrs(vec![]), "", Some(&existing)).unwrap();
1671        let trace_id_idx = column_index(&rows, "trace_id");
1672
1673        assert_eq!(
1674            rows.schema[trace_id_idx].semantic_type,
1675            SemanticType::Tag as i32
1676        );
1677    }
1678
1679    #[test]
1680    fn test_existing_string_primary_key_stringifies_selected_scalar_values() {
1681        let existing = existing_schema(
1682            vec![
1683                time_column(ConcreteDataType::timestamp_nanosecond_datatype()),
1684                column("host", ConcreteDataType::string_datatype()),
1685            ],
1686            &[1],
1687        );
1688        let rows = parse_with_select(
1689            request_with_log_attrs(vec![kv("host", OtlpValue::IntValue(42))]),
1690            "host",
1691            Some(&existing),
1692        )
1693        .unwrap();
1694        let host_idx = column_index(&rows, "host");
1695
1696        assert_eq!(
1697            rows.schema[host_idx].datatype,
1698            ColumnDataType::String as i32
1699        );
1700        assert_eq!(
1701            rows.schema[host_idx].semantic_type,
1702            SemanticType::Tag as i32
1703        );
1704        assert_eq!(
1705            rows.rows[0].values[host_idx].value_data,
1706            Some(ValueData::StringValue("42".to_string()))
1707        );
1708    }
1709
1710    #[test]
1711    fn test_existing_string_field_stringifies_selected_scalar_values() {
1712        let existing = existing_schema(
1713            vec![
1714                time_column(ConcreteDataType::timestamp_nanosecond_datatype()),
1715                column("host", ConcreteDataType::string_datatype()),
1716            ],
1717            &[],
1718        );
1719        let rows = parse_with_select(
1720            request_with_log_attrs(vec![kv("host", OtlpValue::IntValue(42))]),
1721            "host",
1722            Some(&existing),
1723        )
1724        .unwrap();
1725        let host_idx = column_index(&rows, "host");
1726
1727        assert_eq!(
1728            rows.schema[host_idx].datatype,
1729            ColumnDataType::String as i32
1730        );
1731        assert_eq!(
1732            rows.schema[host_idx].semantic_type,
1733            SemanticType::Field as i32
1734        );
1735        assert_eq!(
1736            rows.rows[0].values[host_idx].value_data,
1737            Some(ValueData::StringValue("42".to_string()))
1738        );
1739    }
1740
1741    #[test]
1742    fn test_existing_non_string_primary_key_rejects_incompatible_selected_value() {
1743        let existing = existing_schema(
1744            vec![
1745                time_column(ConcreteDataType::timestamp_nanosecond_datatype()),
1746                column("host", ConcreteDataType::int64_datatype()),
1747            ],
1748            &[1],
1749        );
1750        let err = parse_with_select(
1751            request_with_log_attrs(vec![kv(
1752                "host",
1753                OtlpValue::StringValue("node-a".to_string()),
1754            )]),
1755            "host",
1756            Some(&existing),
1757        )
1758        .unwrap_err();
1759
1760        assert!(
1761            err.to_string()
1762                .contains("failed to align log column 'host'")
1763        );
1764    }
1765
1766    #[test]
1767    fn test_existing_timestamp_unit_is_respected() {
1768        let existing = existing_schema(
1769            vec![time_column(
1770                ConcreteDataType::timestamp_millisecond_datatype(),
1771            )],
1772            &[],
1773        );
1774        let rows = parse_with_select(request_with_log_attrs(vec![]), "", Some(&existing)).unwrap();
1775
1776        assert_eq!(
1777            rows.schema[0].datatype,
1778            ColumnDataType::TimestampMillisecond as i32
1779        );
1780        assert_eq!(
1781            rows.rows[0].values[0].value_data,
1782            Some(ValueData::TimestampMillisecondValue(1234))
1783        );
1784    }
1785
1786    #[test]
1787    fn test_missing_existing_primary_key_is_not_generated() {
1788        let existing = existing_schema(
1789            vec![
1790                time_column(ConcreteDataType::timestamp_nanosecond_datatype()),
1791                column("host", ConcreteDataType::string_datatype()),
1792            ],
1793            &[1],
1794        );
1795        let rows = parse_with_select(request_with_log_attrs(vec![]), "", Some(&existing)).unwrap();
1796
1797        assert!(
1798            !rows
1799                .schema
1800                .iter()
1801                .any(|schema| schema.column_name == "host")
1802        );
1803    }
1804
1805    #[test]
1806    fn test_existing_table_keeps_new_generated_columns_as_fields() {
1807        let existing = existing_schema(
1808            vec![
1809                time_column(ConcreteDataType::timestamp_nanosecond_datatype()),
1810                column("trace_id", ConcreteDataType::string_datatype()),
1811            ],
1812            &[1],
1813        );
1814        let rows = parse_with_select(
1815            request_with_log_attrs(vec![kv(
1816                "host",
1817                OtlpValue::StringValue("node-a".to_string()),
1818            )]),
1819            "host",
1820            Some(&existing),
1821        )
1822        .unwrap();
1823        let host_idx = column_index(&rows, "host");
1824        let scope_name_idx = column_index(&rows, "scope_name");
1825
1826        assert_eq!(
1827            rows.schema[host_idx].semantic_type,
1828            SemanticType::Field as i32
1829        );
1830        assert_eq!(
1831            rows.schema[scope_name_idx].semantic_type,
1832            SemanticType::Field as i32
1833        );
1834        // Unmarked existing tables keep the legacy columns.
1835        for column in [
1836            SERVICE_NAME_COLUMN,
1837            EVENT_NAME_COLUMN,
1838            OBSERVED_TIMESTAMP_COLUMN,
1839        ] {
1840            assert!(
1841                rows.schema
1842                    .iter()
1843                    .all(|schema| schema.column_name != column)
1844            );
1845        }
1846    }
1847
1848    #[test]
1849    fn test_existing_uint64_column_keeps_type_and_coerces_int64_request() {
1850        // An existing UInt64 column keeps its type when new signed (Int64)
1851        // ingest arrives, without an `ALTER`. The built-in models move to
1852        // signed while existing unsigned tables stay byte-for-byte unchanged.
1853        let existing = existing_schema(
1854            vec![
1855                time_column(ConcreteDataType::timestamp_nanosecond_datatype()),
1856                column("counter", ConcreteDataType::uint64_datatype()),
1857            ],
1858            &[],
1859        );
1860
1861        let rows = parse_with_select(
1862            request_with_log_attrs(vec![kv("counter", OtlpValue::IntValue(42))]),
1863            "counter",
1864            Some(&existing),
1865        )
1866        .unwrap();
1867        let idx = column_index(&rows, "counter");
1868
1869        assert_eq!(rows.schema[idx].datatype, ColumnDataType::Uint64 as i32);
1870        assert_eq!(
1871            rows.rows[0].values[idx].value_data,
1872            Some(ValueData::U64Value(42))
1873        );
1874    }
1875
1876    #[test]
1877    fn test_jsonb_extended_log_values_rejected() {
1878        for (value, expected_type) in [
1879            (
1880                JsonbValue::Number(JsonbNumber::Decimal64(jsonb::Decimal64 {
1881                    value: 123,
1882                    scale: 2,
1883                })),
1884                "DECIMAL",
1885            ),
1886            (JsonbValue::Binary(&[1, 2, 3]), "Binary"),
1887            (JsonbValue::Date(jsonb::Date { value: 0 }), "Date"),
1888            (
1889                JsonbValue::Timestamp(jsonb::Timestamp { value: 0 }),
1890                "Timestamp",
1891            ),
1892            (
1893                JsonbValue::TimestampTz(jsonb::TimestampTz {
1894                    value: 0,
1895                    offset: 0,
1896                }),
1897                "TimestampTz",
1898            ),
1899            (
1900                JsonbValue::Interval(jsonb::Interval {
1901                    months: 0,
1902                    days: 0,
1903                    micros: 0,
1904                }),
1905                "Interval",
1906            ),
1907        ] {
1908            let schema_error =
1909                decide_column_schema_and_convert_value("attr", value.clone(), None, "logs")
1910                    .expect_err("extended JSONB type must be rejected");
1911            let value_error = jsonb_value_to_log_value_data("attr", value, true)
1912                .expect_err("extended JSONB type must be rejected");
1913            for error in [schema_error, value_error] {
1914                assert!(matches!(error,
1915                    Error::UnsupportedJsonDataTypeForTag { key, ty, .. }
1916                        if key == "attr" && ty == expected_type
1917                ));
1918            }
1919        }
1920    }
1921
1922    #[test]
1923    fn test_jsonb_uint64_exceeding_i64_range_rejected() {
1924        // A JSON number that only fits in u64 must be rejected, not wrapped:
1925        // storing it as a negative i64 would silently corrupt the value. The
1926        // unsigned -> signed transition is only lossless for values that fit
1927        // the signed range.
1928        let err = jsonb_value_to_log_value_data(
1929            "counter",
1930            JsonbValue::Number(JsonbNumber::UInt64(u64::MAX)),
1931            false,
1932        )
1933        .unwrap_err();
1934
1935        assert!(
1936            err.to_string()
1937                .contains("exceeds the i64 range supported by built-in log columns")
1938        );
1939    }
1940
1941    #[test]
1942    fn test_existing_int64_column_rejects_numeric_string_value() {
1943        // Regression guard for the signed -> unsigned transition scope: even a
1944        // parseable numeric string must keep its pre-existing rejection when
1945        // targeting a non-String column. Only the signed -> unsigned integer
1946        // pairs gained coercion on this path, not the generic String ->
1947        // numeric ones.
1948        let existing = existing_schema(
1949            vec![
1950                time_column(ConcreteDataType::timestamp_nanosecond_datatype()),
1951                column("counter", ConcreteDataType::int64_datatype()),
1952            ],
1953            &[],
1954        );
1955
1956        let err = parse_with_select(
1957            request_with_log_attrs(vec![kv(
1958                "counter",
1959                OtlpValue::StringValue("42".to_string()),
1960            )]),
1961            "counter",
1962            Some(&existing),
1963        )
1964        .unwrap_err();
1965
1966        assert!(
1967            err.to_string()
1968                .contains("failed to align log column 'counter'")
1969        );
1970    }
1971}