Skip to content

Commit ac87171

Browse files
authored
Merge pull request #2 from gadget-inc/allow-json-attributes-in-traces-2
Allow trace attributes to be stored as json objects
2 parents 648fcc6 + 2829786 commit ac87171

7 files changed

Lines changed: 257 additions & 58 deletions

File tree

provisioning/datasources/clickhouse.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ datasources:
1919
port: 9000
2020
protocol: native
2121
secureJsonData:
22-
# password: ""
22+
password: ""

src/components/queryBuilder/views/TraceQueryBuilder.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import useColumns from 'hooks/useColumns';
1313
import { BuilderOptionsReducerAction, setOptions, setOtelEnabled, setOtelVersion } from 'hooks/useBuilderOptionsState';
1414
import useIsNewQuery from 'hooks/useIsNewQuery';
1515
import { OtelVersionSelect } from '../OtelVersionSelect';
16-
import { useDefaultFilters, useOtelColumns, useTraceDefaultsOnMount } from './traceQueryBuilderHooks';
16+
import { useColumnTypes, useDefaultFilters, useOtelColumns, useTraceDefaultsOnMount } from './traceQueryBuilderHooks';
1717
import TraceIdInput from '../TraceIdInput';
1818
import { OrderByEditor, getOrderByOptions } from '../OrderByEditor';
1919
import { LimitEditor } from '../LimitEditor';
@@ -49,6 +49,7 @@ interface TraceQueryBuilderState {
4949
flattenNested?: boolean;
5050
traceEventsColumnPrefix?: string;
5151
traceLinksColumnPrefix?: string;
52+
skipTraceAttributes?: boolean;
5253
traceId: string;
5354
orderBy: OrderBy[];
5455
limit: number;
@@ -94,6 +95,7 @@ export const TraceQueryBuilder = (props: TraceQueryBuilderProps) => {
9495
flattenNested: Boolean(builderOptions.meta?.flattenNested),
9596
traceEventsColumnPrefix: builderOptions.meta?.traceEventsColumnPrefix || '',
9697
traceLinksColumnPrefix: builderOptions.meta?.traceLinksColumnPrefix || '',
98+
skipTraceAttributes: Boolean(builderOptions.meta?.skipTraceAttributes),
9799
traceId: builderOptions.meta?.traceId || '',
98100
orderBy: builderOptions.orderBy || [],
99101
limit: builderOptions.limit || 0,
@@ -135,6 +137,7 @@ export const TraceQueryBuilder = (props: TraceQueryBuilderProps) => {
135137
flattenNested: next.flattenNested,
136138
traceEventsColumnPrefix: next.traceEventsColumnPrefix,
137139
traceLinksColumnPrefix: next.traceLinksColumnPrefix,
140+
skipTraceAttributes: next.skipTraceAttributes,
138141
},
139142
})
140143
);
@@ -143,6 +146,7 @@ export const TraceQueryBuilder = (props: TraceQueryBuilderProps) => {
143146
useTraceDefaultsOnMount(datasource, isNewQuery, builderOptions, builderOptionsDispatch);
144147
useOtelColumns(builderState.otelEnabled, builderState.otelVersion, builderOptionsDispatch);
145148
useDefaultFilters(builderOptions.table, builderState.isTraceIdMode, isNewQuery, builderOptionsDispatch);
149+
useColumnTypes(allColumns, builderOptions.columns, builderOptions.meta?.useJsonAttributes, builderOptionsDispatch);
146150

147151
const configWarning = showConfigWarning && (
148152
<Alert title="" severity="warning" buttonContent="Close" onRemove={() => setConfigWarningOpen(false)}>
@@ -384,6 +388,15 @@ export const TraceQueryBuilder = (props: TraceQueryBuilderProps) => {
384388
wide
385389
/>
386390
</div>
391+
<div className="gf-form">
392+
<Switch
393+
label={labels.columns.skipTraceAttributes.label}
394+
tooltip={labels.columns.skipTraceAttributes.tooltip}
395+
value={Boolean(builderState.skipTraceAttributes)}
396+
onChange={onOptionChange('skipTraceAttributes')}
397+
wide
398+
/>
399+
</div>
387400
<div className="gf-form">
388401
<LabeledInput
389402
disabled={builderState.otelEnabled}

src/components/queryBuilder/views/traceQueryBuilderHooks.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
QueryBuilderOptions,
1313
SelectedColumn,
1414
StringFilter,
15+
TableColumn,
1516
} from 'types/queryBuilder';
1617
import { BuilderOptionsReducerAction, setOptions } from 'hooks/useBuilderOptionsState';
1718

@@ -115,6 +116,70 @@ export const useOtelColumns = (
115116
}, [otelEnabled, otelVersion, builderOptionsDispatch]);
116117
};
117118

119+
/**
120+
* Auto-populates column types from the table schema and detects JSON attribute columns.
121+
* When JSON columns are detected for TraceTags or TraceServiceTags, automatically sets useJsonAttributes.
122+
*/
123+
export const useColumnTypes = (
124+
allColumns: readonly TableColumn[],
125+
columns: SelectedColumn[] | undefined,
126+
useJsonAttributes: boolean | undefined,
127+
builderOptionsDispatch: React.Dispatch<BuilderOptionsReducerAction>
128+
) => {
129+
const didPopulateTypes = useRef<boolean>(false);
130+
131+
useEffect(() => {
132+
// Wait until we have both columns and schema
133+
if (!columns?.length || !allColumns?.length) {
134+
didPopulateTypes.current = false;
135+
return;
136+
}
137+
138+
// Check if any columns are missing types
139+
const columnsNeedTypes = columns.some((c) => !c.type);
140+
if (!columnsNeedTypes && didPopulateTypes.current) {
141+
return;
142+
}
143+
144+
// Populate types from schema
145+
let hasChanges = false;
146+
const updatedColumns = columns.map((col) => {
147+
if (col.type) {
148+
return col;
149+
}
150+
const schemaCol = allColumns.find((c) => c.name === col.name);
151+
if (schemaCol?.type) {
152+
hasChanges = true;
153+
return { ...col, type: schemaCol.type };
154+
}
155+
return col;
156+
});
157+
158+
// Auto-detect JSON attributes
159+
let detectedJsonAttributes = true;
160+
if (!useJsonAttributes) {
161+
const tagsCol = updatedColumns.find((c) => c.hint === ColumnHint.TraceTags);
162+
const serviceTagsCol = updatedColumns.find((c) => c.hint === ColumnHint.TraceServiceTags);
163+
164+
detectedJsonAttributes =
165+
tagsCol?.type?.toLowerCase().startsWith('json') ||
166+
serviceTagsCol?.type?.toLowerCase().startsWith('json') ||
167+
detectedJsonAttributes;
168+
}
169+
170+
if (hasChanges || detectedJsonAttributes) {
171+
builderOptionsDispatch(
172+
setOptions({
173+
columns: updatedColumns,
174+
meta: detectedJsonAttributes ? { useJsonAttributes: true } : undefined,
175+
})
176+
);
177+
}
178+
179+
didPopulateTypes.current = true;
180+
}, [allColumns, columns, useJsonAttributes, builderOptionsDispatch]);
181+
};
182+
118183
// Apply default filters on table change
119184
export const useDefaultFilters = (
120185
table: string,

src/data/sqlGenerator.ts

Lines changed: 92 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)