@@ -147,19 +147,20 @@ const generateTraceIdQuery = (options: QueryBuilderOptions): string => {
147147 selectParts . push ( getTraceDurationSelectSql ( escapeIdentifier ( traceDurationTime . name ) , timeUnit ) ) ;
148148 }
149149
150- // TODO: for tags and serviceTags, consider the column type. They might not require mapping, they could already be JSON.
151- const traceTags = getColumnByHint ( options , ColumnHint . TraceTags ) ;
152- if ( traceTags !== undefined ) {
153- selectParts . push (
154- `arrayMap(key -> map('key', key, 'value',${ escapeIdentifier ( traceTags . name ) } [key]), mapKeys(${ escapeIdentifier ( traceTags . name ) } )) as tags`
155- ) ;
156- }
150+ const useJsonAttributes = options . meta ?. useJsonAttributes ;
151+ const skipTraceAttributes = options . meta ?. skipTraceAttributes ;
152+
153+ // Skip expensive attribute columns when skipTraceAttributes is enabled (for performance)
154+ if ( ! skipTraceAttributes ) {
155+ const traceTags = getColumnByHint ( options , ColumnHint . TraceTags ) ;
156+ if ( traceTags !== undefined ) {
157+ selectParts . push ( getAttributesSelectSql ( escapeIdentifier ( traceTags . name ) , traceTags . type , 'tags' , useJsonAttributes ) ) ;
158+ }
157159
158- const traceServiceTags = getColumnByHint ( options , ColumnHint . TraceServiceTags ) ;
159- if ( traceServiceTags !== undefined ) {
160- selectParts . push (
161- `arrayMap(key -> map('key', key, 'value',${ escapeIdentifier ( traceServiceTags . name ) } [key]), mapKeys(${ escapeIdentifier ( traceServiceTags . name ) } )) as serviceTags`
162- ) ;
160+ const traceServiceTags = getColumnByHint ( options , ColumnHint . TraceServiceTags ) ;
161+ if ( traceServiceTags !== undefined ) {
162+ selectParts . push ( getAttributesSelectSql ( escapeIdentifier ( traceServiceTags . name ) , traceServiceTags . type , 'serviceTags' , useJsonAttributes ) ) ;
163+ }
163164 }
164165
165166 const traceStatusCode = getColumnByHint ( options , ColumnHint . TraceStatusCode ) ;
@@ -171,49 +172,54 @@ const generateTraceIdQuery = (options: QueryBuilderOptions): string => {
171172
172173 const flattenNested = Boolean ( options . meta ?. flattenNested ) ;
173174
174- const traceEventsPrefix = options . meta ?. traceEventsColumnPrefix || '' ;
175- if ( traceEventsPrefix !== '' ) {
176- if ( flattenNested ) {
177- selectParts . push (
178- [
179- `arrayMap(event -> tuple(multiply(toFloat64(event.Timestamp), 1000),` ,
180- `arrayConcat(arrayMap(key -> map('key', key, 'value', event.Attributes[key]),` ,
181- `mapKeys(event.Attributes)), [map('key', 'message', 'value', event.Name)]))::Tuple(timestamp Float64, fields Array(Map(String, String))),` ,
182- `${ escapeIdentifier ( traceEventsPrefix ) } ) as logs` ,
183- ] . join ( ' ' )
184- ) ;
185- } else {
186- selectParts . push (
187- [
188- `arrayMap((name, timestamp, attributes) -> tuple(name, toString(toUnixTimestamp64Milli(timestamp)),` ,
189- `arrayMap( key -> map('key', key, 'value', attributes[key]),` ,
190- `mapKeys(attributes)))::Tuple(name String, timestamp String, fields Array(Map(String, String))),` ,
191- `${ escapeIdentifier ( traceEventsPrefix ) } .Name, ${ escapeIdentifier ( traceEventsPrefix ) } .Timestamp,` ,
192- `${ escapeIdentifier ( traceEventsPrefix ) } .Attributes) AS logs` ,
193- ] . join ( ' ' )
194- ) ;
175+ // Skip Events and Links when skipTraceAttributes is enabled (they contain nested attributes)
176+ if ( ! skipTraceAttributes ) {
177+ const traceEventsPrefix = options . meta ?. traceEventsColumnPrefix || '' ;
178+ if ( traceEventsPrefix !== '' ) {
179+ const eventAttrsSql = getNestedAttributesSql ( 'event.Attributes' , useJsonAttributes ) ;
180+ const attrsSql = getNestedAttributesSql ( 'attributes' , useJsonAttributes ) ;
181+
182+ if ( flattenNested ) {
183+ selectParts . push (
184+ [
185+ `arrayMap(event -> tuple(multiply(toFloat64(event.Timestamp), 1000),` ,
186+ `arrayConcat(${ eventAttrsSql } , [map('key', 'message', 'value', event.Name)]))::Tuple(timestamp Float64, fields Array(Map(String, String))),` ,
187+ `${ escapeIdentifier ( traceEventsPrefix ) } ) as logs` ,
188+ ] . join ( ' ' )
189+ ) ;
190+ } else {
191+ selectParts . push (
192+ [
193+ `arrayMap((name, timestamp, attributes) -> tuple(name, toString(toUnixTimestamp64Milli(timestamp)),` ,
194+ `${ attrsSql } )::Tuple(name String, timestamp String, fields Array(Map(String, String))),` ,
195+ `${ escapeIdentifier ( traceEventsPrefix ) } .Name, ${ escapeIdentifier ( traceEventsPrefix ) } .Timestamp,` ,
196+ `${ escapeIdentifier ( traceEventsPrefix ) } .Attributes) AS logs` ,
197+ ] . join ( ' ' )
198+ ) ;
199+ }
195200 }
196- }
197201
198- const traceLinksPrefix = options . meta ?. traceLinksColumnPrefix || '' ;
199- if ( traceLinksPrefix !== '' ) {
200- if ( flattenNested ) {
201- selectParts . push (
202- [
203- `arrayMap(link -> tuple(link.TraceId, link.SpanId, arrayMap(key -> map('key', key, 'value', link.Attributes[key]),` ,
204- `mapKeys(link.Attributes)))::Tuple(traceID String, spanID String, tags Array(Map(String, String))),` ,
205- `${ escapeIdentifier ( traceLinksPrefix ) } ) AS references` ,
206- ] . join ( ' ' )
207- ) ;
208- } else {
209- selectParts . push (
210- [
211- `arrayMap((traceID, spanID, attributes) -> tuple(traceID, spanID, arrayMap(key -> map('key', key, 'value', attributes[key]),` ,
212- `mapKeys(attributes)))::Tuple(traceID String, spanID String, tags Array(Map(String, String))),` ,
213- `${ escapeIdentifier ( traceLinksPrefix ) } .TraceId, ${ escapeIdentifier ( traceLinksPrefix ) } .SpanId,` ,
214- `${ escapeIdentifier ( traceLinksPrefix ) } .Attributes) AS references` ,
215- ] . join ( ' ' )
216- ) ;
202+ const traceLinksPrefix = options . meta ?. traceLinksColumnPrefix || '' ;
203+ if ( traceLinksPrefix !== '' ) {
204+ const linkAttrsSql = getNestedAttributesSql ( 'link.Attributes' , useJsonAttributes ) ;
205+ const linkAttrsSqlNonFlat = getNestedAttributesSql ( 'attributes' , useJsonAttributes ) ;
206+
207+ if ( flattenNested ) {
208+ selectParts . push (
209+ [
210+ `arrayMap(link -> tuple(link.TraceId, link.SpanId, ${ linkAttrsSql } )::Tuple(traceID String, spanID String, tags Array(Map(String, String))),` ,
211+ `${ escapeIdentifier ( traceLinksPrefix ) } ) AS references` ,
212+ ] . join ( ' ' )
213+ ) ;
214+ } else {
215+ selectParts . push (
216+ [
217+ `arrayMap((traceID, spanID, attributes) -> tuple(traceID, spanID, ${ linkAttrsSqlNonFlat } )::Tuple(traceID String, spanID String, tags Array(Map(String, String))),` ,
218+ `${ escapeIdentifier ( traceLinksPrefix ) } .TraceId, ${ escapeIdentifier ( traceLinksPrefix ) } .SpanId,` ,
219+ `${ escapeIdentifier ( traceLinksPrefix ) } .Attributes) AS references` ,
220+ ] . join ( ' ' )
221+ ) ;
222+ }
217223 }
218224 }
219225
@@ -657,7 +663,7 @@ const getTableIdentifier = (database: string, table: string): string => {
657663 return `${ escapeIdentifier ( database ) } ${ sep } ${ escapeIdentifier ( table ) } ` ;
658664} ;
659665
660- const escapeIdentifier = ( id : string ) : string => {
666+ export const escapeIdentifier = ( id : string ) : string => {
661667 return id ? `"${ id } "` : '' ;
662668} ;
663669
@@ -669,6 +675,38 @@ const escapeValue = (value: string): string => {
669675 return `'${ value } '` ;
670676} ;
671677
678+ /**
679+ * Returns the SELECT SQL for attributes columns (TraceTags/TraceServiceTags).
680+ * Handles both Map and JSON column types.
681+ * For Map types: uses arrayMap with mapKeys to convert to array of key/value maps
682+ * For JSON types: return the JSON column as is
683+ */
684+ export const getAttributesSelectSql = ( columnIdentifier : string , columnType : string | undefined , alias : string , useJsonAttributes ?: boolean ) : string => {
685+ const isJsonType = useJsonAttributes || columnType ?. toLowerCase ( ) . startsWith ( 'json' ) ;
686+
687+ if ( isJsonType ) {
688+ // For native JSON columns (ClickHouse 25.x+), convert to string first then extract keys/values.
689+ // Native JSON can be used directly in Grafana.
690+ return `toString(${ columnIdentifier } ) as ${ alias } ` ;
691+ }
692+
693+ // Default: Map type - convert to array of {key, value} maps for Grafana trace panel
694+ return `arrayMap(key -> map('key', key, 'value',${ columnIdentifier } [key]), mapKeys(${ columnIdentifier } )) as ${ alias } ` ;
695+ } ;
696+
697+ /**
698+ * Returns the SQL fragment for converting attributes to array of key/value maps.
699+ * Used within nested structures like Events and Links.
700+ * @param attributesExpr - The expression to access attributes (e.g., "event.Attributes" or "attributes")
701+ */
702+ const getNestedAttributesSql = ( attributesExpr : string , useJsonAttributes ?: boolean ) : string => {
703+ if ( useJsonAttributes ) {
704+ // For native JSON columns (ClickHouse 25.x+), convert to string first then extract keys/values
705+ return `toString(${ attributesExpr } )` ;
706+ }
707+ return `arrayMap(key -> map('key', key, 'value', ${ attributesExpr } [key]), mapKeys(${ attributesExpr } ))` ;
708+ } ;
709+
672710/**
673711 * Returns the SELECT column for trace duration.
674712 * Time unit is used to convert the value to milliseconds, as is required by Grafana's Trace panel.
0 commit comments