Skip to main content

frontend/instance/otlp/
trace_ingest.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, HashMap, HashSet};
16use std::sync::Arc;
17
18use api::helper::ColumnDataTypeWrapper;
19use api::v1::alter_table_expr::Kind;
20use api::v1::{
21    AlterTableExpr, ColumnDataType, ColumnSchema, ModifyColumnType, ModifyColumnTypes,
22    RowInsertRequest, RowInsertRequests, Rows, Value,
23};
24use client::Output;
25use common_error::ext::{BoxedError, ErrorExt};
26use common_error::status_code::StatusCode;
27use common_meta::rpc::ddl::TriggerReason;
28use common_telemetry::{debug, warn};
29use datatypes::prelude::ConcreteDataType;
30use opentelemetry_proto::tonic::collector::trace::v1::ExportTraceServiceRequest;
31use pipeline::PipelineWay;
32use servers::error::{self, Result as ServerResult};
33use servers::otlp;
34use servers::otlp::coerce::{coerce_value_data, is_supported_trace_coercion, trace_value_datatype};
35use servers::otlp::trace::span::{TraceSpan, TraceSpanGroup};
36use servers::otlp::trace::v1::{TraceBatchSchema, TraceBinaryType, TraceRetryColumns};
37use servers::otlp::trace::{SERVICE_NAME_COLUMN, TraceAuxData};
38use servers::query_handler::TraceIngestOutcome;
39use session::context::QueryContextRef;
40use snafu::{IntoError, ResultExt};
41use table::requests::{
42    SEMANTIC_ENTITY_SERVICE_ID, SEMANTIC_PIPELINE, SEMANTIC_SIGNAL_TYPE, SEMANTIC_SOURCE,
43    SEMANTIC_TRACE_CONVENTIONS, SEMANTIC_VALUE_MIXED, SEMANTIC_VALUE_UNKNOWN, SIGNAL_TYPE_TRACE,
44    SOURCE_OPENTELEMETRY, TABLE_DATA_MODEL_TRACE_V1, TABLE_DATA_MODEL_TRACE_V2,
45};
46
47use crate::instance::Instance;
48use crate::instance::otlp::trace_semconv::trace_semconv_fixed_type;
49use crate::instance::otlp::trace_types::{
50    PendingTraceColumnRewrite, PreparedTraceColumnRewrites, TraceColumnRewriteError,
51    choose_trace_reconcile_decision, enrich_trace_reconcile_error,
52    is_trace_reconcile_candidate_type, prepare_trace_column_rewrites, push_observed_trace_type,
53    truncate_for_diagnostics,
54};
55use crate::metrics::{OTLP_TRACES_FAILURE_COUNT, OTLP_TRACES_ROWS};
56
57/// Merge converted V2 rows only when their fixed schemas agree.
58fn merge_trace_v2_rows(rows: &mut Rows, batch: Rows) -> ServerResult<()> {
59    if rows.schema.is_empty() {
60        rows.schema = batch.schema;
61    } else {
62        snafu::ensure!(
63            rows.schema == batch.schema,
64            error::InternalSnafu {
65                err_msg: "Trace V2 schema mismatch while merging spans",
66            }
67        );
68    }
69    rows.rows.extend(batch.rows);
70    Ok(())
71}
72
73const TRACE_FAILURE_MESSAGE_LIMIT: usize = 4;
74
75/// Maximum characters of a failure cause echoed to the client and the log.
76const TRACE_FAILURE_CAUSE_LIMIT: usize = 256;
77
78/// Determines how trace ingestion responds to a failure before a write is dispatched.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80enum ChunkFailureReaction {
81    RetryPerSpan,
82    DiscardChunk,
83    Propagate,
84}
85
86impl ChunkFailureReaction {
87    fn as_metric_label(self) -> &'static str {
88        match self {
89            Self::RetryPerSpan => "retry_per_span",
90            Self::DiscardChunk => "discard_chunk",
91            Self::Propagate => "propagate_failure",
92        }
93    }
94}
95
96/// Shared dependencies and request metadata used while ingesting trace chunks.
97struct TraceChunkIngestContext<'a> {
98    pipeline: &'a PipelineWay,
99    table_name: &'a str,
100}
101
102/// Accumulates trace outcomes, auxiliary rows, and bounded failure details.
103struct TraceIngestState {
104    aux_data: TraceAuxData,
105    outcome: TraceIngestOutcome,
106    failure_messages: TraceFailureMessages,
107}
108
109/// Bounded, deduplicated failure details for one trace request.
110///
111/// Occurrences past [`TRACE_FAILURE_MESSAGE_LIMIT`] distinct failures are
112/// counted but their keys are dropped: retaining them would let this state grow
113/// with the number of distinct bad values in a request.
114#[derive(Debug, Default)]
115struct TraceFailureMessages {
116    entries: Vec<TraceFailureEntry>,
117    suppressed_occurrences: usize,
118}
119
120#[derive(Debug)]
121struct TraceFailureEntry {
122    label: &'static str,
123    /// Untruncated: two causes can agree on a truncated prefix and differ
124    /// exactly where the actionable detail is.
125    key: String,
126    message: String,
127    occurrences: usize,
128}
129
130impl TraceFailureMessages {
131    fn is_empty(&self) -> bool {
132        self.entries.is_empty()
133    }
134}
135
136/// How a v1 chunk should be reconciled when it is written.
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138enum TraceChunkSchemaState {
139    Prepared,
140    ReconcilePerChunk,
141    SpanFallbackOnly,
142}
143
144impl TraceChunkSchemaState {
145    fn mark_for_reconcile(&mut self) {
146        if matches!(self, Self::Prepared) {
147            *self = Self::ReconcilePerChunk;
148        }
149    }
150}
151
152/// Sparse representation retained for a v1 trace chunk.
153struct TraceChunkRetry {
154    table_name: String,
155    schema: Vec<ColumnSchema>,
156    rows: Vec<TraceRetryRow>,
157}
158
159/// The projected retry values and identifying metadata for one span.
160struct TraceRetryRow {
161    span_metadata: TraceSpanMetadata,
162    values: Vec<TraceRetryValue>,
163}
164
165/// One retry cell and the logical type to restore before insertion.
166struct TraceRetryValue {
167    column_index: usize,
168    value: Value,
169    schema_type: Option<TraceRetrySchemaType>,
170}
171
172/// The scalar or binary logical type recorded for a retry value.
173enum TraceRetrySchemaType {
174    Scalar(ColumnDataType),
175    Binary(TraceBinaryType),
176}
177
178/// Span identity and operation fields retained after request conversion.
179struct TraceSpanMetadata {
180    trace_id: String,
181    span_id: String,
182    operation: Option<(String, String, String)>,
183}
184
185impl From<&TraceSpan> for TraceSpanMetadata {
186    fn from(span: &TraceSpan) -> Self {
187        Self {
188            trace_id: span.trace_id.clone(),
189            span_id: span.span_id.clone(),
190            operation: span.service_name.as_ref().map(|service_name| {
191                (
192                    service_name.clone(),
193                    span.span_name.clone(),
194                    span.span_kind.clone(),
195                )
196            }),
197        }
198    }
199}
200
201impl TraceSpanMetadata {
202    fn add_to_aux_data(self, aux_data: &mut TraceAuxData) {
203        if let Some((service_name, span_name, span_kind)) = self.operation {
204            aux_data.services.insert(service_name.clone());
205            aux_data
206                .operations
207                .insert((service_name, span_name, span_kind));
208        }
209    }
210}
211
212impl TraceChunkRetry {
213    fn try_new(
214        table_name: String,
215        rows: Rows,
216        span_metadata: Vec<TraceSpanMetadata>,
217        retry_columns: TraceRetryColumns,
218    ) -> ServerResult<Self> {
219        let Rows { schema, rows } = rows;
220        let row_count = rows.len();
221        if row_count != span_metadata.len() {
222            return error::InternalSnafu {
223                err_msg: format!(
224                    "trace row count {} does not match span metadata count {}",
225                    row_count,
226                    span_metadata.len()
227                ),
228            }
229            .fail();
230        }
231
232        let retry_columns_by_index = schema
233            .iter()
234            .map(|column| retry_columns.get(&column.column_name))
235            .collect::<Vec<_>>();
236        let mut fixed_columns = Vec::with_capacity(schema.len());
237        let mut sparse_columns = vec![Vec::new(); rows.len()];
238        for (column_index, retry_column) in retry_columns_by_index.iter().enumerate() {
239            let Some(retry_column) = retry_column else {
240                fixed_columns.push(column_index);
241                continue;
242            };
243            for present_row in &retry_column.present_rows {
244                if *present_row < sparse_columns.len() {
245                    sparse_columns[*present_row].push(column_index);
246                }
247            }
248        }
249
250        let mut retry_rows = Vec::with_capacity(rows.len());
251        for (row_index, (mut row, span_metadata)) in rows.into_iter().zip(span_metadata).enumerate()
252        {
253            if row.values.len() > schema.len() {
254                return error::InternalSnafu {
255                    err_msg: format!(
256                        "trace row column count {} exceeds schema column count {}",
257                        row.values.len(),
258                        schema.len()
259                    ),
260                }
261                .fail();
262            }
263            let sparse_row = &mut sparse_columns[row_index];
264            let mut selected_columns = Vec::with_capacity(fixed_columns.len() + sparse_row.len());
265            selected_columns.extend_from_slice(&fixed_columns);
266            selected_columns.append(sparse_row);
267            selected_columns.sort_unstable();
268            selected_columns.dedup();
269
270            let mut values = Vec::with_capacity(selected_columns.len());
271            for column_index in selected_columns {
272                let column = &schema[column_index];
273                let value = row
274                    .values
275                    .get_mut(column_index)
276                    .map(std::mem::take)
277                    .unwrap_or_default();
278                let value_type = value.value_data.as_ref().and_then(trace_value_datatype);
279                let binary_type = if value_type == Some(ColumnDataType::Binary) {
280                    retry_columns_by_index[column_index].and_then(|retry_column| {
281                        retry_column
282                            .binary_types
283                            .binary_search_by_key(&row_index, |(row_index, _)| *row_index)
284                            .ok()
285                            .map(|index| retry_column.binary_types[index].1)
286                    })
287                } else {
288                    None
289                };
290                let schema_type = binary_type.map(TraceRetrySchemaType::Binary).or_else(|| {
291                    value_type
292                        .filter(|datatype| {
293                            is_trace_reconcile_candidate_type(*datatype)
294                                && ColumnDataType::try_from(column.datatype).ok() != Some(*datatype)
295                        })
296                        .map(TraceRetrySchemaType::Scalar)
297                });
298                values.push(TraceRetryValue {
299                    column_index,
300                    value,
301                    schema_type,
302                });
303            }
304            retry_rows.push(TraceRetryRow {
305                span_metadata,
306                values,
307            });
308        }
309
310        Ok(Self {
311            table_name,
312            schema,
313            rows: retry_rows,
314        })
315    }
316
317    fn to_request(&self) -> ServerResult<RowInsertRequest> {
318        let mut rows = Vec::with_capacity(self.rows.len());
319        for retry_row in &self.rows {
320            let mut values = vec![Value::default(); self.schema.len()];
321            for retry_value in &retry_row.values {
322                let Some(value) = values.get_mut(retry_value.column_index) else {
323                    return error::InternalSnafu {
324                        err_msg: format!(
325                            "trace retry column index {} is out of bounds",
326                            retry_value.column_index
327                        ),
328                    }
329                    .fail();
330                };
331                *value = retry_value.value.clone();
332            }
333            rows.push(api::v1::Row { values });
334        }
335
336        Ok(RowInsertRequest {
337            table_name: self.table_name.clone(),
338            rows: Some(Rows {
339                schema: self.schema.clone(),
340                rows,
341            }),
342        })
343    }
344
345    fn into_single_span_chunks(self) -> ServerResult<Vec<Self>> {
346        let Self {
347            table_name,
348            schema,
349            rows,
350        } = self;
351        rows.into_iter()
352            .map(|row| {
353                let (span_metadata, rows_for_insert) = row.into_rows(&schema)?;
354                Self::try_new(
355                    table_name.clone(),
356                    rows_for_insert,
357                    vec![span_metadata],
358                    TraceRetryColumns::default(),
359                )
360            })
361            .collect()
362    }
363
364    fn add_to_aux_data(self, aux_data: &mut TraceAuxData) {
365        for row in self.rows {
366            row.span_metadata.add_to_aux_data(aux_data);
367        }
368    }
369}
370
371impl TraceRetryRow {
372    fn into_rows(self, schema: &[ColumnSchema]) -> ServerResult<(TraceSpanMetadata, Rows)> {
373        let mut projected_schema = Vec::with_capacity(self.values.len());
374        let mut projected_values = Vec::with_capacity(self.values.len());
375        for retry_value in self.values {
376            let Some(mut column) = schema.get(retry_value.column_index).cloned() else {
377                return error::InternalSnafu {
378                    err_msg: format!(
379                        "trace fallback column index {} is out of bounds",
380                        retry_value.column_index
381                    ),
382                }
383                .fail();
384            };
385            match retry_value.schema_type {
386                Some(TraceRetrySchemaType::Scalar(datatype)) => {
387                    column.datatype = datatype as i32;
388                    column.datatype_extension = None;
389                }
390                Some(TraceRetrySchemaType::Binary(binary_type)) => {
391                    binary_type.apply_to_schema(&mut column);
392                }
393                None => {}
394            }
395            projected_schema.push(column);
396            projected_values.push(retry_value.value);
397        }
398
399        Ok((
400            self.span_metadata,
401            Rows {
402                schema: projected_schema,
403                rows: vec![api::v1::Row {
404                    values: projected_values,
405                }],
406            },
407        ))
408    }
409}
410
411/// Request-wide schema observations collected across all v1 trace chunks.
412#[derive(Default)]
413struct TraceRequestSchema {
414    columns: Vec<TraceColumnRequestSchema>,
415    column_indexes: HashMap<String, usize>,
416}
417
418/// Per-column observations and the resolved target type for a trace request.
419struct TraceColumnRequestSchema {
420    schema: ColumnSchema,
421    batches: BTreeMap<usize, TraceBatchColumnObservation>,
422    target_type: Option<ColumnDataType>,
423}
424
425/// The schema and value types observed for one column in one chunk.
426struct TraceBatchColumnObservation {
427    value_types: Vec<ColumnDataType>,
428    schema_type: ColumnDataType,
429    concrete_type: ConcreteDataType,
430}
431
432/// Maps incompatible columns to the chunk indexes that must use fallback.
433type TraceSchemaExclusions = HashMap<String, HashSet<usize>>;
434
435/// Table columns to add or widen before inserting the prepared chunks.
436struct TraceTablePreAlter {
437    ensure_columns: Vec<ColumnSchema>,
438    modify_float64_columns: Vec<String>,
439    alter_existing: bool,
440}
441
442/// Either a ready table-alter plan or the observations that cannot be unified.
443enum TraceRequestSchemaPlan {
444    Ready(TraceTablePreAlter),
445    IncompatibleObservations(TraceSchemaExclusions),
446}
447
448impl TraceTablePreAlter {
449    fn requires_ddl(&self) -> bool {
450        !self.ensure_columns.is_empty() || !self.modify_float64_columns.is_empty()
451    }
452}
453
454impl TraceRequestSchema {
455    fn observe_batch_schema(
456        &mut self,
457        batch_index: usize,
458        schema: &[ColumnSchema],
459        batch_schema: &TraceBatchSchema,
460    ) {
461        for column in schema {
462            if batch_schema.has_incompatible_logical_types_for(&column.column_name) {
463                continue;
464            }
465            let observed_types = batch_schema.value_types(&column.column_name);
466            self.observe_trace_column(batch_index, column, None);
467            if let Some(observed_types) = observed_types {
468                for value_type in observed_types {
469                    self.observe_trace_column(batch_index, column, Some(*value_type));
470                }
471            }
472        }
473    }
474
475    fn observe_retry_chunk(
476        &mut self,
477        batch_index: usize,
478        chunk: &TraceChunkRetry,
479    ) -> ServerResult<()> {
480        // Split chunks already carry row-projected schemas; untouched chunks keep
481        // their original batch schema while observations stay sparse.
482        for row in &chunk.rows {
483            for retry_value in &row.values {
484                let Some(column) = chunk.schema.get(retry_value.column_index) else {
485                    return error::InternalSnafu {
486                        err_msg: format!(
487                            "trace retry column index {} is out of bounds",
488                            retry_value.column_index
489                        ),
490                    }
491                    .fail();
492                };
493                let value_type = retry_value
494                    .value
495                    .value_data
496                    .as_ref()
497                    .and_then(trace_value_datatype);
498                self.observe_trace_column(batch_index, column, value_type);
499            }
500        }
501        Ok(())
502    }
503
504    fn observe_trace_column(
505        &mut self,
506        batch_index: usize,
507        schema: &ColumnSchema,
508        value_type: Option<ColumnDataType>,
509    ) {
510        let column_schema = self.observe_column(schema);
511        if let Ok(current_type) = ColumnDataType::try_from(schema.datatype) {
512            let observation = column_schema.batches.entry(batch_index).or_insert_with(|| {
513                let wrapper =
514                    ColumnDataTypeWrapper::new(current_type, schema.datatype_extension.clone());
515                TraceBatchColumnObservation {
516                    value_types: Vec::new(),
517                    schema_type: current_type,
518                    concrete_type: ConcreteDataType::from(wrapper),
519                }
520            });
521            push_observed_trace_type(&mut observation.value_types, current_type);
522        }
523        if let Some(value_type) = value_type {
524            column_schema.observe_type(batch_index, value_type);
525        }
526    }
527
528    fn incompatible_schema_observations(
529        &self,
530        table_schema: Option<&datatypes::schema::Schema>,
531    ) -> TraceSchemaExclusions {
532        let mut exclusions = HashMap::new();
533        for column in &self.columns {
534            let existing_type = table_schema
535                .and_then(|schema| schema.column_schema_by_name(&column.schema.column_name))
536                .and_then(|table_col| {
537                    ColumnDataTypeWrapper::try_from(table_col.data_type.clone())
538                        .ok()
539                        .map(|wrapper| {
540                            let datatype = wrapper.datatype();
541                            (datatype, ConcreteDataType::from(wrapper))
542                        })
543                });
544            let incompatible_batches = column.incompatible_schema_batches(existing_type);
545            if !incompatible_batches.is_empty() {
546                exclusions.insert(column.schema.column_name.clone(), incompatible_batches);
547            }
548        }
549        exclusions
550    }
551
552    fn resolve_table_schema(
553        &mut self,
554        table_schema: Option<&datatypes::schema::Schema>,
555    ) -> TraceRequestSchemaPlan {
556        let mut pre_alter = TraceTablePreAlter {
557            ensure_columns: Vec::new(),
558            modify_float64_columns: Vec::new(),
559            alter_existing: table_schema.is_some(),
560        };
561
562        for column in &mut self.columns {
563            let Some(current_type) = ColumnDataType::try_from(column.schema.datatype).ok() else {
564                continue;
565            };
566            let observed_types = column.observed_types();
567
568            let existing_type = table_schema
569                .and_then(|schema| schema.column_schema_by_name(&column.schema.column_name))
570                .and_then(|table_col| {
571                    ColumnDataTypeWrapper::try_from(table_col.data_type.clone())
572                        .ok()
573                        .map(|wrapper| wrapper.datatype())
574                });
575            let fixed_type = trace_semconv_fixed_type(&column.schema.column_name);
576
577            let needs_reconcile = observed_types
578                .iter()
579                .copied()
580                .any(is_trace_reconcile_candidate_type)
581                || existing_type
582                    .map(is_trace_reconcile_candidate_type)
583                    .unwrap_or(false)
584                || fixed_type.is_some();
585
586            let target_type = if needs_reconcile {
587                let decision = match choose_trace_reconcile_decision(
588                    &column.schema.column_name,
589                    &observed_types,
590                    existing_type,
591                ) {
592                    Ok(decision) => decision,
593                    Err(_) => {
594                        return TraceRequestSchemaPlan::IncompatibleObservations(HashMap::from([
595                            (
596                                column.schema.column_name.clone(),
597                                column.batches.keys().copied().collect(),
598                            ),
599                        ]));
600                    }
601                };
602                decision
603                    .map(|decision| {
604                        if decision.requires_alter() {
605                            pre_alter
606                                .modify_float64_columns
607                                .push(column.schema.column_name.clone());
608                        }
609                        decision.target_type()
610                    })
611                    .unwrap_or(current_type)
612            } else {
613                current_type
614            };
615
616            column.target_type = Some(target_type);
617
618            if table_schema
619                .map(|schema| {
620                    schema
621                        .column_schema_by_name(&column.schema.column_name)
622                        .is_none()
623                })
624                .unwrap_or(false)
625            {
626                pre_alter.ensure_columns.push(column.target_schema());
627            }
628        }
629
630        if table_schema.is_none() {
631            pre_alter.ensure_columns = self
632                .columns
633                .iter()
634                .map(TraceColumnRequestSchema::target_schema)
635                .collect();
636        }
637
638        TraceRequestSchemaPlan::Ready(pre_alter)
639    }
640
641    fn prepare_request_rewrite(
642        &self,
643        batch_index: usize,
644        request: &RowInsertRequest,
645    ) -> Result<Option<PreparedTraceColumnRewrites>, TraceColumnRewriteError> {
646        let Some(rows) = request.rows.as_ref() else {
647            return Ok(None);
648        };
649        let pending_rewrites = self.pending_batch_rewrites(batch_index, rows);
650        if pending_rewrites.is_empty() {
651            return Ok(None);
652        }
653        prepare_trace_column_rewrites(&rows.rows, pending_rewrites, &request.table_name).map(Some)
654    }
655
656    fn apply_request_rewrite(
657        request: &mut RowInsertRequest,
658        prepared: PreparedTraceColumnRewrites,
659    ) {
660        if let Some(rows) = request.rows.as_mut() {
661            prepared.apply(rows);
662        }
663    }
664
665    fn pending_batch_rewrites(
666        &self,
667        batch_index: usize,
668        rows: &api::v1::Rows,
669    ) -> Vec<PendingTraceColumnRewrite> {
670        let mut pending_rewrites = Vec::new();
671        for (col_idx, col_schema) in rows.schema.iter().enumerate() {
672            let Some(global_idx) = self.column_indexes.get(&col_schema.column_name).copied() else {
673                continue;
674            };
675            let global_column = &self.columns[global_idx];
676            let Some(target_type) = global_column.target_type() else {
677                continue;
678            };
679            if !global_column
680                .batches
681                .get(&batch_index)
682                .is_some_and(|observation| {
683                    observation
684                        .value_types
685                        .iter()
686                        .any(|datatype| *datatype != target_type)
687                })
688            {
689                continue;
690            }
691            pending_rewrites.push(PendingTraceColumnRewrite {
692                col_idx,
693                target_type,
694                column_name: global_column.schema.column_name.clone(),
695            });
696        }
697
698        pending_rewrites
699    }
700
701    #[cfg(test)]
702    fn remove_batches(&mut self, batch_indexes: &HashSet<usize>) {
703        for column in &mut self.columns {
704            column
705                .batches
706                .retain(|batch_index, _| !batch_indexes.contains(batch_index));
707            column.target_type = None;
708        }
709        self.columns.retain(|column| !column.batches.is_empty());
710
711        self.column_indexes.clear();
712        for (index, column) in self.columns.iter().enumerate() {
713            self.column_indexes
714                .insert(column.schema.column_name.clone(), index);
715        }
716    }
717
718    #[cfg(test)]
719    fn resolved_target_types(&self) -> Vec<Option<ColumnDataType>> {
720        self.columns
721            .iter()
722            .map(|column| column.target_type)
723            .collect()
724    }
725
726    fn observe_column(&mut self, schema: &ColumnSchema) -> &mut TraceColumnRequestSchema {
727        if let Some(index) = self.column_indexes.get(&schema.column_name).copied() {
728            return &mut self.columns[index];
729        }
730
731        let index = self.columns.len();
732        self.columns.push(TraceColumnRequestSchema {
733            schema: schema.clone(),
734            batches: BTreeMap::new(),
735            target_type: None,
736        });
737        self.column_indexes
738            .insert(schema.column_name.clone(), index);
739        &mut self.columns[index]
740    }
741}
742
743impl TraceColumnRequestSchema {
744    fn observe_type(&mut self, batch_index: usize, datatype: ColumnDataType) {
745        if let Some(observation) = self.batches.get_mut(&batch_index) {
746            push_observed_trace_type(&mut observation.value_types, datatype);
747        }
748    }
749
750    fn observed_types(&self) -> Vec<ColumnDataType> {
751        let mut observed_types = Vec::new();
752        for datatype in self
753            .batches
754            .values()
755            .flat_map(|observation| &observation.value_types)
756        {
757            push_observed_trace_type(&mut observed_types, *datatype);
758        }
759        observed_types
760    }
761
762    fn incompatible_schema_batches(
763        &self,
764        existing_type: Option<(ColumnDataType, ConcreteDataType)>,
765    ) -> HashSet<usize> {
766        let mut incompatible_batches = HashSet::new();
767        if let Some((existing_datatype, existing_concrete_type)) = existing_type {
768            for (batch_index, observation) in &self.batches {
769                if trace_logical_types_incompatible(
770                    observation.schema_type,
771                    &observation.concrete_type,
772                    existing_datatype,
773                    &existing_concrete_type,
774                ) {
775                    incompatible_batches.insert(*batch_index);
776                }
777            }
778            return incompatible_batches;
779        }
780
781        let mut reconcile_candidates = Vec::<&TraceBatchColumnObservation>::new();
782        for observation in self
783            .batches
784            .values()
785            .filter(|observation| is_trace_reconcile_candidate_type(observation.schema_type))
786        {
787            if !reconcile_candidates.iter().any(|candidate| {
788                candidate.schema_type == observation.schema_type
789                    && candidate.concrete_type == observation.concrete_type
790            }) {
791                reconcile_candidates.push(observation);
792            }
793        }
794        if !reconcile_candidates.is_empty() {
795            for (batch_index, observation) in &self.batches {
796                if reconcile_candidates.iter().any(|candidate| {
797                    trace_logical_types_incompatible(
798                        observation.schema_type,
799                        &observation.concrete_type,
800                        candidate.schema_type,
801                        &candidate.concrete_type,
802                    )
803                }) {
804                    incompatible_batches.insert(*batch_index);
805                }
806            }
807            return incompatible_batches;
808        }
809
810        let mut schemas = Vec::<(ColumnDataType, ConcreteDataType, bool)>::new();
811        for observation in self.batches.values() {
812            if schemas.iter().any(|(observed_datatype, observed_type, _)| {
813                *observed_datatype == observation.schema_type
814                    && observed_type == &observation.concrete_type
815            }) {
816                continue;
817            }
818
819            let mut conflicts = false;
820            for (observed_datatype, observed_type, observed_conflicts) in &mut schemas {
821                if trace_logical_types_incompatible(
822                    *observed_datatype,
823                    observed_type,
824                    observation.schema_type,
825                    &observation.concrete_type,
826                ) {
827                    *observed_conflicts = true;
828                    conflicts = true;
829                }
830            }
831            schemas.push((
832                observation.schema_type,
833                observation.concrete_type.clone(),
834                conflicts,
835            ));
836        }
837
838        for (batch_index, observation) in &self.batches {
839            if schemas
840                .iter()
841                .any(|(observed_datatype, observed_type, conflict)| {
842                    *observed_datatype == observation.schema_type
843                        && observed_type == &observation.concrete_type
844                        && *conflict
845                })
846            {
847                incompatible_batches.insert(*batch_index);
848            }
849        }
850
851        incompatible_batches
852    }
853
854    fn target_type(&self) -> Option<ColumnDataType> {
855        self.target_type
856            .or_else(|| ColumnDataType::try_from(self.schema.datatype).ok())
857    }
858
859    fn target_schema(&self) -> ColumnSchema {
860        let mut schema = self.schema.clone();
861        if let Some(target_type) = self.target_type() {
862            schema.datatype = target_type as i32;
863        }
864        schema
865    }
866}
867
868impl Instance {
869    /// Ingest OTLP trace spans with chunk-level writes and span-level fallback on
870    /// deterministic chunk failures.
871    pub(super) async fn ingest_trace_spans(
872        &self,
873        pipeline: &PipelineWay,
874        table_name: String,
875        groups: Vec<TraceSpanGroup>,
876        conventions: &str,
877        ctx: QueryContextRef,
878    ) -> ServerResult<TraceIngestOutcome> {
879        let is_trace_v1_model = matches!(pipeline, PipelineWay::OtlpTraceDirectV1);
880        let data_model = match pipeline {
881            PipelineWay::OtlpTraceDirectV1 => Some(TABLE_DATA_MODEL_TRACE_V1),
882            PipelineWay::OtlpTraceDirectV2 => Some(TABLE_DATA_MODEL_TRACE_V2),
883            _ => None,
884        };
885
886        // Only the main span table gets the identity; the derived `_services` /
887        // `_operations` lookup tables keep the unstamped `ctx`.
888        let main_ctx = {
889            let mut c = (*ctx).clone();
890            c.set_extension(SEMANTIC_SIGNAL_TYPE, SIGNAL_TYPE_TRACE);
891            c.set_extension(SEMANTIC_SOURCE, SOURCE_OPENTELEMETRY);
892            // `service_name` is a tag column in both trace models, so the main span
893            // table declares the logical `service` entity (Layer 1 auto-stamp).
894            c.set_extension(SEMANTIC_ENTITY_SERVICE_ID, SERVICE_NAME_COLUMN);
895            if let Some(data_model) = data_model {
896                c.set_extension(SEMANTIC_PIPELINE, data_model);
897                c.set_extension(SEMANTIC_TRACE_CONVENTIONS, conventions);
898            }
899            Arc::new(c)
900        };
901
902        if data_model.is_some()
903            && let Some(table) = self
904                .catalog_manager
905                .table(
906                    ctx.current_catalog(),
907                    &ctx.current_schema(),
908                    &table_name,
909                    None,
910                )
911                .await?
912        {
913            operator::insert::validate_trace_table_model(&table.table_info(), &main_ctx)?;
914        }
915
916        let ingest_ctx = TraceChunkIngestContext {
917            pipeline,
918            table_name: &table_name,
919        };
920        let mut ingest_state = TraceIngestState {
921            aux_data: TraceAuxData::default(),
922            outcome: TraceIngestOutcome::default(),
923            failure_messages: TraceFailureMessages::default(),
924        };
925
926        let main_result: ServerResult<()> = async {
927            if is_trace_v1_model {
928                let mut request_schema = TraceRequestSchema::default();
929                let mut chunks = Vec::new();
930                let mut chunk_states = Vec::new();
931                // Convert each chunk once, then retain only its sparse values while
932                // request-wide schema planning runs.
933                for chunk in groups
934                    .into_iter()
935                    .flat_map(|group| chunk_owned(group.spans, self.trace_ingest_chunk_size))
936                {
937                    let batch_index = chunks.len();
938                    let span_metadata = chunk.iter().map(TraceSpanMetadata::from).collect();
939                    let (table_data, batch_schema) =
940                        otlp::trace::v1::v1_to_main_table_data_with_schema(chunk)?;
941                    let schema_state = if batch_schema.has_incompatible_logical_types() {
942                        TraceChunkSchemaState::SpanFallbackOnly
943                    } else {
944                        TraceChunkSchemaState::Prepared
945                    };
946                    let (schema, rows) = table_data.into_schema_and_rows();
947                    if schema_state == TraceChunkSchemaState::Prepared {
948                        request_schema.observe_batch_schema(batch_index, &schema, &batch_schema);
949                    }
950                    chunks.push(TraceChunkRetry::try_new(
951                        ingest_ctx.table_name.to_string(),
952                        Rows { schema, rows },
953                        span_metadata,
954                        batch_schema.into_retry_columns(),
955                    )?);
956                    chunk_states.push(schema_state);
957                }
958
959                if !chunks.is_empty() {
960                    self.ingest_trace_v1_prepared_chunks(
961                        &ingest_ctx,
962                        request_schema,
963                        chunks,
964                        chunk_states,
965                        main_ctx.clone(),
966                        &mut ingest_state,
967                    )
968                    .await?;
969                }
970            } else {
971                for group in groups {
972                    let chunks = chunk_owned(group.spans, self.trace_ingest_chunk_size);
973                    for chunk in chunks {
974                        self.ingest_trace_chunk(
975                            &ingest_ctx,
976                            chunk,
977                            main_ctx.clone(),
978                            &mut ingest_state,
979                        )
980                        .await?;
981                    }
982                }
983            }
984
985            Ok(())
986        }
987        .await;
988
989        OTLP_TRACES_ROWS.inc_by(ingest_state.outcome.accepted_spans as u64);
990
991        if !ingest_state.aux_data.is_empty() {
992            // Auxiliary trace tables are derived from spans whose main-table
993            // writes are already confirmed, so they never create new accepted
994            // spans and they do not affect rejected span counts.
995            let aux_requests = otlp::trace::to_grpc_insert_requests_for_aux_tables(
996                std::mem::take(&mut ingest_state.aux_data),
997                ingest_ctx.pipeline,
998                ingest_ctx.table_name,
999            );
1000
1001            match aux_requests {
1002                Ok((aux_requests, _)) if !aux_requests.inserts.is_empty() => {
1003                    match self
1004                        .insert_trace_requests(aux_requests, ingest_ctx.pipeline, ctx)
1005                        .await
1006                    {
1007                        Ok(output) => {
1008                            Self::add_trace_write_cost(&mut ingest_state.outcome, output.meta.cost);
1009                        }
1010                        Err(err) => {
1011                            Self::push_trace_failure_message(
1012                                &mut ingest_state.failure_messages,
1013                                "aux_table_update_failed",
1014                                err.status_code().as_ref(),
1015                                format!(
1016                                    "Auxiliary trace tables were not fully updated ({})",
1017                                    err.status_code().as_ref()
1018                                ),
1019                            );
1020                        }
1021                    }
1022                }
1023                // Preserve the existing conversion-error behavior after a
1024                // successful main write, but never mask an earlier main error.
1025                Err(err) if main_result.is_ok() => return Err(err),
1026                Err(err) => Self::push_trace_failure_message(
1027                    &mut ingest_state.failure_messages,
1028                    "aux_table_update_failed",
1029                    err.status_code().as_ref(),
1030                    format!(
1031                        "Auxiliary trace tables were not fully updated ({})",
1032                        err.status_code().as_ref()
1033                    ),
1034                ),
1035                Ok(_) => {}
1036            }
1037        }
1038
1039        main_result?;
1040
1041        ingest_state.outcome.error_message = Self::finish_trace_failure_message(
1042            ingest_state.outcome.accepted_spans,
1043            ingest_state.outcome.rejected_spans,
1044            ingest_state.failure_messages,
1045        );
1046
1047        if let Some(error_message) = &ingest_state.outcome.error_message {
1048            let accepted_spans = ingest_state.outcome.accepted_spans;
1049            let rejected_spans = ingest_state.outcome.rejected_spans;
1050            // A partial success repeats every export interval while the sender
1051            // keeps emitting the bad value, so only a fully rejected request is
1052            // worth a warning. Either way the detail reaches the sender: as an
1053            // OTLP partial success, or as the status message of a 400.
1054            //
1055            // The detail embeds attribute keys verbatim, so it goes out as a
1056            // Debug field; interpolating it would let a newline in an attribute
1057            // key forge log lines.
1058            if accepted_spans == 0 && rejected_spans > 0 {
1059                warn!(
1060                    table_name = ingest_ctx.table_name,
1061                    rejected_spans,
1062                    error_message = ?error_message,
1063                    "OTLP trace ingest rejected every span"
1064                );
1065            } else {
1066                debug!(
1067                    table_name = ingest_ctx.table_name,
1068                    accepted_spans,
1069                    rejected_spans,
1070                    error_message = ?error_message,
1071                    "OTLP trace ingest reported failures"
1072                );
1073            }
1074        }
1075
1076        Ok(ingest_state.outcome)
1077    }
1078
1079    /// Ingest one owned trace chunk so successful spans can be moved into the
1080    /// accepted set without extra cloning.
1081    async fn ingest_trace_chunk(
1082        &self,
1083        ingest_ctx: &TraceChunkIngestContext<'_>,
1084        mut chunk: Vec<TraceSpan>,
1085        ctx: QueryContextRef,
1086        ingest_state: &mut TraceIngestState,
1087    ) -> ServerResult<()> {
1088        // Try the fast path first so healthy batches keep their original
1089        // throughput and write amplification stays low.
1090        let convert = |spans: &[TraceSpan]| {
1091            otlp::trace::to_grpc_insert_requests_from_spans(
1092                spans,
1093                ingest_ctx.pipeline,
1094                ingest_ctx.table_name,
1095            )
1096        };
1097        let (requests, chunk_rows) = match convert(&chunk) {
1098            Ok(x) => x,
1099            Err(_) if matches!(ingest_ctx.pipeline, PipelineWay::OtlpTraceDirectV2) => {
1100                let mut rows = Rows {
1101                    rows: Vec::with_capacity(chunk.len()),
1102                    ..Default::default()
1103                };
1104
1105                for span in std::mem::take(&mut chunk) {
1106                    let result = convert(std::slice::from_ref(&span));
1107                    match result {
1108                        Ok((requests, _)) => {
1109                            // V2 must always emit the same table and fixed column order.
1110                            for batch in requests.inserts.into_iter().filter_map(|r| r.rows) {
1111                                merge_trace_v2_rows(&mut rows, batch)?;
1112                            }
1113                            chunk.push(span);
1114                        }
1115                        Err(err) => {
1116                            ingest_state.outcome.rejected_spans += 1;
1117
1118                            let (cause, shown_cause) = Self::trace_failure_cause(&err);
1119                            Self::push_trace_failure_message(
1120                                &mut ingest_state.failure_messages,
1121                                "span_rejected",
1122                                &cause,
1123                                format!(
1124                                    "Rejected span {}:{}: {}",
1125                                    span.trace_id, span.span_id, shown_cause
1126                                ),
1127                            );
1128                        }
1129                    }
1130                }
1131
1132                if rows.rows.is_empty() {
1133                    return Ok(());
1134                }
1135                let chunk_rows = rows.rows.len();
1136                let requests = RowInsertRequests {
1137                    inserts: vec![RowInsertRequest {
1138                        table_name: ingest_ctx.table_name.to_string(),
1139                        rows: Some(rows),
1140                    }],
1141                };
1142                (requests, chunk_rows)
1143            }
1144            Err(e) => return Err(e),
1145        };
1146
1147        let output = match self
1148            .insert_trace_requests(requests, ingest_ctx.pipeline, ctx)
1149            .await
1150        {
1151            Ok(output) => output,
1152            Err(err) => {
1153                // A distributed insert can partially commit before returning an
1154                // error, so retrying or rejecting the whole chunk is unsafe.
1155                Self::push_trace_failure_message(
1156                    &mut ingest_state.failure_messages,
1157                    ChunkFailureReaction::Propagate.as_metric_label(),
1158                    err.status_code().as_ref(),
1159                    format!(
1160                        "Propagating chunk write failure ({})",
1161                        err.status_code().as_ref()
1162                    ),
1163                );
1164                return Err(err);
1165            }
1166        };
1167
1168        Self::add_trace_write_cost(&mut ingest_state.outcome, output.meta.cost);
1169        ingest_state.outcome.accepted_spans += chunk_rows;
1170        for span in &chunk {
1171            ingest_state.aux_data.observe_span(span);
1172        }
1173
1174        Ok(())
1175    }
1176
1177    async fn ingest_trace_v1_prepared_chunks(
1178        &self,
1179        ingest_ctx: &TraceChunkIngestContext<'_>,
1180        mut request_schema: TraceRequestSchema,
1181        mut chunks: Vec<TraceChunkRetry>,
1182        mut chunk_states: Vec<TraceChunkSchemaState>,
1183        ctx: QueryContextRef,
1184        ingest_state: &mut TraceIngestState,
1185    ) -> ServerResult<()> {
1186        if let Err(err) = self
1187            .prepare_trace_v1_request_schema(
1188                ingest_ctx.table_name,
1189                &mut request_schema,
1190                &mut chunks,
1191                &mut chunk_states,
1192                &ctx,
1193            )
1194            .await
1195        {
1196            if matches!(
1197                Self::classify_trace_prewrite_failure(err.status_code(), err.is_retryable()),
1198                ChunkFailureReaction::Propagate
1199            ) {
1200                return Err(err);
1201            }
1202
1203            // Span-specific conversion failures were already isolated during
1204            // prevalidation. An error escaping request-wide schema preparation
1205            // applies to every remaining span, so repeating it per span cannot
1206            // recover valid data.
1207            let span_count = chunks.iter().map(|chunk| chunk.rows.len()).sum::<usize>();
1208            ingest_state.outcome.rejected_spans += span_count;
1209            let (cause, shown_cause) = Self::trace_failure_cause(&err);
1210            Self::push_trace_failure_message(
1211                &mut ingest_state.failure_messages,
1212                ChunkFailureReaction::DiscardChunk.as_metric_label(),
1213                // Merging discards of different sizes would make the reported
1214                // count times its occurrences wrong.
1215                &format!("{span_count}:{cause}"),
1216                format!(
1217                    "Discarded {} spans after pre-write request failure ({}): {}",
1218                    span_count,
1219                    err.status_code().as_ref(),
1220                    shown_cause
1221                ),
1222            );
1223            return Ok(());
1224        }
1225
1226        for (batch_index, (retry, schema_state)) in chunks.into_iter().zip(chunk_states).enumerate()
1227        {
1228            self.ingest_trace_v1_chunk(
1229                &request_schema,
1230                batch_index,
1231                retry,
1232                schema_state,
1233                ctx.clone(),
1234                ingest_state,
1235            )
1236            .await?;
1237        }
1238
1239        Ok(())
1240    }
1241
1242    async fn ingest_trace_v1_chunk(
1243        &self,
1244        request_schema: &TraceRequestSchema,
1245        batch_index: usize,
1246        retry: TraceChunkRetry,
1247        mut schema_state: TraceChunkSchemaState,
1248        ctx: QueryContextRef,
1249        ingest_state: &mut TraceIngestState,
1250    ) -> ServerResult<()> {
1251        if schema_state == TraceChunkSchemaState::SpanFallbackOnly {
1252            Self::push_trace_failure_message(
1253                &mut ingest_state.failure_messages,
1254                ChunkFailureReaction::RetryPerSpan.as_metric_label(),
1255                "incompatible_binary_and_json",
1256                "Chunk fallback triggered by incompatible binary and JSON values".to_string(),
1257            );
1258            return self
1259                .ingest_trace_v1_rows_span_by_span(retry, ctx, ingest_state)
1260                .await;
1261        }
1262
1263        let mut request = retry.to_request()?;
1264        let _ = Self::prepare_trace_v1_chunk_rewrite(
1265            request_schema,
1266            batch_index,
1267            &mut request,
1268            &mut schema_state,
1269        )?;
1270        let span_count = retry.rows.len();
1271        let mut requests = RowInsertRequests {
1272            inserts: vec![request],
1273        };
1274        if schema_state == TraceChunkSchemaState::ReconcilePerChunk
1275            && let Err(err) = self.reconcile_trace_column_types(&mut requests, &ctx).await
1276        {
1277            match Self::classify_trace_prewrite_failure(err.status_code(), err.is_retryable()) {
1278                ChunkFailureReaction::RetryPerSpan => {
1279                    Self::push_trace_failure_message(
1280                        &mut ingest_state.failure_messages,
1281                        ChunkFailureReaction::RetryPerSpan.as_metric_label(),
1282                        err.status_code().as_ref(),
1283                        format!("Chunk fallback triggered by {}", err.status_code().as_ref()),
1284                    );
1285                    return self
1286                        .ingest_trace_v1_rows_span_by_span(retry, ctx, ingest_state)
1287                        .await;
1288                }
1289                ChunkFailureReaction::DiscardChunk => {
1290                    ingest_state.outcome.rejected_spans += span_count;
1291                    let (cause, shown_cause) = Self::trace_failure_cause(&err);
1292                    Self::push_trace_failure_message(
1293                        &mut ingest_state.failure_messages,
1294                        ChunkFailureReaction::DiscardChunk.as_metric_label(),
1295                        // Chunks discarded for one cause differ in size; merging
1296                        // them would make the reported count times its
1297                        // occurrences wrong.
1298                        &format!("{span_count}:{cause}"),
1299                        format!(
1300                            "Discarded {} spans after pre-write chunk failure ({}): {}",
1301                            span_count,
1302                            err.status_code().as_ref(),
1303                            shown_cause
1304                        ),
1305                    );
1306                    return Ok(());
1307                }
1308                ChunkFailureReaction::Propagate => {
1309                    Self::push_trace_failure_message(
1310                        &mut ingest_state.failure_messages,
1311                        ChunkFailureReaction::Propagate.as_metric_label(),
1312                        err.status_code().as_ref(),
1313                        format!(
1314                            "Propagating pre-write chunk failure ({})",
1315                            err.status_code().as_ref()
1316                        ),
1317                    );
1318                    return Err(err);
1319                }
1320            }
1321        }
1322
1323        let output = match self
1324            .handle_trace_inserts(requests, ctx)
1325            .await
1326            .map_err(BoxedError::new)
1327            .context(error::ExecuteGrpcQuerySnafu)
1328        {
1329            Ok(output) => output,
1330            Err(err) => {
1331                // Do not retry after dispatch: writes to other peers may already
1332                // have committed even when the aggregate request returns an error.
1333                Self::push_trace_failure_message(
1334                    &mut ingest_state.failure_messages,
1335                    ChunkFailureReaction::Propagate.as_metric_label(),
1336                    err.status_code().as_ref(),
1337                    format!(
1338                        "Propagating chunk write failure ({})",
1339                        err.status_code().as_ref()
1340                    ),
1341                );
1342                return Err(err);
1343            }
1344        };
1345
1346        Self::add_trace_write_cost(&mut ingest_state.outcome, output.meta.cost);
1347        ingest_state.outcome.accepted_spans += span_count;
1348        retry.add_to_aux_data(&mut ingest_state.aux_data);
1349
1350        Ok(())
1351    }
1352
1353    async fn ingest_trace_v1_rows_span_by_span(
1354        &self,
1355        retry: TraceChunkRetry,
1356        ctx: QueryContextRef,
1357        ingest_state: &mut TraceIngestState,
1358    ) -> ServerResult<()> {
1359        for row in retry.rows {
1360            let (span, rows) = row.into_rows(&retry.schema)?;
1361            let mut requests = RowInsertRequests {
1362                inserts: vec![RowInsertRequest {
1363                    table_name: retry.table_name.clone(),
1364                    rows: Some(rows),
1365                }],
1366            };
1367
1368            if let Err(err) = self.reconcile_trace_column_types(&mut requests, &ctx).await {
1369                if matches!(
1370                    Self::classify_trace_prewrite_failure(err.status_code(), err.is_retryable()),
1371                    ChunkFailureReaction::Propagate
1372                ) {
1373                    Self::push_trace_failure_message(
1374                        &mut ingest_state.failure_messages,
1375                        ChunkFailureReaction::Propagate.as_metric_label(),
1376                        err.status_code().as_ref(),
1377                        format!(
1378                            "Propagating pre-write span failure for {}:{} ({})",
1379                            span.trace_id,
1380                            span.span_id,
1381                            err.status_code().as_ref()
1382                        ),
1383                    );
1384                    return Err(err);
1385                }
1386
1387                ingest_state.outcome.rejected_spans += 1;
1388                // Dedup on the cause, not the span id: one bad column rejects
1389                // every span and would otherwise fill the entry list.
1390                let (cause, shown_cause) = Self::trace_failure_cause(&err);
1391                Self::push_trace_failure_message(
1392                    &mut ingest_state.failure_messages,
1393                    "span_rejected",
1394                    &cause,
1395                    format!(
1396                        "Rejected span {}:{} ({}): {}",
1397                        span.trace_id,
1398                        span.span_id,
1399                        err.status_code().as_ref(),
1400                        shown_cause
1401                    ),
1402                );
1403                continue;
1404            }
1405
1406            let output = match self
1407                .handle_trace_inserts(requests, ctx.clone())
1408                .await
1409                .map_err(BoxedError::new)
1410                .context(error::ExecuteGrpcQuerySnafu)
1411            {
1412                Ok(output) => output,
1413                Err(err) => {
1414                    Self::push_trace_failure_message(
1415                        &mut ingest_state.failure_messages,
1416                        ChunkFailureReaction::Propagate.as_metric_label(),
1417                        err.status_code().as_ref(),
1418                        format!(
1419                            "Propagating span write failure for {}:{} ({})",
1420                            span.trace_id,
1421                            span.span_id,
1422                            err.status_code().as_ref()
1423                        ),
1424                    );
1425                    return Err(err);
1426                }
1427            };
1428
1429            Self::add_trace_write_cost(&mut ingest_state.outcome, output.meta.cost);
1430            ingest_state.outcome.accepted_spans += 1;
1431            span.add_to_aux_data(&mut ingest_state.aux_data);
1432        }
1433
1434        Ok(())
1435    }
1436
1437    /// Reconcile and insert one trace request batch.
1438    async fn insert_trace_requests(
1439        &self,
1440        mut requests: RowInsertRequests,
1441        pipeline: &PipelineWay,
1442        ctx: QueryContextRef,
1443    ) -> ServerResult<Output> {
1444        if matches!(pipeline, PipelineWay::OtlpTraceDirectV1) {
1445            self.reconcile_trace_column_types(&mut requests, &ctx)
1446                .await?;
1447        }
1448        if matches!(
1449            pipeline,
1450            PipelineWay::OtlpTraceDirectV1 | PipelineWay::OtlpTraceDirectV2
1451        ) {
1452            self.handle_trace_inserts(requests, ctx)
1453                .await
1454                .map_err(BoxedError::new)
1455                .context(error::ExecuteGrpcQuerySnafu)
1456        } else {
1457            self.handle_log_inserts(requests, ctx)
1458                .await
1459                .map_err(BoxedError::new)
1460                .context(error::ExecuteGrpcQuerySnafu)
1461        }
1462    }
1463
1464    async fn plan_trace_v1_request_schema(
1465        &self,
1466        table_name: &str,
1467        request_schema: &mut TraceRequestSchema,
1468        ctx: &QueryContextRef,
1469    ) -> ServerResult<TraceRequestSchemaPlan> {
1470        let catalog = ctx.current_catalog();
1471        let schema = ctx.current_schema();
1472        let table = self
1473            .catalog_manager
1474            .table(catalog, &schema, table_name, None)
1475            .await?;
1476        if let Some(table) = &table {
1477            operator::insert::validate_trace_table_model(&table.table_info(), ctx)?;
1478        }
1479        let table_schema = table.as_ref().map(|table| table.schema());
1480        let exclusions = request_schema.incompatible_schema_observations(table_schema.as_deref());
1481        if !exclusions.is_empty() {
1482            return Ok(TraceRequestSchemaPlan::IncompatibleObservations(exclusions));
1483        }
1484
1485        Ok(request_schema.resolve_table_schema(table_schema.as_deref()))
1486    }
1487
1488    async fn prepare_trace_v1_request_schema(
1489        &self,
1490        table_name: &str,
1491        request_schema: &mut TraceRequestSchema,
1492        chunks: &mut Vec<TraceChunkRetry>,
1493        chunk_states: &mut Vec<TraceChunkSchemaState>,
1494        ctx: &QueryContextRef,
1495    ) -> ServerResult<()> {
1496        loop {
1497            // Fixed semconv targets are known before schema planning. Isolate invalid
1498            // values before they can affect conflict resolution or DDL.
1499            let exclusions = Self::prevalidate_trace_v1_fixed_columns(chunks, chunk_states)?;
1500            if !exclusions.is_empty() {
1501                Self::exclude_trace_v1_schema_observations(
1502                    request_schema,
1503                    chunks,
1504                    chunk_states,
1505                    &exclusions,
1506                )?;
1507                continue;
1508            }
1509
1510            if request_schema.columns.is_empty() {
1511                return Ok(());
1512            }
1513
1514            let pre_alter = match self
1515                .plan_trace_v1_request_schema(table_name, request_schema, ctx)
1516                .await?
1517            {
1518                TraceRequestSchemaPlan::Ready(pre_alter) => pre_alter,
1519                TraceRequestSchemaPlan::IncompatibleObservations(exclusions) => {
1520                    Self::exclude_trace_v1_schema_observations(
1521                        request_schema,
1522                        chunks,
1523                        chunk_states,
1524                        &exclusions,
1525                    )?;
1526                    continue;
1527                }
1528            };
1529            if pre_alter.requires_ddl() {
1530                let exclusions = Self::prevalidate_trace_v1_chunk_rewrites(
1531                    request_schema,
1532                    chunks,
1533                    chunk_states,
1534                )?;
1535                if !exclusions.is_empty() {
1536                    Self::exclude_trace_v1_schema_observations(
1537                        request_schema,
1538                        chunks,
1539                        chunk_states,
1540                        &exclusions,
1541                    )?;
1542                    continue;
1543                }
1544
1545                self.apply_trace_v1_pre_alter(ctx, table_name, pre_alter)
1546                    .await?;
1547                let final_plan = self
1548                    .plan_trace_v1_request_schema(table_name, request_schema, ctx)
1549                    .await?;
1550                let schema_converged = matches!(
1551                    final_plan,
1552                    TraceRequestSchemaPlan::Ready(final_plan) if !final_plan.requires_ddl()
1553                );
1554                if !schema_converged {
1555                    for state in chunk_states.iter_mut() {
1556                        state.mark_for_reconcile();
1557                    }
1558                    return Ok(());
1559                }
1560
1561                return Ok(());
1562            }
1563
1564            return Ok(());
1565        }
1566    }
1567
1568    fn prevalidate_trace_v1_fixed_columns(
1569        chunks: &[TraceChunkRetry],
1570        chunk_states: &[TraceChunkSchemaState],
1571    ) -> ServerResult<TraceSchemaExclusions> {
1572        let mut exclusions = HashMap::<String, HashSet<usize>>::new();
1573        for (batch_index, (chunk, state)) in chunks.iter().zip(chunk_states).enumerate() {
1574            if *state != TraceChunkSchemaState::Prepared {
1575                continue;
1576            }
1577
1578            'rows: for row in &chunk.rows {
1579                for retry_value in &row.values {
1580                    let Some(column) = chunk.schema.get(retry_value.column_index) else {
1581                        return error::InternalSnafu {
1582                            err_msg: format!(
1583                                "trace retry column index {} is out of bounds",
1584                                retry_value.column_index
1585                            ),
1586                        }
1587                        .fail();
1588                    };
1589                    let Some(target_type) = trace_semconv_fixed_type(&column.column_name) else {
1590                        continue;
1591                    };
1592                    let Some(request_type) = retry_value
1593                        .value
1594                        .value_data
1595                        .as_ref()
1596                        .and_then(trace_value_datatype)
1597                    else {
1598                        continue;
1599                    };
1600                    if request_type != target_type
1601                        && coerce_value_data(
1602                            &retry_value.value.value_data,
1603                            target_type,
1604                            request_type,
1605                        )
1606                        .is_err()
1607                    {
1608                        exclusions
1609                            .entry(column.column_name.clone())
1610                            .or_default()
1611                            .insert(batch_index);
1612                        break 'rows;
1613                    }
1614                }
1615            }
1616        }
1617        Ok(exclusions)
1618    }
1619
1620    fn prevalidate_trace_v1_chunk_rewrites(
1621        request_schema: &TraceRequestSchema,
1622        chunks: &[TraceChunkRetry],
1623        chunk_states: &mut [TraceChunkSchemaState],
1624    ) -> ServerResult<TraceSchemaExclusions> {
1625        let mut exclusions = HashMap::<String, HashSet<usize>>::new();
1626        for (batch_index, (chunk, state)) in chunks.iter().zip(chunk_states).enumerate() {
1627            if *state != TraceChunkSchemaState::Prepared {
1628                continue;
1629            }
1630            let mut request = chunk.to_request()?;
1631            if let Some(column_name) = Self::prepare_trace_v1_chunk_rewrite(
1632                request_schema,
1633                batch_index,
1634                &mut request,
1635                state,
1636            )? {
1637                exclusions
1638                    .entry(column_name)
1639                    .or_default()
1640                    .insert(batch_index);
1641            }
1642        }
1643        Ok(exclusions)
1644    }
1645
1646    fn prepare_trace_v1_chunk_rewrite(
1647        request_schema: &TraceRequestSchema,
1648        batch_index: usize,
1649        request: &mut RowInsertRequest,
1650        state: &mut TraceChunkSchemaState,
1651    ) -> ServerResult<Option<String>> {
1652        if *state != TraceChunkSchemaState::Prepared {
1653            return Ok(None);
1654        }
1655
1656        match request_schema.prepare_request_rewrite(batch_index, request) {
1657            Ok(Some(rewrite)) => {
1658                TraceRequestSchema::apply_request_rewrite(request, rewrite);
1659                Ok(None)
1660            }
1661            Ok(None) => Ok(None),
1662            Err(failure) => {
1663                if !matches!(
1664                    Self::classify_trace_prewrite_failure(
1665                        failure.error.status_code(),
1666                        failure.error.is_retryable(),
1667                    ),
1668                    ChunkFailureReaction::RetryPerSpan
1669                ) {
1670                    return Err(failure.error);
1671                }
1672                state.mark_for_reconcile();
1673                Ok(Some(failure.column_name))
1674            }
1675        }
1676    }
1677
1678    fn exclude_trace_v1_schema_observations(
1679        request_schema: &mut TraceRequestSchema,
1680        chunks: &mut Vec<TraceChunkRetry>,
1681        chunk_states: &mut Vec<TraceChunkSchemaState>,
1682        exclusions: &TraceSchemaExclusions,
1683    ) -> ServerResult<()> {
1684        if chunks.len() != chunk_states.len() {
1685            return error::InternalSnafu {
1686                err_msg: format!(
1687                    "trace chunk count {} does not match state count {}",
1688                    chunks.len(),
1689                    chunk_states.len()
1690                ),
1691            }
1692            .fail();
1693        }
1694
1695        let batch_indexes = exclusions
1696            .values()
1697            .flatten()
1698            .copied()
1699            .collect::<HashSet<_>>();
1700        let previous_chunks = std::mem::take(chunks);
1701        let previous_states = std::mem::take(chunk_states);
1702        for (batch_index, (chunk, mut state)) in
1703            previous_chunks.into_iter().zip(previous_states).enumerate()
1704        {
1705            if !batch_indexes.contains(&batch_index) {
1706                chunks.push(chunk);
1707                chunk_states.push(state);
1708            } else if chunk.rows.len() <= 1 {
1709                state.mark_for_reconcile();
1710                chunks.push(chunk);
1711                chunk_states.push(state);
1712            } else {
1713                let split_chunks = chunk.into_single_span_chunks()?;
1714                chunk_states.extend(std::iter::repeat_n(
1715                    TraceChunkSchemaState::Prepared,
1716                    split_chunks.len(),
1717                ));
1718                chunks.extend(split_chunks);
1719            }
1720        }
1721
1722        *request_schema = TraceRequestSchema::default();
1723        for (batch_index, (chunk, state)) in chunks.iter().zip(chunk_states.iter()).enumerate() {
1724            if *state == TraceChunkSchemaState::Prepared {
1725                request_schema.observe_retry_chunk(batch_index, chunk)?;
1726            }
1727        }
1728
1729        Ok(())
1730    }
1731
1732    async fn apply_trace_v1_pre_alter(
1733        &self,
1734        ctx: &QueryContextRef,
1735        table_name: &str,
1736        pre_alter: TraceTablePreAlter,
1737    ) -> ServerResult<()> {
1738        let TraceTablePreAlter {
1739            ensure_columns,
1740            modify_float64_columns,
1741            alter_existing,
1742        } = pre_alter;
1743
1744        if !ensure_columns.is_empty() {
1745            self.inserter
1746                .ensure_trace_table_on_demand(
1747                    table_name,
1748                    ensure_columns,
1749                    alter_existing,
1750                    ctx,
1751                    &self.statement_executor,
1752                )
1753                .await
1754                .map_err(BoxedError::new)
1755                .context(error::ExecuteGrpcQuerySnafu)?;
1756        }
1757
1758        if !modify_float64_columns.is_empty() {
1759            self.alter_trace_table_columns_to_float64(ctx, table_name, &modify_float64_columns)
1760                .await?;
1761        }
1762
1763        Ok(())
1764    }
1765
1766    fn classify_trace_prewrite_failure(
1767        status: StatusCode,
1768        retryable: bool,
1769    ) -> ChunkFailureReaction {
1770        if retryable {
1771            return ChunkFailureReaction::Propagate;
1772        }
1773
1774        match status {
1775            StatusCode::InvalidArguments
1776            | StatusCode::InvalidSyntax
1777            | StatusCode::Unsupported
1778            | StatusCode::TableNotFound
1779            | StatusCode::TableColumnNotFound => ChunkFailureReaction::RetryPerSpan,
1780            StatusCode::DatabaseNotFound => ChunkFailureReaction::DiscardChunk,
1781            _ => ChunkFailureReaction::Propagate,
1782        }
1783    }
1784
1785    fn add_trace_write_cost(outcome: &mut TraceIngestOutcome, cost: usize) {
1786        outcome.write_cost += cost;
1787    }
1788
1789    /// Returns the full cause of a pre-write failure and its display form.
1790    ///
1791    /// `output_msg` masks internal errors and unwraps the root cause. Dedup must
1792    /// key on the full text: two causes can agree on a truncated prefix and
1793    /// differ exactly where the actionable detail is.
1794    fn trace_failure_cause(err: &error::Error) -> (String, String) {
1795        let cause = err.output_msg();
1796        let display = truncate_for_diagnostics(&cause, TRACE_FAILURE_CAUSE_LIMIT);
1797        (cause, display)
1798    }
1799
1800    /// Records one failure, merging repeats of `(label, key)` into a count.
1801    fn push_trace_failure_message(
1802        messages: &mut TraceFailureMessages,
1803        label: &'static str,
1804        key: &str,
1805        message: String,
1806    ) {
1807        OTLP_TRACES_FAILURE_COUNT.with_label_values(&[label]).inc();
1808
1809        if let Some(entry) = messages
1810            .entries
1811            .iter_mut()
1812            .find(|entry| entry.label == label && entry.key == key)
1813        {
1814            entry.occurrences += 1;
1815            return;
1816        }
1817
1818        if messages.entries.len() >= TRACE_FAILURE_MESSAGE_LIMIT {
1819            messages.suppressed_occurrences += 1;
1820            return;
1821        }
1822
1823        messages.entries.push(TraceFailureEntry {
1824            label,
1825            key: key.to_string(),
1826            message,
1827            occurrences: 1,
1828        });
1829    }
1830
1831    fn finish_trace_failure_message(
1832        accepted_spans: usize,
1833        rejected_spans: usize,
1834        messages: TraceFailureMessages,
1835    ) -> Option<String> {
1836        if rejected_spans == 0 && messages.is_empty() {
1837            return None;
1838        }
1839
1840        let mut summary = format!(
1841            "Accepted {} spans, rejected {} spans",
1842            accepted_spans, rejected_spans
1843        );
1844
1845        if !messages.is_empty() {
1846            let details = messages
1847                .entries
1848                .into_iter()
1849                .map(|entry| {
1850                    if entry.occurrences > 1 {
1851                        format!("{} (x{})", entry.message, entry.occurrences)
1852                    } else {
1853                        entry.message
1854                    }
1855                })
1856                .collect::<Vec<_>>()
1857                .join("; ");
1858            summary.push_str(": ");
1859            summary.push_str(&details);
1860        }
1861
1862        if messages.suppressed_occurrences > 0 {
1863            summary.push_str(&format!(
1864                "; {} additional failures suppressed",
1865                messages.suppressed_occurrences
1866            ));
1867        }
1868
1869        Some(summary)
1870    }
1871
1872    /// Widen existing trace table columns to Float64 before request rewrite.
1873    async fn alter_trace_table_columns_to_float64(
1874        &self,
1875        ctx: &QueryContextRef,
1876        table_name: &str,
1877        column_names: &[String],
1878    ) -> ServerResult<()> {
1879        let catalog_name = ctx.current_catalog().to_string();
1880        let schema_name = ctx.current_schema();
1881        let alter_expr = AlterTableExpr {
1882            catalog_name: catalog_name.clone(),
1883            schema_name: schema_name.clone(),
1884            table_name: table_name.to_string(),
1885            kind: Some(Kind::ModifyColumnTypes(ModifyColumnTypes {
1886                modify_column_types: column_names
1887                    .iter()
1888                    .map(|column_name| ModifyColumnType {
1889                        column_name: column_name.clone(),
1890                        target_type: ColumnDataType::Float64 as i32,
1891                        target_type_extension: None,
1892                    })
1893                    .collect(),
1894            })),
1895        };
1896
1897        if let Err(err) = self
1898            .statement_executor
1899            .alter_table_inner(alter_expr, ctx.clone(), TriggerReason::AutoAlter)
1900            .await
1901        {
1902            let table = self
1903                .catalog_manager
1904                .table(&catalog_name, &schema_name, table_name, None)
1905                .await
1906                .map_err(servers::error::Error::from)?;
1907            let alter_already_applied = table
1908                .map(|table| {
1909                    let table_schema = table.schema();
1910                    column_names.iter().all(|column_name| {
1911                        table_schema
1912                            .column_schema_by_name(column_name)
1913                            .and_then(|table_col| {
1914                                ColumnDataTypeWrapper::try_from(table_col.data_type.clone())
1915                                    .ok()
1916                                    .map(|wrapper| wrapper.datatype())
1917                            })
1918                            == Some(ColumnDataType::Float64)
1919                    })
1920                })
1921                .unwrap_or(false);
1922
1923            if alter_already_applied {
1924                return Ok(());
1925            }
1926
1927            warn!(
1928                table_name,
1929                columns = ?column_names,
1930                error = %err,
1931                "failed to widen trace columns before insert"
1932            );
1933
1934            return Err(wrap_trace_alter_failure(err));
1935        }
1936
1937        Ok(())
1938    }
1939
1940    /// Coerce request column types and values to match the existing table schema
1941    /// for compatible type pairs. Existing table schema wins when present;
1942    /// otherwise the full request batch decides a stable target type.
1943    async fn reconcile_trace_column_types(
1944        &self,
1945        requests: &mut RowInsertRequests,
1946        ctx: &QueryContextRef,
1947    ) -> ServerResult<()> {
1948        let catalog = ctx.current_catalog();
1949        let schema = ctx.current_schema();
1950
1951        for req in &mut requests.inserts {
1952            let table = self
1953                .catalog_manager
1954                .table(catalog, &schema, &req.table_name, None)
1955                .await?;
1956
1957            let Some(rows) = req.rows.as_mut() else {
1958                continue;
1959            };
1960
1961            let table_schema = table.map(|table| table.schema());
1962            let mut pending_rewrites = Vec::new();
1963            let mut pending_alter_columns = Vec::new();
1964
1965            for (col_idx, col_schema) in rows.schema.iter().enumerate() {
1966                let Some(current_type) = ColumnDataType::try_from(col_schema.datatype).ok() else {
1967                    continue;
1968                };
1969
1970                let mut observed_types = Vec::new();
1971                push_observed_trace_type(&mut observed_types, current_type);
1972
1973                // Scan the full request first so the final type decision is not affected
1974                // by row order inside the batch.
1975                for row in &rows.rows {
1976                    let Some(value) = row
1977                        .values
1978                        .get(col_idx)
1979                        .and_then(|value| value.value_data.as_ref())
1980                    else {
1981                        continue;
1982                    };
1983
1984                    let Some(value_type) = trace_value_datatype(value) else {
1985                        continue;
1986                    };
1987                    push_observed_trace_type(&mut observed_types, value_type);
1988                }
1989
1990                let existing_schema_type = table_schema
1991                    .as_ref()
1992                    .and_then(|schema| schema.column_schema_by_name(&col_schema.column_name))
1993                    .and_then(|table_col| {
1994                        ColumnDataTypeWrapper::try_from(table_col.data_type.clone())
1995                            .ok()
1996                            .map(|wrapper| {
1997                                let datatype = wrapper.datatype();
1998                                (datatype, ConcreteDataType::from(wrapper))
1999                            })
2000                    });
2001                let request_concrete_type = ConcreteDataType::from(ColumnDataTypeWrapper::new(
2002                    current_type,
2003                    col_schema.datatype_extension.clone(),
2004                ));
2005                if let Some((existing_datatype, existing_concrete_type)) =
2006                    existing_schema_type.as_ref()
2007                    && trace_logical_types_incompatible(
2008                        current_type,
2009                        &request_concrete_type,
2010                        *existing_datatype,
2011                        existing_concrete_type,
2012                    )
2013                {
2014                    return error::InvalidParameterSnafu {
2015                        reason: format!(
2016                            "incompatible logical types for trace column '{}' in table '{}'",
2017                            col_schema.column_name, req.table_name
2018                        ),
2019                    }
2020                    .fail();
2021                }
2022                let existing_type = existing_schema_type.map(|(datatype, _)| datatype);
2023                let fixed_type = trace_semconv_fixed_type(&col_schema.column_name);
2024
2025                if !observed_types
2026                    .iter()
2027                    .copied()
2028                    .any(is_trace_reconcile_candidate_type)
2029                    && existing_type
2030                        .map(|datatype| !is_trace_reconcile_candidate_type(datatype))
2031                        .unwrap_or(true)
2032                    && fixed_type.is_none()
2033                {
2034                    continue;
2035                }
2036
2037                // Decide the final type once per column, then rewrite all affected cells
2038                // together in one row pass below.
2039                let Some(decision) = choose_trace_reconcile_decision(
2040                    &col_schema.column_name,
2041                    &observed_types,
2042                    existing_type,
2043                )
2044                .map_err(|_| {
2045                    enrich_trace_reconcile_error(
2046                        &req.table_name,
2047                        &col_schema.column_name,
2048                        &observed_types,
2049                        existing_type,
2050                        fixed_type,
2051                    )
2052                })?
2053                else {
2054                    continue;
2055                };
2056                let target_type = decision.target_type();
2057
2058                if !decision.requires_alter()
2059                    && observed_types
2060                        .iter()
2061                        .all(|observed| *observed == target_type)
2062                    && col_schema.datatype == target_type as i32
2063                {
2064                    continue;
2065                }
2066
2067                if decision.requires_alter()
2068                    && !pending_alter_columns.contains(&col_schema.column_name)
2069                {
2070                    pending_alter_columns.push(col_schema.column_name.clone());
2071                }
2072
2073                pending_rewrites.push(PendingTraceColumnRewrite {
2074                    col_idx,
2075                    target_type,
2076                    column_name: col_schema.column_name.clone(),
2077                });
2078            }
2079
2080            if pending_rewrites.is_empty() {
2081                continue;
2082            }
2083
2084            let prepared_rewrites =
2085                prepare_trace_column_rewrites(&rows.rows, pending_rewrites, &req.table_name)
2086                    .map_err(|failure| failure.error)?;
2087
2088            if !pending_alter_columns.is_empty() {
2089                self.alter_trace_table_columns_to_float64(
2090                    ctx,
2091                    &req.table_name,
2092                    &pending_alter_columns,
2093                )
2094                .await?;
2095            }
2096
2097            prepared_rewrites.apply(rows);
2098        }
2099
2100        Ok(())
2101    }
2102}
2103
2104fn trace_logical_types_incompatible(
2105    left_datatype: ColumnDataType,
2106    left_concrete_type: &ConcreteDataType,
2107    right_datatype: ColumnDataType,
2108    right_concrete_type: &ConcreteDataType,
2109) -> bool {
2110    if left_concrete_type == right_concrete_type {
2111        return false;
2112    }
2113    // A supported coercion in either direction means the two types can be
2114    // reconciled, so they are not logically incompatible. This lets a signed
2115    // request reach the coercion path instead of being excluded up front when
2116    // the existing column is unsigned (e.g. trace `duration_nano` written as
2117    // Int64 into an existing UInt64 column during the unsigned -> signed
2118    // transition).
2119    if is_supported_trace_coercion(left_datatype, right_datatype)
2120        || is_supported_trace_coercion(right_datatype, left_datatype)
2121    {
2122        return false;
2123    }
2124    left_datatype == right_datatype
2125        || !is_trace_reconcile_candidate_type(left_datatype)
2126        || !is_trace_reconcile_candidate_type(right_datatype)
2127}
2128
2129fn chunk_owned<T>(items: Vec<T>, chunk_size: usize) -> Vec<Vec<T>> {
2130    if items.is_empty() {
2131        return Vec::new();
2132    }
2133
2134    if chunk_size == 0 {
2135        return vec![items];
2136    }
2137
2138    let mut chunks = Vec::with_capacity(items.len().div_ceil(chunk_size));
2139    let mut iter = items.into_iter();
2140    while iter.len() > 0 {
2141        chunks.push(iter.by_ref().take(chunk_size).collect());
2142    }
2143    chunks
2144}
2145
2146/// Preserve the original alter failure status so chunk retry behavior stays correct.
2147fn wrap_trace_alter_failure<E>(err: E) -> servers::error::Error
2148where
2149    E: ErrorExt + Send + Sync + 'static,
2150{
2151    error::ExecuteGrpcQuerySnafu.into_error(BoxedError::new(err))
2152}
2153
2154/// Derives `trace.conventions` from the request's resource/scope `schema_url`s.
2155/// A single distinct non-empty value is concrete; multiple distinct values are
2156/// `mixed`; none is `unknown`. `schema_url` is row-level in OTLP, so the
2157/// table-level value is best-effort per the RFC conflict rule.
2158pub(super) fn trace_conventions(request: &ExportTraceServiceRequest) -> String {
2159    let mut seen: Option<&str> = None;
2160    let mut mixed = false;
2161
2162    for resource_spans in &request.resource_spans {
2163        let urls = std::iter::once(resource_spans.schema_url.as_str()).chain(
2164            resource_spans
2165                .scope_spans
2166                .iter()
2167                .map(|s| s.schema_url.as_str()),
2168        );
2169        for url in urls {
2170            if url.is_empty() {
2171                continue;
2172            }
2173            match seen {
2174                None => seen = Some(url),
2175                Some(prev) if prev == url => {}
2176                Some(_) => {
2177                    mixed = true;
2178                    break;
2179                }
2180            }
2181        }
2182        if mixed {
2183            break;
2184        }
2185    }
2186
2187    if mixed {
2188        SEMANTIC_VALUE_MIXED.to_string()
2189    } else {
2190        seen.map(str::to_string)
2191            .unwrap_or_else(|| SEMANTIC_VALUE_UNKNOWN.to_string())
2192    }
2193}
2194
2195#[cfg(test)]
2196mod tests;