Skip to content

Commit b076156

Browse files
committed
feat: add trace support
Add trace search and full trace rendering for OpenTelemetry trace indexes. This includes Grafana trace frames, service node graph frames, span events, exception stack traces, status/warning handling, service tags, peer service metadata, and stable per-service node colors. Add trace-to-logs and log-to-trace correlation links for separate Quickwit logs and traces indexes, plus configuration fields for related datasources. Split trace parsing into dedicated backend files, add parser and frontend link tests, document the feature, and add a local multi-service trace/log fixture script for manual testing. Signed-off-by: Patrik Cyvoct <patrik@ptrk.io>
1 parent 3403416 commit b076156

30 files changed

Lines changed: 2858 additions & 195 deletions

README.md

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ To configure the Quickwit datasource, you need to provide the following informat
121121
- The index name.
122122
- The log message field name (optional). This is the field displayed in the explorer view.
123123
- The log level field name (optional). It must be a fast field.
124+
- The related logs or traces datasource (optional). This enables trace-to-logs and log-to-trace links when logs and traces are stored in separate Quickwit indexes.
124125

125126
### With Grafana UI
126127

@@ -141,6 +142,112 @@ datasources:
141142
logLevelField: severity_text
142143
```
143144
145+
### Logs and traces in separate indexes
146+
147+
When logs and traces are stored in different Quickwit indexes, configure one datasource per index and link them with `logsDatasourceUid` and `tracesDatasourceUid`.
148+
149+
```yaml
150+
apiVersion: 1
151+
152+
datasources:
153+
- name: Quickwit Logs
154+
uid: quickwit-logs
155+
type: quickwit-quickwit-datasource
156+
url: http://localhost:7280/api/v1
157+
jsonData:
158+
index: 'otel-logs-v0_9'
159+
logMessageField: body.message
160+
logLevelField: severity_text
161+
tracesDatasourceUid: quickwit-traces
162+
tracesDatasourceName: Quickwit Traces
163+
164+
- name: Quickwit Traces
165+
uid: quickwit-traces
166+
type: quickwit-quickwit-datasource
167+
url: http://localhost:7280/api/v1
168+
jsonData:
169+
index: 'otel-traces-v0_9'
170+
logsDatasourceUid: quickwit-logs
171+
logsDatasourceName: Quickwit Logs
172+
```
173+
174+
## Traces
175+
176+
The query editor has two trace query types:
177+
178+
- **Trace search** scans matching spans and returns one row per trace. Use this to find trace IDs by Lucene query, service, operation, status, or attributes.
179+
- **Traces** returns a full trace frame for Grafana's trace viewer. Use this with a trace ID query such as `trace_id:abc123`.
180+
181+
The trace parser expects Quickwit OpenTelemetry trace fields such as:
182+
183+
- `trace_id`
184+
- `span_id`
185+
- `parent_span_id`
186+
- `service_name`
187+
- `span_name`
188+
- `span_start_timestamp_nanos`
189+
- `span_duration_millis` or `span_end_timestamp_nanos`
190+
191+
It also reads optional fields for richer trace rendering:
192+
193+
- `resource_attributes` for service tags
194+
- `span_attributes` for span tags, including `service.peer.name` and `peer.service`
195+
- `span_status` for error status and warnings
196+
- `events` for span events and exception stack traces
197+
- `links` for span references
198+
- `scope_name` and `scope_version` for instrumentation library details
199+
200+
Trace responses include:
201+
202+
- Grafana trace frames for the trace viewer.
203+
- Node graph frames that summarize service-to-service calls.
204+
- Span warnings for error status and dropped attributes/events/links.
205+
- Span event details and exception stack traces.
206+
- Stable per-service node colors in the node graph.
207+
208+
### Trace/log correlations
209+
210+
Trace-to-logs links are attached to each trace span. They query the configured logs datasource with:
211+
212+
```text
213+
trace_id:${__span.traceId} AND span_id:${__span.spanId}
214+
```
215+
216+
Log-to-trace links are attached to log fields named:
217+
218+
- `trace_id`
219+
- `traceID`
220+
- `traceId`
221+
- `attributes.trace_id`
222+
223+
They open the configured traces datasource with:
224+
225+
```text
226+
trace_id:${__value.raw}
227+
```
228+
229+
### Local trace fixtures
230+
231+
For local testing, use the fixture script:
232+
233+
```bash
234+
QUICKWIT_URL=http://127.0.0.1:7280/api/v1 ./scripts/ingest-multi-service-traces.sh
235+
```
236+
237+
It writes two multi-service traces into `otel-traces-v0_9` and matching correlated logs into `otel-logs-v0_9`.
238+
239+
In **Trace search**, filter the fixture data with:
240+
241+
```text
242+
span_attributes.fixture:multi-service-trace
243+
```
244+
245+
In **Traces**, open a returned trace ID with:
246+
247+
```text
248+
trace_id:<trace id>
249+
```
250+
144251
## Features
145252

146253
- Explore view.
@@ -149,6 +256,9 @@ datasources:
149256
- Adhoc filters.
150257
- Annotations
151258
- Explore Log Context.
259+
- Trace search and trace view.
260+
- Trace-to-logs and log-to-trace links.
261+
- Service node graph for trace results.
152262
- [Alerting](https://grafana.com/docs/grafana/latest/alerting/).
153263

154264
## FAQ and Limitations

pkg/quickwit/client/client.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ type ReadyStatus struct {
2222

2323
type DatasourceInfo struct {
2424
ID int64
25+
UID string
26+
Name string
27+
LogsDatasourceUID string
28+
LogsDatasourceName string
29+
TracesDatasourceUID string
30+
TracesDatasourceName string
2531
HTTPClient *http.Client
2632
URL string
2733
Database string

pkg/quickwit/data_query.go

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ func buildMSR(queries []*Query, defaultTimeField string) ([]*es.SearchRequest, e
3030

3131
if isLogsQuery(q) {
3232
processLogsQuery(q, b, q.RangeFrom, q.RangeTo, defaultTimeField)
33+
} else if isTraceSearchQuery(q) {
34+
processTraceSearchQuery(q, b, defaultTimeField)
35+
} else if isTracesQuery(q) {
36+
processTracesQuery(q, b, defaultTimeField)
3337
} else if isDocumentQuery(q) {
3438
processDocumentQuery(q, b, q.RangeFrom, q.RangeTo, defaultTimeField)
3539
} else {
@@ -269,8 +273,8 @@ func getPipelineAggField(m *MetricAgg) string {
269273

270274
func isQueryWithError(query *Query) error {
271275
if len(query.BucketAggs) == 0 {
272-
// If no aggregations, only document and logs queries are valid
273-
if len(query.Metrics) == 0 || !(isLogsQuery(query) || isDocumentQuery(query)) {
276+
// If no aggregations, only document, logs, and trace queries are valid
277+
if len(query.Metrics) == 0 || !(isLogsQuery(query) || isTraceSearchQuery(query) || isTracesQuery(query) || isDocumentQuery(query)) {
274278
return fmt.Errorf("invalid query, missing metrics and aggregations")
275279
}
276280
} else {
@@ -302,19 +306,34 @@ func isQueryWithError(query *Query) error {
302306
}
303307

304308
func isLogsQuery(query *Query) bool {
305-
return query.Metrics[0].Type == logsType
309+
return queryMetricType(query) == logsType
310+
}
311+
312+
func isTracesQuery(query *Query) bool {
313+
return queryMetricType(query) == tracesType
314+
}
315+
316+
func isTraceSearchQuery(query *Query) bool {
317+
return queryMetricType(query) == traceSearchType
306318
}
307319

308320
func isDocumentQuery(query *Query) bool {
309321
return isRawDataQuery(query) || isRawDocumentQuery(query)
310322
}
311323

312324
func isRawDataQuery(query *Query) bool {
313-
return query.Metrics[0].Type == rawDataType
325+
return queryMetricType(query) == rawDataType
314326
}
315327

316328
func isRawDocumentQuery(query *Query) bool {
317-
return query.Metrics[0].Type == rawDocumentType
329+
return queryMetricType(query) == rawDocumentType
330+
}
331+
332+
func queryMetricType(query *Query) string {
333+
if query == nil || len(query.Metrics) == 0 {
334+
return ""
335+
}
336+
return query.Metrics[0].Type
318337
}
319338

320339
func processLogsQuery(q *Query, b *es.SearchRequestBuilder, from, to int64, defaultTimeField string) {
@@ -337,6 +356,18 @@ func processLogsQuery(q *Query, b *es.SearchRequestBuilder, from, to int64, defa
337356
}
338357
}
339358

359+
func processTracesQuery(q *Query, b *es.SearchRequestBuilder, defaultTimeField string) {
360+
metric := q.Metrics[0]
361+
b.Sort(es.SortOrderAsc, defaultTimeField, "epoch_nanos_int")
362+
b.Size(stringToIntWithDefaultValue(metric.Settings.Get("limit").MustString(), defaultSize))
363+
}
364+
365+
func processTraceSearchQuery(q *Query, b *es.SearchRequestBuilder, defaultTimeField string) {
366+
metric := q.Metrics[0]
367+
b.Sort(es.SortOrderDesc, defaultTimeField, "epoch_nanos_int")
368+
b.Size(stringToIntWithDefaultValue(metric.Settings.Get("spanLimit").MustString(), 5000))
369+
}
370+
340371
func processDocumentQuery(q *Query, b *es.SearchRequestBuilder, from, to int64, defaultTimeField string) {
341372
metric := q.Metrics[0]
342373
b.Sort(es.SortOrderDesc, defaultTimeField, "epoch_nanos_int")

pkg/quickwit/data_query_test.go

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,18 @@ import (
1212
es "github.com/quickwit-oss/quickwit-datasource/pkg/quickwit/client"
1313
)
1414

15+
func TestQueryTypeHelpersDoNotPanicWithoutMetrics(t *testing.T) {
16+
query := &Query{}
17+
18+
require.NotPanics(t, func() {
19+
assert.False(t, isLogsQuery(query))
20+
assert.False(t, isTracesQuery(query))
21+
assert.False(t, isTraceSearchQuery(query))
22+
assert.False(t, isRawDataQuery(query))
23+
assert.False(t, isRawDocumentQuery(query))
24+
})
25+
}
26+
1527
func TestExecuteElasticsearchDataQuery(t *testing.T) {
1628
from := time.Date(2018, 5, 15, 17, 50, 0, 0, time.UTC)
1729
to := time.Date(2018, 5, 15, 17, 55, 0, 0, time.UTC)
@@ -1321,6 +1333,42 @@ func TestExecuteElasticsearchDataQuery(t *testing.T) {
13211333
require.Equal(t, sr.Size, 1000)
13221334
})
13231335

1336+
t.Run("With traces query should return query sorted by ascending time", func(t *testing.T) {
1337+
c := newFakeClient()
1338+
_, err := executeElasticsearchDataQuery(c, `{
1339+
"query": "trace_id:3c191d03fa8be0653c191d03fa8be065",
1340+
"metrics": [{ "type": "traces", "id": "1", "settings": { "limit": "5000" }}]
1341+
}`, from, to)
1342+
require.NoError(t, err)
1343+
sr := c.multisearchRequests[0][0]
1344+
require.Equal(t, sr.Size, 5000)
1345+
require.Equal(t, sr.Sort[0]["@timestamp"]["order"], "asc")
1346+
1347+
rangeFilter := sr.Query.Bool.Filters[0].(*es.DateRangeFilter)
1348+
require.Equal(t, rangeFilter.Lte, "2018-05-15T17:55:00Z")
1349+
require.Equal(t, rangeFilter.Gte, "2018-05-15T17:50:00Z")
1350+
queryFilter := sr.Query.Bool.Filters[1].(*es.QueryStringFilter)
1351+
require.Equal(t, queryFilter.Query, "trace_id:3c191d03fa8be0653c191d03fa8be065")
1352+
})
1353+
1354+
t.Run("With trace search query should scan spans sorted by descending time", func(t *testing.T) {
1355+
c := newFakeClient()
1356+
_, err := executeElasticsearchDataQuery(c, `{
1357+
"query": "service_name:quickwit",
1358+
"metrics": [{ "type": "trace_search", "id": "1", "settings": { "limit": "20", "spanLimit": "2500" }}]
1359+
}`, from, to)
1360+
require.NoError(t, err)
1361+
sr := c.multisearchRequests[0][0]
1362+
require.Equal(t, sr.Size, 2500)
1363+
require.Equal(t, sr.Sort[0]["@timestamp"]["order"], "desc")
1364+
1365+
rangeFilter := sr.Query.Bool.Filters[0].(*es.DateRangeFilter)
1366+
require.Equal(t, rangeFilter.Lte, "2018-05-15T17:55:00Z")
1367+
require.Equal(t, rangeFilter.Gte, "2018-05-15T17:50:00Z")
1368+
queryFilter := sr.Query.Bool.Filters[1].(*es.QueryStringFilter)
1369+
require.Equal(t, queryFilter.Query, "service_name:quickwit")
1370+
})
1371+
13241372
t.Run("With invalid query should return error", (func(t *testing.T) {
13251373
c := newFakeClient()
13261374
_, err := executeElasticsearchDataQuery(c, `{
@@ -1721,5 +1769,5 @@ func executeElasticsearchDataQuery(c es.Client, body string, from, to time.Time)
17211769
return &backend.QueryDataResponse{}, err
17221770
}
17231771

1724-
return parseResponse(res, queries, configuredFields)
1772+
return parseResponse(res, queries, configuredFields, nil)
17251773
}

pkg/quickwit/elasticsearch.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ func queryData(ctx context.Context, dataQueries []backend.DataQuery, dsInfo *es.
4545
return &backend.QueryDataResponse{}, err
4646
}
4747

48-
return parseResponse(res, queries, dsInfo.ConfiguredFields)
48+
return parseResponse(res, queries, dsInfo.ConfiguredFields, dsInfo)
4949
}
5050

5151
func handleQuickwitErrors(err error) (*backend.QueryDataResponse, error) {

pkg/quickwit/models.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ var metricAggType = map[string]string{
6060
"raw_data": "Raw Data",
6161
"rate": "Rate",
6262
"logs": "Logs",
63+
"traces": "Traces",
64+
"trace_search": "Trace search",
6365
}
6466

6567
var extendedStats = map[string]string{

pkg/quickwit/quickwit.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,26 @@ func NewQuickwitDatasource(ctx context.Context, settings backend.DataSourceInsta
6767
logMessageField = ""
6868
}
6969

70+
logsDatasourceUID, ok := jsonData["logsDatasourceUid"].(string)
71+
if !ok {
72+
logsDatasourceUID = ""
73+
}
74+
75+
logsDatasourceName, ok := jsonData["logsDatasourceName"].(string)
76+
if !ok {
77+
logsDatasourceName = ""
78+
}
79+
80+
tracesDatasourceUID, ok := jsonData["tracesDatasourceUid"].(string)
81+
if !ok {
82+
tracesDatasourceUID = ""
83+
}
84+
85+
tracesDatasourceName, ok := jsonData["tracesDatasourceName"].(string)
86+
if !ok {
87+
tracesDatasourceName = ""
88+
}
89+
7090
index, ok := jsonData["index"].(string)
7191
if !ok {
7292
index = ""
@@ -99,6 +119,12 @@ func NewQuickwitDatasource(ctx context.Context, settings backend.DataSourceInsta
99119

100120
model := es.DatasourceInfo{
101121
ID: settings.ID,
122+
UID: settings.UID,
123+
Name: settings.Name,
124+
LogsDatasourceUID: logsDatasourceUID,
125+
LogsDatasourceName: logsDatasourceName,
126+
TracesDatasourceUID: tracesDatasourceUID,
127+
TracesDatasourceName: tracesDatasourceName,
102128
URL: settings.URL,
103129
HTTPClient: httpCli,
104130
Database: index,

pkg/quickwit/response_parser.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ const (
4141

4242
var searchWordsRegex = regexp.MustCompile(regexp.QuoteMeta(es.HighlightPreTagsString) + `(.*?)` + regexp.QuoteMeta(es.HighlightPostTagsString))
4343

44-
func parseResponse(rawResponses []*json.RawMessage, targets []*Query, configuredFields es.ConfiguredFields) (*backend.QueryDataResponse, error) {
44+
func parseResponse(rawResponses []*json.RawMessage, targets []*Query, configuredFields es.ConfiguredFields, dsInfo *es.DatasourceInfo) (*backend.QueryDataResponse, error) {
4545
result := backend.QueryDataResponse{
4646
Responses: backend.Responses{},
4747
}
@@ -54,7 +54,7 @@ func parseResponse(rawResponses []*json.RawMessage, targets []*Query, configured
5454

5555
byteReader := bytes.NewReader(*rawRes)
5656
dec := json.NewDecoder(byteReader)
57-
if isLogsQuery(target) {
57+
if isLogsQuery(target) || isTraceSearchQuery(target) || isTracesQuery(target) {
5858
dec.UseNumber()
5959
}
6060
var res *es.SearchResponse
@@ -92,6 +92,18 @@ func parseResponse(rawResponses []*json.RawMessage, targets []*Query, configured
9292
return &backend.QueryDataResponse{}, err
9393
}
9494
result.Responses[target.RefID] = queryRes
95+
} else if isTraceSearchQuery(target) {
96+
err := processTraceSearchResponse(res, target, dsInfo, &queryRes)
97+
if err != nil {
98+
return &backend.QueryDataResponse{}, err
99+
}
100+
result.Responses[target.RefID] = queryRes
101+
} else if isTracesQuery(target) {
102+
err := processTracesResponse(res, target, configuredFields, dsInfo, &queryRes)
103+
if err != nil {
104+
return &backend.QueryDataResponse{}, err
105+
}
106+
result.Responses[target.RefID] = queryRes
95107
} else {
96108
// Process as metric query result
97109
props := make(map[string]string)

0 commit comments

Comments
 (0)