mirror of
https://github.com/grafana/grafana.git
synced 2026-07-03 03:37:53 +00:00
Tempo: Remove from core plugins (#127056)
* delete tempo backend * delete tempo frontend * delete tempo devenv docker block * update codeowners and devenv * remove tempo * remove tempo from schema * remove tempo dependencies from public/app/features * remove tempo from the rest of the places * add @opentelemetry/exporter-collector * more cleanup * fix test * fix backend test * prettier * fix backend tests * update exports * fix type assertion * fix test * fix e2e test
This commit is contained in:
parent
b92bfd830f
commit
61ec98144c
164 changed files with 313 additions and 24528 deletions
4
.github/CODEOWNERS
vendored
4
.github/CODEOWNERS
vendored
|
|
@ -326,7 +326,6 @@
|
|||
/devenv/docker/blocks/sensugo/ @grafana/grafana-backend-group
|
||||
/devenv/docker/blocks/slow_proxy/ @bergquist
|
||||
/devenv/docker/blocks/smtp/ @bergquist
|
||||
/devenv/docker/blocks/tempo/ @grafana/data-sources-plugins
|
||||
/devenv/docker/blocks/traefik/ @mckn
|
||||
/devenv/docker/blocks/webdav/ @grafana/alerting-backend
|
||||
/devenv/docker/buildcontainer/ @bergquist
|
||||
|
|
@ -368,7 +367,6 @@
|
|||
# Observability backend code
|
||||
/pkg/tsdb/prometheus/ @grafana/data-sources-plugins
|
||||
/pkg/tsdb/loki/ @grafana/data-sources-plugins
|
||||
/pkg/tsdb/tempo/ @grafana/data-sources-plugins
|
||||
/pkg/tsdb/grafana-pyroscope-datasource/ @grafana/data-sources-plugins
|
||||
/pkg/tsdb/parca/ @grafana/data-sources-plugins
|
||||
|
||||
|
|
@ -729,7 +727,6 @@ i18next.config.ts @grafana/grafana-frontend-platform
|
|||
/packages/grafana-schema/src/**/statetimeline @grafana/dataviz-squad
|
||||
/packages/grafana-schema/src/**/statushistory @grafana/dataviz-squad
|
||||
/packages/grafana-schema/src/**/table @grafana/dataviz-squad
|
||||
/packages/grafana-schema/src/**/tempo @grafana/data-sources-plugins
|
||||
/packages/grafana-schema/src/**/text @grafana/dataviz-squad
|
||||
/packages/grafana-schema/src/**/timeseries @grafana/dataviz-squad
|
||||
/packages/grafana-schema/src/**/trend @grafana/dataviz-squad
|
||||
|
|
@ -1143,7 +1140,6 @@ eslint-suppressions.json @grafanabot
|
|||
/public/app/plugins/datasource/grafana-postgresql-datasource/ @grafana/data-sources-plugins
|
||||
/public/app/plugins/datasource/prometheus/ @grafana/data-sources-plugins
|
||||
/public/app/plugins/datasource/cloud-monitoring/ @grafana/data-sources-plugins
|
||||
/public/app/plugins/datasource/tempo/ @grafana/data-sources-plugins
|
||||
/public/app/plugins/datasource/grafana-pyroscope-datasource/ @grafana/data-sources-plugins
|
||||
/public/app/plugins/datasource/parca/ @grafana/data-sources-plugins
|
||||
/public/app/plugins/datasource/alertmanager/ @grafana/alerting-squad
|
||||
|
|
|
|||
1
.github/workflows/auto-triager/labels.txt
vendored
1
.github/workflows/auto-triager/labels.txt
vendored
|
|
@ -116,7 +116,6 @@ datasource/Postgres
|
|||
datasource/Prometheus
|
||||
datasource/SiteWIse
|
||||
datasource/Splunk
|
||||
datasource/Tempo
|
||||
datasource/TestDataDB
|
||||
datasource/Timestream
|
||||
datasource/X-Ray
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ on:
|
|||
- "public/app/plugins/datasource/mssql/**"
|
||||
- "public/app/plugins/datasource/prometheus/**"
|
||||
- "public/app/plugins/datasource/grafana-pyroscope-datasource/**"
|
||||
- "public/app/plugins/datasource/tempo/**"
|
||||
- "pkg/tsdb/cloudwatch/**"
|
||||
- "pkg/tsdb/cloud-monitoring/**"
|
||||
- "pkg/tsdb/graphite/**"
|
||||
|
|
@ -20,7 +19,6 @@ on:
|
|||
- "pkg/tsdb/mssql/**"
|
||||
- "pkg/tsdb/prometheus/**"
|
||||
- "pkg/tsdb/grafana-pyroscope-datasource/**"
|
||||
- "pkg/tsdb/tempo/**"
|
||||
|
||||
jobs:
|
||||
add-comment:
|
||||
|
|
|
|||
|
|
@ -61,7 +61,6 @@ jobs:
|
|||
- '!public/app/plugins/datasource/mssql/**'
|
||||
- '!public/app/plugins/datasource/mysql/**'
|
||||
- '!public/app/plugins/datasource/parca/**'
|
||||
- '!public/app/plugins/datasource/tempo/**'
|
||||
backend_strict:
|
||||
- 'pkg/**'
|
||||
- 'go.mod'
|
||||
|
|
@ -91,7 +90,6 @@ jobs:
|
|||
- 'public/app/plugins/datasource/mssql/**'
|
||||
- 'public/app/plugins/datasource/mysql/**'
|
||||
- 'public/app/plugins/datasource/parca/**'
|
||||
- 'public/app/plugins/datasource/tempo/**'
|
||||
|
||||
- name: Check for exception label
|
||||
if: steps.changed-files.outputs.frontend_strict_any_changed == 'true' && steps.changed-files.outputs.backend_strict_any_changed == 'true'
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -94,7 +94,6 @@ example-apiserver/
|
|||
/devenv/docker-compose.yaml
|
||||
/devenv/docker-compose.override.yaml
|
||||
/devenv/.env
|
||||
/devenv/docker/blocks/tempo/tempo-data/
|
||||
/devenv/docker/ha-test-unified-alerting/logs/webhook/dumps/
|
||||
/devenv/docker/ha-test-unified-alerting/logs/webhook/webhook-listener.log
|
||||
/devenv/docker/blocks/auth/openldap/certs/
|
||||
|
|
|
|||
|
|
@ -101,8 +101,6 @@ linters:
|
|||
- '**/pkg/tsdb/mysql/**/*'
|
||||
- '**/pkg/tsdb/parca/*'
|
||||
- '**/pkg/tsdb/parca/**/*'
|
||||
- '**/pkg/tsdb/tempo/*'
|
||||
- '**/pkg/tsdb/tempo/**/*'
|
||||
- '**/pkg/tsdb/cloudwatch/*'
|
||||
- '**/pkg/tsdb/cloudwatch/**/*'
|
||||
- '**/pkg/tsdb/loki/*'
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ Standalone Go apps using Grafana App SDK: `apps/dashboard/`, `apps/folder/`, `ap
|
|||
|
||||
### Plugin Workspaces
|
||||
|
||||
These built-in plugins require separate build steps: `azuremonitor`, `cloud-monitoring`, `grafana-postgresql-datasource`, `loki`, `tempo`, `jaeger`, `mysql`, `parca`, `grafana-pyroscope-datasource`, `grafana-testdata-datasource`.
|
||||
These built-in plugins require separate build steps: `azuremonitor`, `cloud-monitoring`, `grafana-postgresql-datasource`, `loki`, `jaeger`, `mysql`, `parca`, `grafana-pyroscope-datasource`, `grafana-testdata-datasource`.
|
||||
|
||||
Build a specific plugin: `yarn workspace @grafana-plugins/<name> dev`
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ pkg/kinds/
|
|||
pkg/kindsys/
|
||||
pkg/registry/schemas/
|
||||
grafana-mixin/
|
||||
public/app/plugins/datasource/tempo
|
||||
public/app/features/explore/TraceView/components
|
||||
public/img/icons/solid/
|
||||
public/img/icons/unicons/
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ func TestCoreProvider_loadPlugins(t *testing.T) {
|
|||
provider := NewCoreProviderWithTTL(&logging.NoOpLogger{}, staticRootPath, false, defaultCoreTTL)
|
||||
err = provider.loadPlugins(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, provider.loadedPlugins, 49)
|
||||
assert.Len(t, provider.loadedPlugins, 48)
|
||||
})
|
||||
|
||||
t.Run("loads plugins from staticRootPath", func(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -118,7 +118,6 @@ If you want to contribute to any of the plugins listed below (that are found wit
|
|||
- jaeger
|
||||
- mysql
|
||||
- parca
|
||||
- tempo
|
||||
- loki
|
||||
|
||||
To build and watch all these plugins you can run the following command. Note this can be quite resource intensive as it will start separate build processes for each plugin.
|
||||
|
|
@ -127,7 +126,7 @@ To build and watch all these plugins you can run the following command. Note thi
|
|||
yarn plugin:build:dev
|
||||
```
|
||||
|
||||
If, instead, you would like to build and watch a specific plugin you can run the following command. Make sure to substitute `<name_of_plugin>` with the plugins name field found in its package.json. e.g. `@grafana-plugins/tempo`.
|
||||
If, instead, you would like to build and watch a specific plugin you can run the following command. Make sure to substitute `<name_of_plugin>` with the plugins name field found in its package.json. e.g. `@grafana-plugins/jaeger`.
|
||||
|
||||
```
|
||||
yarn workspace <name_of_plugin> dev
|
||||
|
|
|
|||
|
|
@ -54,10 +54,6 @@ make devenv sources=postgres,auth/openldap,grafana postgres_version=9.2 grafana_
|
|||
|
||||
The grafana block is pre-configured with the dev-datasources and dashboards.
|
||||
|
||||
#### Tempo
|
||||
|
||||
The tempo block runs loki and prometheus as well and should not be ran with prometheus as a separate source. You need to install a docker plugin for the self logging to work, without it the container won't start. See https://grafana.com/docs/loki/latest/clients/docker-driver/#installing for installation instructions.
|
||||
|
||||
#### Jaeger
|
||||
|
||||
Jaeger block runs both Jaeger and Loki container. Loki container sends traces to Jaeger and also logs its own logs into itself so it is possible to setup derived field for traceID from Loki to Jaeger. You need to install a docker plugin for the self logging to work, without it the container won't start. See https://grafana.com/docs/loki/latest/clients/docker-driver/#installing for installation instructions.
|
||||
|
|
|
|||
|
|
@ -63,9 +63,6 @@ datasources:
|
|||
alertmanagerUid: gdev-alertmanager
|
||||
prometheusType: Prometheus #Cortex | Mimir | Prometheus | Thanos
|
||||
prometheusVersion: 2.40.0
|
||||
exemplarTraceIdDestinations:
|
||||
- name: traceID
|
||||
datasourceUid: gdev-tempo
|
||||
secureJsonData:
|
||||
basicAuthPassword: admin #https://grafana.com/docs/grafana/latest/administration/provisioning/#using-environment-variables
|
||||
|
||||
|
|
@ -241,12 +238,6 @@ datasources:
|
|||
- targetUID: gdev-jaeger
|
||||
label: 'Jaeger traces'
|
||||
description: 'Related traces stored in Jaeger'
|
||||
- targetUID: gdev-zipkin
|
||||
label: 'Zipkin traces'
|
||||
description: 'Related traces stored in Zipkin'
|
||||
- targetUID: gdev-tempo
|
||||
label: 'Tempo traces'
|
||||
description: 'Related traces stored in Tempo'
|
||||
- targetUID: gdev-prometheus
|
||||
label: 'Logs to metrics'
|
||||
description: 'Related metrics stored in Prometheus'
|
||||
|
|
@ -262,14 +253,6 @@ datasources:
|
|||
matcherRegex: "traceID=(\\w+)"
|
||||
url: '$${__value.raw}'
|
||||
datasourceUid: gdev-jaeger
|
||||
- name: 'traceID'
|
||||
matcherRegex: "traceID=(\\w+)"
|
||||
url: '$${__value.raw}'
|
||||
datasourceUid: gdev-zipkin
|
||||
- name: 'traceID'
|
||||
matcherRegex: "traceID=(\\w+)"
|
||||
url: '$${__value.raw}'
|
||||
datasourceUid: gdev-tempo
|
||||
|
||||
- name: gdev-jaeger
|
||||
type: jaeger
|
||||
|
|
@ -278,49 +261,6 @@ datasources:
|
|||
url: http://localhost:16686
|
||||
editable: false
|
||||
|
||||
- name: gdev-zipkin
|
||||
type: zipkin
|
||||
uid: gdev-zipkin
|
||||
access: proxy
|
||||
url: http://localhost:9411
|
||||
editable: false
|
||||
|
||||
- name: gdev-tempo
|
||||
type: tempo
|
||||
uid: gdev-tempo
|
||||
access: proxy
|
||||
url: http://localhost:3200
|
||||
editable: false
|
||||
correlations:
|
||||
- targetUID: gdev-loki
|
||||
label: 'Logs (correlation)'
|
||||
description: 'Correlation to logs stored in Loki'
|
||||
config:
|
||||
type: query
|
||||
target:
|
||||
expr: '{ job="job" }'
|
||||
field: 'traceID'
|
||||
jsonData:
|
||||
tracesToLogsV2:
|
||||
datasourceUid: gdev-loki
|
||||
spanStartTimeShift: '5m'
|
||||
spanEndTimeShift: '-5m'
|
||||
customQuery: true
|
||||
query: '{filename="/var/log/grafana/grafana.log"} |="$${__span.traceId}"'
|
||||
tracesToProfiles:
|
||||
datasourceUid: gdev-pyroscope
|
||||
profileTypeId: 'process_cpu:cpu:nanoseconds:cpu:nanoseconds'
|
||||
tracesToMetrics:
|
||||
datasourceUid: gdev-prometheus
|
||||
spanStartTimeShift: '1h'
|
||||
spanEndTimeShift: '-1h'
|
||||
tags: [{ key: 'job' }]
|
||||
queries:
|
||||
- name: 'Metrics'
|
||||
query: 'sum(rate({$$__tags}[5m]))'
|
||||
serviceMap:
|
||||
datasourceUid: 'gdev-prometheus'
|
||||
|
||||
- name: gdev-pyroscope
|
||||
type: grafana-pyroscope-datasource
|
||||
uid: gdev-pyroscope
|
||||
|
|
|
|||
|
|
@ -174,39 +174,3 @@ datasources:
|
|||
access: proxy
|
||||
url: http://pyroscope:4040
|
||||
editable: false
|
||||
|
||||
- name: gdev-tempo
|
||||
type: tempo
|
||||
uid: gdev-tempo
|
||||
access: proxy
|
||||
url: http://tempo:3200
|
||||
editable: false
|
||||
correlations:
|
||||
- targetUID: gdev-loki
|
||||
label: 'Logs (correlation)'
|
||||
description: 'Correlation to logs stored in Loki'
|
||||
config:
|
||||
type: query
|
||||
target:
|
||||
expr: '{ job="job" }'
|
||||
field: 'traceID'
|
||||
jsonData:
|
||||
tracesToLogsV2:
|
||||
datasourceUid: gdev-loki
|
||||
spanStartTimeShift: '5m'
|
||||
spanEndTimeShift: '-5m'
|
||||
customQuery: true
|
||||
query: '{filename="/var/log/grafana/grafana.log"} |="$${__span.traceId}"'
|
||||
tracesToProfiles:
|
||||
datasourceUid: gdev-pyroscope
|
||||
profileTypeId: 'process_cpu:cpu:nanoseconds:cpu:nanoseconds'
|
||||
tracesToMetrics:
|
||||
datasourceUid: gdev-prometheus
|
||||
spanStartTimeShift: '1h'
|
||||
spanEndTimeShift: '-1h'
|
||||
tags: [{ key: 'job' }]
|
||||
queries:
|
||||
- name: 'Metrics'
|
||||
query: 'sum(rate({$$__tags}[5m]))'
|
||||
serviceMap:
|
||||
datasourceUid: 'gdev-prometheus'
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
This devenv docker-compose.yaml will allow you to;
|
||||
|
||||
- search traces
|
||||
- view traces
|
||||
- upload/download trace JSON files
|
||||
- view service graphs
|
||||
- search traces via Loki
|
||||
|
||||
To send traces from grafana use this configuration;
|
||||
|
||||
```
|
||||
[tracing.opentelemetry.otlp]
|
||||
# otlp destination (ex localhost:4317)
|
||||
address = localhost:4317
|
||||
```
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
db:
|
||||
image: grafana/tns-db:9c1ab38
|
||||
command:
|
||||
- '-log.level=debug'
|
||||
ports:
|
||||
- 0.0.0.0:8000:80
|
||||
environment:
|
||||
JAEGER_ENDPOINT: 'http://tempo:14268/api/traces'
|
||||
JAEGER_TAGS: job=tns/db
|
||||
JAEGER_SAMPLER_TYPE: const
|
||||
JAEGER_SAMPLER_PARAM: 1
|
||||
labels:
|
||||
namespace: tns
|
||||
logging:
|
||||
driver: loki
|
||||
options:
|
||||
loki-url: 'http://localhost:3100/api/prom/push'
|
||||
labels: namespace
|
||||
loki-relabel-config: |
|
||||
- action: replace
|
||||
source_labels: ["namespace","compose_service"]
|
||||
separator: "/"
|
||||
target_label: job
|
||||
|
||||
app:
|
||||
image: grafana/tns-app:9c1ab38
|
||||
command:
|
||||
- '-log.level=debug'
|
||||
- 'http://db'
|
||||
depends_on:
|
||||
- db
|
||||
ports:
|
||||
- 0.0.0.0:8001:80
|
||||
environment:
|
||||
JAEGER_ENDPOINT: 'http://tempo:14268/api/traces'
|
||||
JAEGER_TAGS: job=tns/app
|
||||
JAEGER_SAMPLER_TYPE: const
|
||||
JAEGER_SAMPLER_PARAM: 1
|
||||
labels:
|
||||
namespace: tns
|
||||
logging:
|
||||
driver: loki
|
||||
options:
|
||||
loki-url: 'http://localhost:3100/api/prom/push'
|
||||
labels: namespace
|
||||
loki-relabel-config: |
|
||||
- action: replace
|
||||
source_labels: ["namespace","compose_service"]
|
||||
separator: "/"
|
||||
target_label: job
|
||||
|
||||
loadgen:
|
||||
image: grafana/tns-loadgen:9c1ab38
|
||||
command:
|
||||
- '-log.level=debug'
|
||||
- 'http://app'
|
||||
depends_on:
|
||||
- app
|
||||
ports:
|
||||
- 0.0.0.0:8002:80
|
||||
environment:
|
||||
JAEGER_ENDPOINT: 'http://tempo:14268/api/traces'
|
||||
JAEGER_TAGS: job=tns/loadgen
|
||||
JAEGER_SAMPLER_TYPE: const
|
||||
JAEGER_SAMPLER_PARAM: 1
|
||||
labels:
|
||||
namespace: tns
|
||||
logging:
|
||||
driver: loki
|
||||
options:
|
||||
loki-url: 'http://localhost:3100/api/prom/push'
|
||||
labels: namespace
|
||||
|
||||
tempo:
|
||||
image: grafana/tempo:latest
|
||||
command:
|
||||
- --config.file=/etc/tempo.yaml
|
||||
volumes:
|
||||
- ./docker/blocks/tempo/tempo.yaml:/etc/tempo.yaml
|
||||
- ./docker/blocks/tempo/tempo-data:/tmp/tempo
|
||||
ports:
|
||||
- "14268:14268" # jaeger ingest
|
||||
- "3200:3200" # tempo
|
||||
- "4317:4317" # otlp grpc
|
||||
- "4318:4318" # otlp http
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus:latest
|
||||
command:
|
||||
- --config.file=/etc/prometheus.yaml
|
||||
- --web.enable-remote-write-receiver
|
||||
- --enable-feature=exemplar-storage
|
||||
- --enable-feature=native-histograms
|
||||
volumes:
|
||||
- ./docker/blocks/tempo/prometheus.yaml:/etc/prometheus.yaml
|
||||
depends_on:
|
||||
- app
|
||||
- db
|
||||
- loadgen
|
||||
ports:
|
||||
- "9090:9090"
|
||||
labels:
|
||||
namespace: monitoring
|
||||
logging:
|
||||
driver: loki
|
||||
options:
|
||||
loki-url: 'http://localhost:3100/api/prom/push'
|
||||
labels: namespace
|
||||
|
||||
loki:
|
||||
image: grafana/loki:main
|
||||
command:
|
||||
- -config.file=/etc/loki/local-config.yaml
|
||||
- -table-manager.retention-period=1d
|
||||
- -table-manager.retention-deletes-enabled=true
|
||||
ports:
|
||||
- "3100:3100"
|
||||
labels:
|
||||
namespace: monitoring
|
||||
logging:
|
||||
driver: loki
|
||||
options:
|
||||
loki-url: 'http://localhost:3100/api/prom/push'
|
||||
labels: namespace
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
|
||||
scrape_configs:
|
||||
- job_name: 'prometheus'
|
||||
static_configs:
|
||||
- targets: [ 'localhost:9090' ]
|
||||
- job_name: 'tempo'
|
||||
static_configs:
|
||||
- targets: [ 'tempo:3200' ]
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
server:
|
||||
http_listen_port: 3200
|
||||
|
||||
distributor:
|
||||
receivers: # this configuration will listen on all ports and protocols that tempo is capable of.
|
||||
jaeger: # the receives all come from the OpenTelemetry collector. more configuration information can
|
||||
protocols: # be found there: https://github.com/open-telemetry/opentelemetry-collector/tree/main/receiver
|
||||
thrift_http: #
|
||||
endpoint: "tempo:14268" # for a production deployment you should only enable the receivers you need!
|
||||
grpc:
|
||||
endpoint: "tempo:14250"
|
||||
thrift_binary:
|
||||
endpoint: "tempo:6832"
|
||||
thrift_compact:
|
||||
endpoint: "tempo:6831"
|
||||
zipkin:
|
||||
endpoint: "tempo:9411"
|
||||
otlp:
|
||||
protocols:
|
||||
grpc:
|
||||
endpoint: "tempo:4317"
|
||||
http:
|
||||
endpoint: "tempo:4318"
|
||||
opencensus:
|
||||
endpoint: "tempo:55678"
|
||||
|
||||
compactor:
|
||||
compaction:
|
||||
compaction_window: 1h # blocks in this time window will be compacted together
|
||||
max_block_bytes: 100_000_000 # maximum size of compacted blocks
|
||||
block_retention: 1h
|
||||
compacted_block_retention: 10m
|
||||
|
||||
metrics_generator:
|
||||
traces_storage:
|
||||
path: /tmp/tempo/generator/traces
|
||||
registry:
|
||||
external_labels:
|
||||
source: tempo
|
||||
cluster: docker-compose
|
||||
storage:
|
||||
path: /tmp/tempo/generator/wal
|
||||
remote_write:
|
||||
- url: http://prometheus:9090/api/v1/write
|
||||
send_exemplars: true
|
||||
|
||||
storage:
|
||||
trace:
|
||||
backend: local # backend configuration to use
|
||||
block:
|
||||
bloom_filter_false_positive: .05 # bloom filter false positive rate. lower values create larger filters but fewer false positives
|
||||
v2_index_downsample_bytes: 1000 # number of bytes per index record
|
||||
v2_encoding: zstd # block encoding/compression. options: none, gzip, lz4-64k, lz4-256k, lz4-1M, lz4, snappy, zstd, s2
|
||||
wal:
|
||||
path: /tmp/tempo/wal # where to store the wal locally
|
||||
v2_encoding: snappy # wal encoding/compression. options: none, gzip, lz4-64k, lz4-256k, lz4-1M, lz4, snappy, zstd, s2
|
||||
local:
|
||||
path: /tmp/tempo/blocks
|
||||
|
||||
overrides:
|
||||
defaults:
|
||||
metrics_generator:
|
||||
generate_native_histograms: both # 'classic' or 'native' or 'both'
|
||||
processors: [local-blocks, service-graphs, span-metrics]
|
||||
|
||||
stream_over_http_enabled: true
|
||||
|
|
@ -34,10 +34,10 @@ test.describe(
|
|||
const dataSourcePicker = page.getByTestId(selectors.components.DataSourcePicker.container);
|
||||
await dataSourcePicker.click();
|
||||
|
||||
const tempoOption = page.getByText('gdev-tempo');
|
||||
await tempoOption.scrollIntoViewIfNeeded();
|
||||
await expect(tempoOption).toBeVisible();
|
||||
await tempoOption.click();
|
||||
const jaegerOption = page.getByText('gdev-jaeger');
|
||||
await jaegerOption.scrollIntoViewIfNeeded();
|
||||
await expect(jaegerOption).toBeVisible();
|
||||
await jaegerOption.click();
|
||||
|
||||
// Save the data source
|
||||
const saveAndTestButton = page.getByTestId(selectors.pages.DataSource.saveAndTest);
|
||||
|
|
@ -61,7 +61,7 @@ test.describe(
|
|||
contentType: 'application/json',
|
||||
body: JSON.stringify(require('../fixtures/exemplars-query-response.json')),
|
||||
});
|
||||
} else if (datasourceType === 'tempo') {
|
||||
} else if (datasourceType === 'jaeger') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
|
|
@ -117,7 +117,7 @@ test.describe(
|
|||
const exemplarMarker = page.getByTestId(selectors.components.DataSource.Prometheus.exemplarMarker).first();
|
||||
await exemplarMarker.hover();
|
||||
|
||||
const queryWithTempoLink = page.getByText('Query with gdev-tempo');
|
||||
const queryWithTempoLink = page.getByText('Query with gdev-jaeger');
|
||||
await queryWithTempoLink.click();
|
||||
|
||||
// Verify trace viewer has span bars
|
||||
|
|
|
|||
|
|
@ -3155,78 +3155,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/plugins/datasource/tempo/QueryField.tsx": {
|
||||
"react-prefer-function-component/react-prefer-function-component": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.tsx": {
|
||||
"react-hooks/exhaustive-deps": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/plugins/datasource/tempo/ServiceGraphSection.tsx": {
|
||||
"@typescript-eslint/consistent-type-assertions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilter.tsx": {
|
||||
"@grafana/no-gf-form": {
|
||||
"count": 1
|
||||
},
|
||||
"react-prefer-function-component/react-prefer-function-component": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterKey.tsx": {
|
||||
"@grafana/no-gf-form": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterRenderer.tsx": {
|
||||
"@grafana/no-gf-form": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterValue.tsx": {
|
||||
"@grafana/no-gf-form": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/ConditionSegment.tsx": {
|
||||
"@grafana/no-gf-form": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"public/app/plugins/datasource/tempo/configuration/TraceQLSearchSettings.tsx": {
|
||||
"@typescript-eslint/consistent-type-assertions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/plugins/datasource/tempo/configuration/TraceQLSearchTags.tsx": {
|
||||
"react-hooks/exhaustive-deps": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/plugins/datasource/tempo/datasource.ts": {
|
||||
"@typescript-eslint/consistent-type-assertions": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/plugins/datasource/tempo/resultTransformer.ts": {
|
||||
"@grafana/no-locale-compare": {
|
||||
"count": 2
|
||||
},
|
||||
"@typescript-eslint/consistent-type-assertions": {
|
||||
"count": 2
|
||||
},
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"public/app/plugins/panel/annolist/AnnoListPanel.tsx": {
|
||||
"react-prefer-function-component/react-prefer-function-component": {
|
||||
"count": 1
|
||||
|
|
|
|||
|
|
@ -513,7 +513,6 @@ module.exports = [
|
|||
'public/app/plugins/datasource/mssql/**/*.{ts,tsx}',
|
||||
'public/app/plugins/datasource/mysql/**/*.{ts,tsx}',
|
||||
'public/app/plugins/datasource/parca/**/*.{ts,tsx}',
|
||||
'public/app/plugins/datasource/tempo/**/*.{ts,tsx}',
|
||||
],
|
||||
plugins: {
|
||||
import: importPlugin,
|
||||
|
|
|
|||
|
|
@ -95,6 +95,5 @@ module.exports = {
|
|||
'<rootDir>/public/app/plugins/datasource/mssql',
|
||||
'<rootDir>/public/app/plugins/datasource/mysql',
|
||||
'<rootDir>/public/app/plugins/datasource/parca',
|
||||
'<rootDir>/public/app/plugins/datasource/tempo',
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -298,6 +298,7 @@
|
|||
"@openfeature/react-sdk": "^1.3.0",
|
||||
"@openfeature/web-sdk": "^1.8.0",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
"@opentelemetry/exporter-collector": "0.25.0",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@rc-component/tree": "1.1.0",
|
||||
"@react-aria/dialog": "3.5.31",
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ describe('v0alpha1AppMapper', () => {
|
|||
it('should only map specs with type app', () => {
|
||||
const result = v0alpha1AppMapper(v0alpha1Response);
|
||||
|
||||
expect(v0alpha1Response.items).toHaveLength(52);
|
||||
expect(v0alpha1Response.items).toHaveLength(51);
|
||||
expect(Object.keys(result)).toHaveLength(4);
|
||||
expect(Object.keys(result)).toEqual(Object.keys(apps));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ describe('v0alpha1PanelMapper', () => {
|
|||
it('should only map specs with type panel', () => {
|
||||
const result = v0alpha1PanelMapper(v0alpha1Response);
|
||||
|
||||
expect(v0alpha1Response.items).toHaveLength(52);
|
||||
expect(v0alpha1Response.items).toHaveLength(51);
|
||||
expect(Object.keys(result)).toHaveLength(28);
|
||||
expect(Object.keys(result)).toEqual(Object.keys(panels));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4003,71 +4003,6 @@ export const v0alpha1Response: PluginMetasResponse = structuredClone({
|
|||
},
|
||||
status: {},
|
||||
},
|
||||
{
|
||||
kind: 'Meta',
|
||||
apiVersion: 'plugins.grafana.app/v0alpha1',
|
||||
metadata: {
|
||||
name: 'tempo',
|
||||
namespace: 'default',
|
||||
},
|
||||
spec: {
|
||||
pluginJson: {
|
||||
id: 'tempo',
|
||||
type: 'datasource',
|
||||
name: 'Tempo',
|
||||
info: {
|
||||
keywords: [],
|
||||
logos: {
|
||||
small: 'app/plugins/datasource/tempo/dist/img/tempo_logo.svg',
|
||||
large: 'app/plugins/datasource/tempo/dist/img/tempo_logo.svg',
|
||||
},
|
||||
updated: '',
|
||||
version: '12.4.0-pre',
|
||||
author: {
|
||||
name: 'Grafana Labs',
|
||||
url: 'https://grafana.com',
|
||||
},
|
||||
description: 'High volume, minimal dependency trace storage. OSS tracing solution from Grafana Labs.',
|
||||
links: [
|
||||
{
|
||||
name: 'GitHub Project',
|
||||
url: 'https://github.com/grafana/tempo',
|
||||
},
|
||||
{
|
||||
name: 'Raise issue',
|
||||
url: 'https://github.com/grafana/grafana/issues/new',
|
||||
},
|
||||
{
|
||||
name: 'Documentation',
|
||||
url: 'https://grafana.com/docs/grafana/latest/datasources/tempo/',
|
||||
},
|
||||
],
|
||||
},
|
||||
dependencies: {
|
||||
grafanaDependency: '>=10.3.0-0',
|
||||
grafanaVersion: '*',
|
||||
},
|
||||
backend: true,
|
||||
category: 'tracing',
|
||||
executable: 'gpx_tempo',
|
||||
metrics: true,
|
||||
tracing: true,
|
||||
},
|
||||
class: 'core',
|
||||
module: {
|
||||
path: 'app/plugins/datasource/tempo/dist/module.js',
|
||||
loadingStrategy: 'script',
|
||||
},
|
||||
baseURL: 'app/plugins/datasource/tempo/dist',
|
||||
signature: {
|
||||
status: 'internal',
|
||||
},
|
||||
angular: {
|
||||
detected: false,
|
||||
},
|
||||
},
|
||||
status: {},
|
||||
},
|
||||
{
|
||||
kind: 'Meta',
|
||||
apiVersion: 'plugins.grafana.app/v0alpha1',
|
||||
|
|
|
|||
|
|
@ -197,12 +197,6 @@
|
|||
"import": "./dist/esm/raw/composable/table/panelcfg/x/types.gen.mjs",
|
||||
"require": "./dist/cjs/raw/composable/table/panelcfg/x/types.gen.cjs"
|
||||
},
|
||||
"./dist/esm/raw/composable/tempo/dataquery/x/TempoDataQuery_types.gen": {
|
||||
"@grafana-app/source": "./src/raw/composable/tempo/dataquery/x/types.gen.ts",
|
||||
"types": "./dist/types/raw/composable/tempo/dataquery/x/types.gen.d.ts",
|
||||
"import": "./dist/esm/raw/composable/tempo/dataquery/x/types.gen.mjs",
|
||||
"require": "./dist/cjs/raw/composable/tempo/dataquery/x/types.gen.cjs"
|
||||
},
|
||||
"./dist/esm/raw/composable/text/panelcfg/x/TextPanelCfg_types.gen": {
|
||||
"@grafana-app/source": "./src/raw/composable/text/panelcfg/x/types.gen.ts",
|
||||
"types": "./dist/types/raw/composable/text/panelcfg/x/types.gen.d.ts",
|
||||
|
|
|
|||
|
|
@ -1,160 +0,0 @@
|
|||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
//
|
||||
// Generated by:
|
||||
// public/app/plugins/gen.go
|
||||
// Using jennies:
|
||||
// TSTypesJenny
|
||||
// PluginTsTypesJenny
|
||||
//
|
||||
// Run 'make gen-cue' from repository root to regenerate.
|
||||
|
||||
// Generated from public/app/plugins/datasource/tempo/dataquery.cue file.
|
||||
|
||||
import * as common from '@grafana/schema';
|
||||
|
||||
export const pluginVersion = "%VERSION%";
|
||||
|
||||
export interface TempoQuery extends common.DataQuery {
|
||||
/**
|
||||
* For metric queries, how many exemplars to request, 0 means no exemplars
|
||||
*/
|
||||
exemplars?: number;
|
||||
filters: Array<TraceqlFilter>;
|
||||
/**
|
||||
* deprecated Filters that are used to query the metrics summary
|
||||
*/
|
||||
groupBy?: Array<TraceqlFilter>;
|
||||
/**
|
||||
* Defines the maximum number of traces that are returned from Tempo
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @deprecated Define the maximum duration to select traces. Use duration format, for example: 1.2s, 100ms
|
||||
*/
|
||||
maxDuration?: string;
|
||||
/**
|
||||
* For metric queries, whether to run instant or range queries
|
||||
*/
|
||||
metricsQueryType?: MetricsQueryType;
|
||||
/**
|
||||
* @deprecated Define the minimum duration to select traces. Use duration format, for example: 1.2s, 100ms
|
||||
*/
|
||||
minDuration?: string;
|
||||
/**
|
||||
* TraceQL query or trace ID
|
||||
*/
|
||||
query?: string;
|
||||
/**
|
||||
* @deprecated Logfmt query to filter traces by their tags. Example: http.status_code=200 error=true
|
||||
*/
|
||||
search?: string;
|
||||
/**
|
||||
* Use service.namespace in addition to service.name to uniquely identify a service.
|
||||
*/
|
||||
serviceMapIncludeNamespace?: boolean;
|
||||
/**
|
||||
* Filters to be included in a PromQL query to select data for the service graph. Example: {client="app",service="app"}. Providing multiple values will produce union of results for each filter, using PromQL OR operator internally.
|
||||
*/
|
||||
serviceMapQuery?: (string | Array<string>);
|
||||
/**
|
||||
* Whether to use native histograms for service map queries
|
||||
*/
|
||||
serviceMapUseNativeHistograms?: boolean;
|
||||
/**
|
||||
* @deprecated Query traces by service name
|
||||
*/
|
||||
serviceName?: string;
|
||||
/**
|
||||
* @deprecated Query traces by span name
|
||||
*/
|
||||
spanName?: string;
|
||||
/**
|
||||
* Defines the maximum number of spans per spanset that are returned from Tempo
|
||||
*/
|
||||
spss?: number;
|
||||
/**
|
||||
* For metric queries, the step size to use
|
||||
*/
|
||||
step?: string;
|
||||
/**
|
||||
* The type of the table that is used to display the search results
|
||||
*/
|
||||
tableType?: SearchTableType;
|
||||
}
|
||||
|
||||
export const defaultTempoQuery: Partial<TempoQuery> = {
|
||||
filters: [],
|
||||
groupBy: [],
|
||||
};
|
||||
|
||||
export type TempoQueryType = ('traceql' | 'traceqlSearch' | 'serviceMap' | 'upload' | 'nativeSearch' | 'traceId' | 'clear');
|
||||
|
||||
export enum MetricsQueryType {
|
||||
Instant = 'instant',
|
||||
Range = 'range',
|
||||
}
|
||||
|
||||
/**
|
||||
* The state of the TraceQL streaming search query
|
||||
*/
|
||||
export enum SearchStreamingState {
|
||||
Done = 'done',
|
||||
Error = 'error',
|
||||
Pending = 'pending',
|
||||
Streaming = 'streaming',
|
||||
}
|
||||
|
||||
/**
|
||||
* The type of the table that is used to display the search results
|
||||
*/
|
||||
export enum SearchTableType {
|
||||
Raw = 'raw',
|
||||
Spans = 'spans',
|
||||
Traces = 'traces',
|
||||
}
|
||||
|
||||
/**
|
||||
* static fields are pre-set in the UI, dynamic fields are added by the user
|
||||
*/
|
||||
export enum TraceqlSearchScope {
|
||||
Event = 'event',
|
||||
Instrumentation = 'instrumentation',
|
||||
Intrinsic = 'intrinsic',
|
||||
Link = 'link',
|
||||
Resource = 'resource',
|
||||
Span = 'span',
|
||||
Unscoped = 'unscoped',
|
||||
}
|
||||
|
||||
export interface TraceqlFilter {
|
||||
/**
|
||||
* Uniquely identify the filter, will not be used in the query generation
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Whether the value is a custom value typed by the user
|
||||
*/
|
||||
isCustomValue?: boolean;
|
||||
/**
|
||||
* The operator that connects the tag to the value, for example: =, >, !=, =~
|
||||
*/
|
||||
operator?: string;
|
||||
/**
|
||||
* The scope of the filter, can either be unscoped/all scopes, resource or span
|
||||
*/
|
||||
scope?: TraceqlSearchScope;
|
||||
/**
|
||||
* The tag for the search filter, for example: .http.status_code, .service.name, status
|
||||
*/
|
||||
tag?: string;
|
||||
/**
|
||||
* The value for the search filter
|
||||
*/
|
||||
value?: (string | Array<string>);
|
||||
/**
|
||||
* The type of the value, used for example to check whether we need to wrap the value in quotes when generating the query
|
||||
*/
|
||||
valueType?: string;
|
||||
}
|
||||
|
||||
export interface TempoDataQuery {}
|
||||
|
|
@ -25,6 +25,7 @@ var requiredURL = map[string]bool{
|
|||
datasources.DS_ALERTMANAGER: true,
|
||||
datasources.DS_JAEGER: true,
|
||||
datasources.DS_LOKI: true,
|
||||
datasources.DS_OPENTSDB: true,
|
||||
datasources.DS_TEMPO: true,
|
||||
datasources.DS_ZIPKIN: true,
|
||||
datasources.DS_MYSQL: true,
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ func TestIntegrationCallResource(t *testing.T) {
|
|||
cfg.Azure = &azsettings.AzureSettings{}
|
||||
|
||||
coreRegistry := coreplugin.ProvideCoreRegistry(tracing.InitializeTracerForTest(), nil, &cloudwatch.Service{}, nil, nil, nil,
|
||||
nil, nil, nil, testdatasource.ProvideService(), nil, nil, nil, nil, nil, nil, nil)
|
||||
nil, nil, testdatasource.ProvideService(), nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
testCtx := pluginsintegration.CreateIntegrationTestCtx(t, cfg, coreRegistry)
|
||||
|
||||
|
|
|
|||
|
|
@ -555,7 +555,6 @@ import (
|
|||
_ "github.com/grafana/grafana/pkg/tsdb/mysql"
|
||||
_ "github.com/grafana/grafana/pkg/tsdb/parca"
|
||||
_ "github.com/grafana/grafana/pkg/tsdb/prometheus"
|
||||
_ "github.com/grafana/grafana/pkg/tsdb/tempo"
|
||||
_ "github.com/grafana/grafana/pkg/util"
|
||||
_ "github.com/grafana/grafana/pkg/util/errhttp"
|
||||
_ "github.com/grafana/grafana/pkg/util/retryer"
|
||||
|
|
|
|||
|
|
@ -210,7 +210,6 @@ import (
|
|||
"github.com/grafana/grafana/pkg/tsdb/mysql"
|
||||
"github.com/grafana/grafana/pkg/tsdb/parca"
|
||||
"github.com/grafana/grafana/pkg/tsdb/prometheus"
|
||||
"github.com/grafana/grafana/pkg/tsdb/tempo"
|
||||
)
|
||||
|
||||
func otelTracer() trace.Tracer {
|
||||
|
|
@ -319,7 +318,6 @@ var wireBasicSet = wire.NewSet(
|
|||
socialimpl.ProvideService,
|
||||
influxdb.ProvideService,
|
||||
wire.Bind(new(social.Service), new(*socialimpl.SocialService)),
|
||||
tempo.ProvideService,
|
||||
loki.ProvideService,
|
||||
graphite.ProvideService,
|
||||
prometheus.ProvideService,
|
||||
|
|
|
|||
9
pkg/server/wire_gen.go
generated
9
pkg/server/wire_gen.go
generated
File diff suppressed because one or more lines are too long
|
|
@ -26,6 +26,7 @@ const (
|
|||
DS_LOKI = "loki"
|
||||
DS_MSSQL = "mssql"
|
||||
DS_MYSQL = "mysql"
|
||||
DS_OPENTSDB = "opentsdb"
|
||||
DS_POSTGRES = "grafana-postgresql-datasource"
|
||||
DS_PROMETHEUS = "prometheus"
|
||||
DS_AMAZON_PROMETHEUS = "grafana-amazonprometheus-datasource"
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ import (
|
|||
"github.com/grafana/grafana/pkg/tsdb/mysql"
|
||||
"github.com/grafana/grafana/pkg/tsdb/parca"
|
||||
"github.com/grafana/grafana/pkg/tsdb/prometheus"
|
||||
"github.com/grafana/grafana/pkg/tsdb/tempo"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -94,7 +93,7 @@ func ProvideCoreProvider(coreRegistry *Registry) plugins.BackendFactoryProvider
|
|||
|
||||
func ProvideCoreRegistry(tracer trace.Tracer, am *azuremonitor.Service, cw *cloudwatch.Service, cm *cloudmonitoring.Service,
|
||||
grap *graphite.Service, idb *influxdb.Service, lk *loki.Service,
|
||||
pr *prometheus.Service, t *tempo.Service, td *testdatasource.Service, pg *postgres.Service, my *mysql.Service,
|
||||
pr *prometheus.Service, td *testdatasource.Service, pg *postgres.Service, my *mysql.Service,
|
||||
ms *mssql.Service, graf *grafanads.Service, pyroscope *pyroscope.Service, parca *parca.Service, jaeger *jaeger.Service) *Registry {
|
||||
// Non-optimal global solution to replace plugin SDK default tracer for core plugins.
|
||||
sdktracing.InitDefaultTracer(tracer)
|
||||
|
|
@ -107,7 +106,6 @@ func ProvideCoreRegistry(tracer trace.Tracer, am *azuremonitor.Service, cw *clou
|
|||
InfluxDB: asBackendPlugin(idb),
|
||||
Loki: asBackendPlugin(lk),
|
||||
Prometheus: asBackendPlugin(pr),
|
||||
Tempo: asBackendPlugin(t),
|
||||
TestData: asBackendPlugin(td),
|
||||
PostgreSQL: asBackendPlugin(pg),
|
||||
MySQL: asBackendPlugin(my),
|
||||
|
|
@ -229,8 +227,6 @@ func NewPlugin(pluginID string, httpClientProvider *httpclient.Provider, tracer
|
|||
svc = loki.ProvideService(httpClientProvider, tracer)
|
||||
case Prometheus:
|
||||
svc = prometheus.ProvideService(httpClientProvider)
|
||||
case Tempo:
|
||||
svc = tempo.ProvideService(httpClientProvider, tracer)
|
||||
case PostgreSQL:
|
||||
svc = postgres.ProvideService()
|
||||
case MySQL:
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ func TestNewPlugin(t *testing.T) {
|
|||
{ID: PostgreSQL},
|
||||
{ID: Prometheus},
|
||||
{ID: Pyroscope},
|
||||
{ID: Tempo},
|
||||
{ID: TestData, ExpectedAlias: TestDataAlias},
|
||||
{ID: TestDataAlias, ExpectedID: TestData, ExpectedAlias: TestDataAlias},
|
||||
{ID: Jaeger},
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ import (
|
|||
"github.com/grafana/grafana/pkg/tsdb/mysql"
|
||||
"github.com/grafana/grafana/pkg/tsdb/parca"
|
||||
"github.com/grafana/grafana/pkg/tsdb/prometheus"
|
||||
"github.com/grafana/grafana/pkg/tsdb/tempo"
|
||||
"github.com/grafana/grafana/pkg/util/testutil"
|
||||
)
|
||||
|
||||
|
|
@ -151,7 +150,6 @@ func TestIntegrationPluginManager(t *testing.T) {
|
|||
idb := influxdb.ProvideService(hcp)
|
||||
lk := loki.ProvideService(hcp, tracer)
|
||||
pr := prometheus.ProvideService(hcp)
|
||||
tmpo := tempo.ProvideService(hcp, tracer)
|
||||
td := testdatasource.ProvideService()
|
||||
pg := postgres.ProvideService()
|
||||
my := mysql.ProvideService()
|
||||
|
|
@ -160,7 +158,7 @@ func TestIntegrationPluginManager(t *testing.T) {
|
|||
pyroscope := pyroscope.ProvideService(hcp)
|
||||
parca := parca.ProvideService(hcp)
|
||||
jaeger := jaeger.ProvideService(hcp)
|
||||
coreRegistry := coreplugin.ProvideCoreRegistry(tracing.InitializeTracerForTest(), am, cw, cm, grap, idb, lk, pr, tmpo, td, pg, my, ms, graf, pyroscope, parca, jaeger)
|
||||
coreRegistry := coreplugin.ProvideCoreRegistry(tracing.InitializeTracerForTest(), am, cw, cm, grap, idb, lk, pr, td, pg, my, ms, graf, pyroscope, parca, jaeger)
|
||||
|
||||
testCtx := pluginsintegration.CreateIntegrationTestCtx(t, cfg, coreRegistry)
|
||||
|
||||
|
|
@ -240,7 +238,6 @@ func verifyCorePluginCatalogue(t *testing.T, ctx context.Context, ps *pluginstor
|
|||
"influxdb": {},
|
||||
"loki": {},
|
||||
"prometheus": {},
|
||||
"tempo": {},
|
||||
"grafana-testdata-datasource": {},
|
||||
"grafana-postgresql-datasource": {},
|
||||
"mysql": {},
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/backendplugin"
|
||||
pluginsCfg "github.com/grafana/grafana/pkg/plugins/config"
|
||||
"github.com/grafana/grafana/pkg/plugins/envvars"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/client"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/loader"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/loader/angular/angularinspector"
|
||||
|
|
@ -52,7 +53,7 @@ func CreateIntegrationTestCtx(t *testing.T, cfg *setting.Cfg, coreRegistry *core
|
|||
disc := pipeline.ProvideDiscoveryStage(pCfg, reg)
|
||||
boot := pipeline.ProvideBootstrapStage(pCfg, signature.ProvideService(pCfg, statickey.New()), pluginassets.NewLocalProvider(), pluginscdn.ProvideService(pCfg))
|
||||
valid := pipeline.ProvideValidationStage(pCfg, signature.NewValidator(signature.NewUnsignedAuthorizer(pCfg)), angularInspector)
|
||||
init := pipeline.ProvideInitializationStage(pCfg, reg, coreplugin.ProvideCoreProvider(coreRegistry), proc, &pluginfakes.FakeAuthService{}, pluginfakes.NewFakeRoleRegistry(), pluginfakes.NewFakeActionSetRegistry(), nil, tracing.InitializeTracerForTest(), provisionedplugins.NewNoop())
|
||||
init := pipeline.ProvideInitializationStage(pCfg, reg, coreplugin.ProvideCoreProvider(coreRegistry), proc, &pluginfakes.FakeAuthService{}, pluginfakes.NewFakeRoleRegistry(), pluginfakes.NewFakeActionSetRegistry(), envvars.DefaultProvider(), tracing.InitializeTracerForTest(), provisionedplugins.NewNoop())
|
||||
term, err := pipeline.ProvideTerminationStage(pCfg, reg, proc)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
@ -98,7 +99,7 @@ func CreateTestLoader(t *testing.T, cfg *pluginsCfg.PluginManagementCfg, opts Lo
|
|||
if opts.Initializer == nil {
|
||||
reg := registry.ProvideService()
|
||||
coreRegistry := coreplugin.NewRegistry(make(map[string]backendplugin.PluginFactoryFunc))
|
||||
opts.Initializer = pipeline.ProvideInitializationStage(cfg, reg, coreplugin.ProvideCoreProvider(coreRegistry), process.ProvideService(), &pluginfakes.FakeAuthService{}, pluginfakes.NewFakeRoleRegistry(), pluginfakes.NewFakeActionSetRegistry(), nil, tracing.InitializeTracerForTest(), provisionedplugins.NewNoop())
|
||||
opts.Initializer = pipeline.ProvideInitializationStage(cfg, reg, coreplugin.ProvideCoreProvider(coreRegistry), process.ProvideService(), &pluginfakes.FakeAuthService{}, pluginfakes.NewFakeRoleRegistry(), pluginfakes.NewFakeActionSetRegistry(), envvars.DefaultProvider(), tracing.InitializeTracerForTest(), provisionedplugins.NewNoop())
|
||||
}
|
||||
|
||||
if opts.Terminator == nil {
|
||||
|
|
|
|||
|
|
@ -1895,60 +1895,6 @@
|
|||
"signatureOrg": "",
|
||||
"angularDetected": false
|
||||
},
|
||||
{
|
||||
"name": "Tempo",
|
||||
"type": "datasource",
|
||||
"id": "tempo",
|
||||
"enabled": true,
|
||||
"pinned": false,
|
||||
"info": {
|
||||
"author": {
|
||||
"name": "Grafana Labs",
|
||||
"url": "https://grafana.com"
|
||||
},
|
||||
"description": "High volume, minimal dependency trace storage. OSS tracing solution from Grafana Labs.",
|
||||
"links": [
|
||||
{
|
||||
"name": "GitHub Project",
|
||||
"url": "https://github.com/grafana/tempo"
|
||||
},
|
||||
{
|
||||
"name": "Raise issue",
|
||||
"url": "https://github.com/grafana/grafana/issues/new"
|
||||
},
|
||||
{
|
||||
"name": "Documentation",
|
||||
"url": "https://grafana.com/docs/grafana/latest/datasources/tempo/"
|
||||
}
|
||||
],
|
||||
"logos": {
|
||||
"small": "public/plugins/tempo/img/tempo_logo.svg",
|
||||
"large": "public/plugins/tempo/img/tempo_logo.svg"
|
||||
},
|
||||
"build": {},
|
||||
"screenshots": null,
|
||||
"version": "12.4.0-pre",
|
||||
"updated": "",
|
||||
"keywords": null
|
||||
},
|
||||
"dependencies": {
|
||||
"grafanaDependency": "\u003e=12.3.0",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
"defaultNavUrl": "/plugins/tempo/",
|
||||
"category": "tracing",
|
||||
"state": "",
|
||||
"signature": "internal",
|
||||
"signatureType": "",
|
||||
"signatureOrg": "",
|
||||
"angularDetected": false
|
||||
},
|
||||
{
|
||||
"name": "TestData",
|
||||
"type": "datasource",
|
||||
|
|
|
|||
|
|
@ -1,350 +0,0 @@
|
|||
package tempo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"google.golang.org/grpc"
|
||||
grpccodes "google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/metadata"
|
||||
grpcstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/tracing"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/config"
|
||||
"github.com/grafana/tempo/pkg/tempopb"
|
||||
)
|
||||
|
||||
var (
|
||||
logger = backend.NewLoggerWith("logger", "tsdb.tempo")
|
||||
|
||||
// gRPC client metrics - initialized lazily
|
||||
grpcRequestsTotal *prometheus.CounterVec
|
||||
grpcRequestDuration *prometheus.HistogramVec
|
||||
grpcInFlightRequests *prometheus.GaugeVec
|
||||
|
||||
metricsOnce sync.Once
|
||||
)
|
||||
|
||||
// initGRPCMetrics initializes the gRPC client metrics
|
||||
func initGRPCMetrics() {
|
||||
metricsOnce.Do(func() {
|
||||
grpcRequestsTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Namespace: "grafana",
|
||||
Subsystem: "tempo_grpc",
|
||||
Name: "requests_total",
|
||||
Help: "Total number of gRPC requests to Tempo",
|
||||
},
|
||||
[]string{"method", "status"},
|
||||
)
|
||||
|
||||
grpcRequestDuration = promauto.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Namespace: "grafana",
|
||||
Subsystem: "tempo_grpc",
|
||||
Name: "request_duration_seconds",
|
||||
Help: "Duration of gRPC requests to Tempo",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
},
|
||||
[]string{"method", "status"},
|
||||
)
|
||||
|
||||
grpcInFlightRequests = promauto.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Namespace: "grafana",
|
||||
Subsystem: "tempo_grpc",
|
||||
Name: "in_flight_requests",
|
||||
Help: "Number of in-flight gRPC requests to Tempo",
|
||||
},
|
||||
[]string{"method"},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// newGrpcClient creates a new gRPC client to connect to a streaming query service.
|
||||
// This uses the default google.golang.org/grpc library. One caveat to that is that it does not allow passing the
|
||||
// default httpClient to the gRPC client. This means that we cannot use the same middleware that we use for
|
||||
// standard HTTP requests.
|
||||
// Using other library like connect-go isn't possible right now because Tempo uses non-standard proto compiler which
|
||||
// makes generating different client difficult. See https://github.com/grafana/grafana/pull/81683
|
||||
func newGrpcClient(ctx context.Context, settings backend.DataSourceInstanceSettings, opts httpclient.Options) (tempopb.StreamingQuerierClient, error) {
|
||||
parsedUrl, err := url.Parse(settings.URL)
|
||||
if err != nil {
|
||||
logger.Error("Error parsing URL for gRPC client", "error", err, "URL", settings.URL, "function", logEntrypoint())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Make sure we have some default port if none is set. This is required for gRPC to work.
|
||||
onlyHost := parsedUrl.Host
|
||||
if parsedUrl.Port() == "" {
|
||||
if parsedUrl.Scheme == "http" {
|
||||
onlyHost += ":80"
|
||||
} else {
|
||||
onlyHost += ":443"
|
||||
}
|
||||
}
|
||||
|
||||
secure := parsedUrl.Scheme == "https"
|
||||
|
||||
dialOpts, err := getDialOpts(ctx, settings, secure, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting dial options: %w", err)
|
||||
}
|
||||
|
||||
// grpc.Dial() is deprecated in favor of grpc.NewClient(), but grpc.NewClient() changed the default resolver to dns from passthrough.
|
||||
// This is a problem because the getDialOpts() function appends a custom dialer to the dial options to support Grafana Cloud PDC.
|
||||
//
|
||||
// See the following quote from the grpc package documentation:
|
||||
// One subtle difference between NewClient and Dial and DialContext is that the
|
||||
// former uses "dns" as the default name resolver, while the latter use
|
||||
// "passthrough" for backward compatibility. This distinction should not matter
|
||||
// to most users, but could matter to legacy users that specify a custom dialer
|
||||
// and expect it to receive the target string directly.
|
||||
// https://github.com/grpc/grpc-go/blob/fa274d77904729c2893111ac292048d56dcf0bb1/clientconn.go#L209
|
||||
//
|
||||
// Unfortunately, the passthrough resolver isn't exported by the grpc package, so we can't use it.
|
||||
// The options are to continue using grpc.Dial() or implement a custom resolver.
|
||||
// Since the go-grpc package maintainers intend to continue supporting grpc.Dial() through the 1.x series,
|
||||
// we'll continue using grpc.Dial() until we have a compelling reason or bandwidth to implement the custom resolver.
|
||||
// Reference: https://github.com/grpc/grpc-go/blob/f199062ef31ddda54152e1ca5e3d15fb63903dc3/clientconn.go#L204
|
||||
//
|
||||
// See this issue for more information: https://github.com/grpc/grpc-go/issues/7091
|
||||
// Ignore the lint check as this fails the build and for the reasons above.
|
||||
// nolint:staticcheck
|
||||
clientConn, err := grpc.Dial(onlyHost, dialOpts...)
|
||||
if err != nil {
|
||||
logger.Error("Error dialing gRPC client", "error", err, "URL", settings.URL, "function", logEntrypoint())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger.Debug("Instantiating new gRPC client")
|
||||
return tempopb.NewStreamingQuerierClient(clientConn), nil
|
||||
}
|
||||
|
||||
// getDialOpts creates options and interceptors (middleware) this should roughly match what we do in
|
||||
// http_client_provider.go for standard http requests.
|
||||
func getDialOpts(ctx context.Context, settings backend.DataSourceInstanceSettings, secure bool, opts httpclient.Options) ([]grpc.DialOption, error) {
|
||||
// TODO: Still missing some middleware compared to HTTP client:
|
||||
// - OAuth token forwarding (OAuthTokenMiddleware equivalent - requires integration with oauthtoken.OAuthTokenService)
|
||||
// - Response limits (not applicable to gRPC streaming)
|
||||
// - Redirect handling (not applicable to gRPC)
|
||||
// - Complete contextual middleware support
|
||||
//
|
||||
// Implemented so far:
|
||||
// ✓ Basic tracing (logging-based)
|
||||
// ✓ Basic metrics (logging-based)
|
||||
// ✓ Custom headers forwarding
|
||||
// ✓ User agent handling (automatic)
|
||||
|
||||
var dialOps []grpc.DialOption
|
||||
|
||||
// Default max gRPC receive size is 4MB. Tempo responses can exceed this, so we should increase it.
|
||||
// Prefer `GF_LIVE_CLIENT_QUEUE_MAX_SIZE` (set by Grafana for the Tempo plugin) when it's present and valid.
|
||||
const defaultMaxCallRecvMsgSizeBytes = 4 * 1024 * 1024
|
||||
maxCallRecvMsgSizeBytes := defaultMaxCallRecvMsgSizeBytes
|
||||
|
||||
if v := config.GrafanaConfigFromContext(ctx).Get(backend.LiveClientQueueMaxSize); v != "" {
|
||||
parsed, err := strconv.Atoi(v)
|
||||
if err != nil || parsed <= 0 {
|
||||
logger.Debug("Invalid GF_LIVE_CLIENT_QUEUE_MAX_SIZE; using default gRPC max receive size", "value", v, "default", defaultMaxCallRecvMsgSizeBytes, "error", err)
|
||||
} else {
|
||||
maxCallRecvMsgSizeBytes = parsed
|
||||
}
|
||||
}
|
||||
|
||||
dialOps = append(dialOps, grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(maxCallRecvMsgSizeBytes)))
|
||||
dialOps = append(dialOps, grpc.WithChainStreamInterceptor(
|
||||
MetricsStreamInterceptor(),
|
||||
TracingStreamInterceptor(),
|
||||
CustomHeadersStreamInterceptor(opts),
|
||||
UserAgentStreamInterceptor(),
|
||||
))
|
||||
|
||||
if settings.BasicAuthEnabled {
|
||||
// If basic authentication is enabled, it sets the basic authentication header for each RPC call.
|
||||
dialOps = append(dialOps, grpc.WithPerRPCCredentials(&basicAuth{
|
||||
Header: basicHeaderForAuth(opts.BasicAuth.User, opts.BasicAuth.Password),
|
||||
requireTransportSecurity: secure,
|
||||
}))
|
||||
}
|
||||
|
||||
if secure {
|
||||
// If the connection is secure, it uses TLS transport.
|
||||
tls, err := httpclient.GetTLSConfig(opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failure in configuring tls for grpc: %w", err)
|
||||
}
|
||||
|
||||
dialOps = append(dialOps, grpc.WithTransportCredentials(credentials.NewTLS(tls)))
|
||||
} else {
|
||||
// Otherwise, it uses insecure credentials.
|
||||
dialOps = append(dialOps, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
}
|
||||
|
||||
// The following code is required to make gRPC work with Grafana Cloud PDC
|
||||
// (https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/)
|
||||
proxyClient, err := settings.ProxyClient(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("proxy client cannot be retrieved, it is not possible to check if secure socks proxy is enabled: %w", err)
|
||||
}
|
||||
if proxyClient.SecureSocksProxyEnabled() { // secure socks proxy is behind a feature flag
|
||||
dialer, err := proxyClient.NewSecureSocksProxyContextDialer()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failure in creating dialer: %w", err)
|
||||
}
|
||||
logger.Debug("gRPC dialer instantiated. Appending gRPC dialer to dial options")
|
||||
dialOps = append(dialOps, grpc.WithContextDialer(func(ctx context.Context, host string) (net.Conn, error) {
|
||||
logger.Debug("Dialing secure socks proxy", "host", host)
|
||||
conn, err := dialer.Dial("tcp", host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("not possible to dial secure socks proxy: %w", err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.Debug("Context canceled")
|
||||
// We return `conn` anyway since we need to better test how context cancellation works
|
||||
return conn, fmt.Errorf("context canceled: %w", err)
|
||||
default:
|
||||
return conn, nil
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
logger.Debug("Returning dial options")
|
||||
return dialOps, nil
|
||||
}
|
||||
|
||||
// CustomHeadersStreamInterceptor adds custom headers to the outgoing context for each RPC call. Should work similar
|
||||
// to the CustomHeadersMiddleware in the HTTP client provider.
|
||||
func CustomHeadersStreamInterceptor(httpOpts httpclient.Options) grpc.StreamClientInterceptor {
|
||||
return func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
|
||||
if len(httpOpts.Header) != 0 {
|
||||
for key, value := range httpOpts.Header {
|
||||
for _, v := range value {
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, key, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return streamer(ctx, desc, cc, method, opts...)
|
||||
}
|
||||
}
|
||||
|
||||
// UserAgentStreamInterceptor adds user agent to the outgoing context for each RPC call.
|
||||
func UserAgentStreamInterceptor() grpc.StreamClientInterceptor {
|
||||
return func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
|
||||
// Get user agent from context and add it to the outgoing metadata
|
||||
if userAgent := backend.UserAgentFromContext(ctx); userAgent != nil {
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, "User-Agent", userAgent.String())
|
||||
}
|
||||
|
||||
return streamer(ctx, desc, cc, method, opts...)
|
||||
}
|
||||
}
|
||||
|
||||
// TracingStreamInterceptor adds OpenTelemetry tracing support for gRPC streaming calls.
|
||||
// This creates proper OpenTelemetry spans with attributes and error handling.
|
||||
func TracingStreamInterceptor() grpc.StreamClientInterceptor {
|
||||
return func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
|
||||
// Start an OpenTelemetry span for the gRPC call
|
||||
ctx, span := tracing.DefaultTracer().Start(ctx, "tempo.grpc.stream",
|
||||
trace.WithAttributes(
|
||||
attribute.String("rpc.method", method),
|
||||
attribute.String("rpc.service", "tempo"),
|
||||
attribute.String("rpc.system", "grpc"),
|
||||
attribute.String("stream.name", desc.StreamName),
|
||||
attribute.Bool("stream.client", true),
|
||||
),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
logger.Debug("gRPC streaming call", "method", method, "stream_name", desc.StreamName)
|
||||
|
||||
stream, err := streamer(ctx, desc, cc, method, opts...)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return nil, backend.DownstreamErrorf("gRPC streaming call failed: %w", err)
|
||||
}
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return stream, nil
|
||||
}
|
||||
}
|
||||
|
||||
// grpcStatusLabel returns the gRPC status code name for use as a metric label.
|
||||
func grpcStatusLabel(err error) string {
|
||||
if err == nil {
|
||||
return grpccodes.OK.String()
|
||||
}
|
||||
st, ok := grpcstatus.FromError(err)
|
||||
if !ok {
|
||||
return grpccodes.Unknown.String()
|
||||
}
|
||||
return st.Code().String()
|
||||
}
|
||||
|
||||
// MetricsStreamInterceptor adds Prometheus metrics collection for gRPC streaming calls.
|
||||
// This provides similar functionality to the DataSourceMetricsMiddleware for HTTP clients.
|
||||
func MetricsStreamInterceptor() grpc.StreamClientInterceptor {
|
||||
return func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
|
||||
// Initialize metrics lazily
|
||||
initGRPCMetrics()
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// Track in-flight requests
|
||||
grpcInFlightRequests.WithLabelValues(method).Inc()
|
||||
defer grpcInFlightRequests.WithLabelValues(method).Dec()
|
||||
|
||||
stream, err := streamer(ctx, desc, cc, method, opts...)
|
||||
|
||||
// Calculate metrics
|
||||
duration := time.Since(startTime)
|
||||
status := grpcStatusLabel(err)
|
||||
|
||||
// Record metrics
|
||||
grpcRequestsTotal.WithLabelValues(method, status).Inc()
|
||||
grpcRequestDuration.WithLabelValues(method, status).Observe(duration.Seconds())
|
||||
|
||||
logger.Debug("gRPC streaming call completed",
|
||||
"method", method,
|
||||
"duration_ms", duration.Milliseconds(),
|
||||
"status", status)
|
||||
|
||||
return stream, err
|
||||
}
|
||||
}
|
||||
|
||||
type basicAuth struct {
|
||||
Header string
|
||||
requireTransportSecurity bool
|
||||
}
|
||||
|
||||
func (c *basicAuth) GetRequestMetadata(context.Context, ...string) (map[string]string, error) {
|
||||
return map[string]string{
|
||||
"Authorization": c.Header,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *basicAuth) RequireTransportSecurity() bool {
|
||||
return c.requireTransportSecurity
|
||||
}
|
||||
|
||||
func basicHeaderForAuth(username, password string) string {
|
||||
return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", username, password))))
|
||||
}
|
||||
201
pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go
generated
201
pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go
generated
|
|
@ -1,201 +0,0 @@
|
|||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
//
|
||||
// Generated by:
|
||||
// public/app/plugins/gen.go
|
||||
// Using jennies:
|
||||
// PluginGoTypesJenny
|
||||
//
|
||||
// Run 'make gen-cue' from repository root to regenerate.
|
||||
|
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
|
||||
package dataquery
|
||||
|
||||
import (
|
||||
json "encoding/json"
|
||||
errors "errors"
|
||||
)
|
||||
|
||||
type TempoQuery struct {
|
||||
// A unique identifier for the query within the list of targets.
|
||||
// In server side expressions, the refId is used as a variable name to identify results.
|
||||
// By default, the UI will assign A->Z; however setting meaningful names may be useful.
|
||||
RefId string `json:"refId"`
|
||||
// If hide is set to true, Grafana will filter out the response(s) associated with this query before returning it to the panel.
|
||||
Hide *bool `json:"hide,omitempty"`
|
||||
// Specify the query flavor
|
||||
// TODO make this required and give it a default
|
||||
QueryType *string `json:"queryType,omitempty"`
|
||||
// TraceQL query or trace ID
|
||||
Query *string `json:"query,omitempty"`
|
||||
// @deprecated Logfmt query to filter traces by their tags. Example: http.status_code=200 error=true
|
||||
Search *string `json:"search,omitempty"`
|
||||
// @deprecated Query traces by service name
|
||||
ServiceName *string `json:"serviceName,omitempty"`
|
||||
// @deprecated Query traces by span name
|
||||
SpanName *string `json:"spanName,omitempty"`
|
||||
// @deprecated Define the minimum duration to select traces. Use duration format, for example: 1.2s, 100ms
|
||||
MinDuration *string `json:"minDuration,omitempty"`
|
||||
// @deprecated Define the maximum duration to select traces. Use duration format, for example: 1.2s, 100ms
|
||||
MaxDuration *string `json:"maxDuration,omitempty"`
|
||||
// Filters to be included in a PromQL query to select data for the service graph. Example: {client="app",service="app"}. Providing multiple values will produce union of results for each filter, using PromQL OR operator internally.
|
||||
ServiceMapQuery *StringOrArrayOfString `json:"serviceMapQuery,omitempty"`
|
||||
// Use service.namespace in addition to service.name to uniquely identify a service.
|
||||
ServiceMapIncludeNamespace *bool `json:"serviceMapIncludeNamespace,omitempty"`
|
||||
// Whether to use native histograms for service map queries
|
||||
ServiceMapUseNativeHistograms *bool `json:"serviceMapUseNativeHistograms,omitempty"`
|
||||
// Defines the maximum number of traces that are returned from Tempo
|
||||
Limit *int64 `json:"limit,omitempty"`
|
||||
// Defines the maximum number of spans per spanset that are returned from Tempo
|
||||
Spss *int64 `json:"spss,omitempty"`
|
||||
Filters []TraceqlFilter `json:"filters"`
|
||||
// deprecated Filters that are used to query the metrics summary
|
||||
GroupBy []TraceqlFilter `json:"groupBy,omitempty"`
|
||||
// The type of the table that is used to display the search results
|
||||
TableType *SearchTableType `json:"tableType,omitempty"`
|
||||
// For metric queries, the step size to use
|
||||
Step *string `json:"step,omitempty"`
|
||||
// For metric queries, how many exemplars to request, 0 means no exemplars
|
||||
Exemplars *int64 `json:"exemplars,omitempty"`
|
||||
// For mixed data sources the selected datasource is on the query level.
|
||||
// For non mixed scenarios this is undefined.
|
||||
// TODO find a better way to do this ^ that's friendly to schema
|
||||
// TODO this shouldn't be unknown but DataSourceRef | null
|
||||
Datasource any `json:"datasource,omitempty"`
|
||||
// For metric queries, whether to run instant or range queries
|
||||
MetricsQueryType *MetricsQueryType `json:"metricsQueryType,omitempty"`
|
||||
}
|
||||
|
||||
// NewTempoQuery creates a new TempoQuery object.
|
||||
func NewTempoQuery() *TempoQuery {
|
||||
return &TempoQuery{
|
||||
Filters: []TraceqlFilter{},
|
||||
}
|
||||
}
|
||||
|
||||
type TraceqlFilter struct {
|
||||
// Uniquely identify the filter, will not be used in the query generation
|
||||
Id string `json:"id"`
|
||||
// The tag for the search filter, for example: .http.status_code, .service.name, status
|
||||
Tag *string `json:"tag,omitempty"`
|
||||
// The operator that connects the tag to the value, for example: =, >, !=, =~
|
||||
Operator *string `json:"operator,omitempty"`
|
||||
// The value for the search filter
|
||||
Value *StringOrArrayOfString `json:"value,omitempty"`
|
||||
// The type of the value, used for example to check whether we need to wrap the value in quotes when generating the query
|
||||
ValueType *string `json:"valueType,omitempty"`
|
||||
// The scope of the filter, can either be unscoped/all scopes, resource or span
|
||||
Scope *TraceqlSearchScope `json:"scope,omitempty"`
|
||||
// Whether the value is a custom value typed by the user
|
||||
IsCustomValue *bool `json:"isCustomValue,omitempty"`
|
||||
}
|
||||
|
||||
// NewTraceqlFilter creates a new TraceqlFilter object.
|
||||
func NewTraceqlFilter() *TraceqlFilter {
|
||||
return &TraceqlFilter{}
|
||||
}
|
||||
|
||||
// static fields are pre-set in the UI, dynamic fields are added by the user
|
||||
type TraceqlSearchScope string
|
||||
|
||||
const (
|
||||
TraceqlSearchScopeIntrinsic TraceqlSearchScope = "intrinsic"
|
||||
TraceqlSearchScopeUnscoped TraceqlSearchScope = "unscoped"
|
||||
TraceqlSearchScopeEvent TraceqlSearchScope = "event"
|
||||
TraceqlSearchScopeInstrumentation TraceqlSearchScope = "instrumentation"
|
||||
TraceqlSearchScopeLink TraceqlSearchScope = "link"
|
||||
TraceqlSearchScopeResource TraceqlSearchScope = "resource"
|
||||
TraceqlSearchScopeSpan TraceqlSearchScope = "span"
|
||||
)
|
||||
|
||||
// The type of the table that is used to display the search results
|
||||
type SearchTableType string
|
||||
|
||||
const (
|
||||
SearchTableTypeTraces SearchTableType = "traces"
|
||||
SearchTableTypeSpans SearchTableType = "spans"
|
||||
SearchTableTypeRaw SearchTableType = "raw"
|
||||
)
|
||||
|
||||
type MetricsQueryType string
|
||||
|
||||
const (
|
||||
MetricsQueryTypeRange MetricsQueryType = "range"
|
||||
MetricsQueryTypeInstant MetricsQueryType = "instant"
|
||||
)
|
||||
|
||||
type TempoQueryType string
|
||||
|
||||
const (
|
||||
TempoQueryTypeTraceql TempoQueryType = "traceql"
|
||||
TempoQueryTypeTraceqlSearch TempoQueryType = "traceqlSearch"
|
||||
TempoQueryTypeServiceMap TempoQueryType = "serviceMap"
|
||||
TempoQueryTypeUpload TempoQueryType = "upload"
|
||||
TempoQueryTypeNativeSearch TempoQueryType = "nativeSearch"
|
||||
TempoQueryTypeTraceId TempoQueryType = "traceId"
|
||||
TempoQueryTypeClear TempoQueryType = "clear"
|
||||
)
|
||||
|
||||
// The state of the TraceQL streaming search query
|
||||
type SearchStreamingState string
|
||||
|
||||
const (
|
||||
SearchStreamingStatePending SearchStreamingState = "pending"
|
||||
SearchStreamingStateStreaming SearchStreamingState = "streaming"
|
||||
SearchStreamingStateDone SearchStreamingState = "done"
|
||||
SearchStreamingStateError SearchStreamingState = "error"
|
||||
)
|
||||
|
||||
type StringOrArrayOfString struct {
|
||||
String *string `json:"String,omitempty"`
|
||||
ArrayOfString []string `json:"ArrayOfString,omitempty"`
|
||||
}
|
||||
|
||||
// NewStringOrArrayOfString creates a new StringOrArrayOfString object.
|
||||
func NewStringOrArrayOfString() *StringOrArrayOfString {
|
||||
return &StringOrArrayOfString{}
|
||||
}
|
||||
|
||||
// MarshalJSON implements a custom JSON marshalling logic to encode `StringOrArrayOfString` as JSON.
|
||||
func (resource StringOrArrayOfString) MarshalJSON() ([]byte, error) {
|
||||
if resource.String != nil {
|
||||
return json.Marshal(resource.String)
|
||||
}
|
||||
|
||||
if resource.ArrayOfString != nil {
|
||||
return json.Marshal(resource.ArrayOfString)
|
||||
}
|
||||
|
||||
return []byte("null"), nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements a custom JSON unmarshalling logic to decode `StringOrArrayOfString` from JSON.
|
||||
func (resource *StringOrArrayOfString) UnmarshalJSON(raw []byte) error {
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var errList []error
|
||||
|
||||
// String
|
||||
var String string
|
||||
if err := json.Unmarshal(raw, &String); err != nil {
|
||||
errList = append(errList, err)
|
||||
resource.String = nil
|
||||
} else {
|
||||
resource.String = &String
|
||||
return nil
|
||||
}
|
||||
|
||||
// ArrayOfString
|
||||
var ArrayOfString []string
|
||||
if err := json.Unmarshal(raw, &ArrayOfString); err != nil {
|
||||
errList = append(errList, err)
|
||||
resource.ArrayOfString = nil
|
||||
} else {
|
||||
resource.ArrayOfString = ArrayOfString
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.Join(errList...)
|
||||
}
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
package tempo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/grafana/grafana/pkg/tsdb/tempo/traceql"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/tracing"
|
||||
"github.com/grafana/grafana/pkg/tsdb/tempo/kinds/dataquery"
|
||||
"github.com/grafana/tempo/pkg/tempopb"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
)
|
||||
|
||||
const MetricsPathPrefix = "metrics/"
|
||||
|
||||
type PartialTempoQuery struct {
|
||||
MetricsQueryType *dataquery.MetricsQueryType
|
||||
}
|
||||
|
||||
func (s *Service) runMetricsStream(ctx context.Context, req *backend.RunStreamRequest, sender *backend.StreamSender, datasource *DatasourceInfo) error {
|
||||
ctx, span := tracing.DefaultTracer().Start(ctx, "datasource.tempo.runMetricsStream")
|
||||
defer span.End()
|
||||
|
||||
response := &backend.DataResponse{}
|
||||
|
||||
var backendQuery *backend.DataQuery
|
||||
err := json.Unmarshal(req.Data, &backendQuery)
|
||||
if err != nil {
|
||||
response.Error = fmt.Errorf("error unmarshaling backend query model: %v", err)
|
||||
span.RecordError(response.Error)
|
||||
span.SetStatus(codes.Error, response.Error.Error())
|
||||
return backend.DownstreamErrorf("error unmarshaling backend query model: %v", err)
|
||||
}
|
||||
|
||||
tempoQuery := &PartialTempoQuery{}
|
||||
err = json.Unmarshal(req.Data, tempoQuery)
|
||||
if err != nil {
|
||||
response.Error = fmt.Errorf("error unmarshaling Tempo query model: %v", err)
|
||||
span.RecordError(response.Error)
|
||||
span.SetStatus(codes.Error, response.Error.Error())
|
||||
return backend.DownstreamErrorf("failed to unmarshall Tempo query model: %w", err)
|
||||
}
|
||||
|
||||
var qrr *tempopb.QueryRangeRequest
|
||||
err = json.Unmarshal(req.Data, &qrr)
|
||||
if err != nil {
|
||||
response.Error = fmt.Errorf("error unmarshaling Tempo query model: %v", err)
|
||||
span.RecordError(response.Error)
|
||||
span.SetStatus(codes.Error, response.Error.Error())
|
||||
return backend.DownstreamErrorf("failed to unmarshall Tempo query model: %w", err)
|
||||
}
|
||||
|
||||
if qrr.GetQuery() == "" {
|
||||
return backend.DownstreamErrorf("tempo search query cannot be empty")
|
||||
}
|
||||
|
||||
qrr.Start = uint64(backendQuery.TimeRange.From.UnixNano())
|
||||
qrr.End = uint64(backendQuery.TimeRange.To.UnixNano())
|
||||
|
||||
if isInstantQuery(tempoQuery.MetricsQueryType) {
|
||||
instantQuery := &tempopb.QueryInstantRequest{
|
||||
Query: qrr.Query,
|
||||
Start: qrr.Start,
|
||||
End: qrr.End,
|
||||
}
|
||||
|
||||
stream, err := datasource.StreamingClient.MetricsQueryInstant(ctx, instantQuery)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
s.logger.Error("Error Search()", "err", err)
|
||||
if backend.IsDownstreamHTTPError(err) {
|
||||
return backend.DownstreamError(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return s.processInstantMetricsStream(ctx, stream, sender)
|
||||
}
|
||||
|
||||
stream, err := datasource.StreamingClient.MetricsQueryRange(ctx, qrr)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
s.logger.Error("Error Search()", "err", err)
|
||||
if backend.IsDownstreamHTTPError(err) {
|
||||
return backend.DownstreamError(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return s.processMetricsStream(ctx, qrr.Query, stream, sender)
|
||||
}
|
||||
|
||||
func (s *Service) processMetricsStream(ctx context.Context, query string, stream tempopb.StreamingQuerier_MetricsQueryRangeClient, sender StreamSender) error {
|
||||
ctx, span := tracing.DefaultTracer().Start(ctx, "datasource.tempo.processStream")
|
||||
defer span.End()
|
||||
messageCount := 0
|
||||
for {
|
||||
msg, err := stream.Recv()
|
||||
messageCount++
|
||||
span.SetAttributes(attribute.Int("message_count", messageCount))
|
||||
if errors.Is(err, io.EOF) {
|
||||
if err := s.sendResponse(ctx, nil, nil, dataquery.SearchStreamingStateDone, sender); err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Error("Error receiving message", "err", err)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
transformed := traceql.TransformMetricsResponse(query, *msg)
|
||||
|
||||
if err := s.sendResponse(ctx, transformed, msg.Metrics, dataquery.SearchStreamingStateStreaming, sender); err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) processInstantMetricsStream(ctx context.Context, stream tempopb.StreamingQuerier_MetricsQueryInstantClient, sender StreamSender) error {
|
||||
ctx, span := tracing.DefaultTracer().Start(ctx, "datasource.tempo.processStream")
|
||||
defer span.End()
|
||||
messageCount := 0
|
||||
for {
|
||||
msg, err := stream.Recv()
|
||||
messageCount++
|
||||
span.SetAttributes(attribute.Int("message_count", messageCount))
|
||||
if errors.Is(err, io.EOF) {
|
||||
if err := s.sendResponse(ctx, nil, nil, dataquery.SearchStreamingStateDone, sender); err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Error("Error receiving message", "err", err)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
transformed := traceql.TransformInstantMetricsResponse(*msg)
|
||||
|
||||
if err := s.sendResponse(ctx, transformed, msg.Metrics, dataquery.SearchStreamingStateStreaming, sender); err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
// Copyright The OpenTelemetry Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package tempo
|
||||
|
||||
import (
|
||||
"go.opentelemetry.io/collector/pdata/ptrace"
|
||||
)
|
||||
|
||||
// Some of the keys used to represent OTLP constructs as tags or annotations in other formats.
|
||||
const (
|
||||
TagMessage = "message"
|
||||
|
||||
TagSpanKind = "span.kind"
|
||||
|
||||
TagStatusCode = "status.code"
|
||||
TagStatusMsg = "status.message"
|
||||
TagError = "error"
|
||||
TagHTTPStatusMsg = "http.status_message"
|
||||
|
||||
TagW3CTraceState = "w3c.tracestate"
|
||||
)
|
||||
|
||||
// Constants used for signifying batch-level attribute values where not supplied by OTLP data but required
|
||||
// by other protocols.
|
||||
const (
|
||||
ResourceNoServiceName = "OTLPResourceNoServiceName"
|
||||
)
|
||||
|
||||
// OpenTracingSpanKind are possible values for TagSpanKind and match the OpenTracing
|
||||
// conventions: https://github.com/opentracing/specification/blob/main/semantic_conventions.md
|
||||
// These values are used for representing span kinds that have no
|
||||
// equivalents in OpenCensus format. They are stored as values of TagSpanKind
|
||||
type OpenTracingSpanKind string
|
||||
|
||||
const (
|
||||
OpenTracingSpanKindUnspecified OpenTracingSpanKind = ""
|
||||
OpenTracingSpanKindClient OpenTracingSpanKind = "client"
|
||||
OpenTracingSpanKindServer OpenTracingSpanKind = "server"
|
||||
OpenTracingSpanKindConsumer OpenTracingSpanKind = "consumer"
|
||||
OpenTracingSpanKindProducer OpenTracingSpanKind = "producer"
|
||||
OpenTracingSpanKindInternal OpenTracingSpanKind = "internal"
|
||||
)
|
||||
|
||||
// StatusCodeFromHTTP takes an HTTP status code and return the appropriate OpenTelemetry status code
|
||||
// See: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/http.md#status
|
||||
func StatusCodeFromHTTP(httpStatusCode int) ptrace.StatusCode {
|
||||
if httpStatusCode >= 100 && httpStatusCode < 399 {
|
||||
return ptrace.StatusCodeUnset
|
||||
}
|
||||
return ptrace.StatusCodeError
|
||||
}
|
||||
|
|
@ -1,584 +0,0 @@
|
|||
package tempo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
//nolint:staticcheck // tempopb uses old protobuf API, jsonpb required for compatibility
|
||||
"github.com/golang/protobuf/jsonpb"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/grafana/grafana/pkg/tsdb/tempo/kinds/dataquery"
|
||||
"github.com/grafana/tempo/pkg/tempopb"
|
||||
v1 "github.com/grafana/tempo/pkg/tempopb/common/v1"
|
||||
)
|
||||
|
||||
type DataFrameField struct {
|
||||
Name string
|
||||
Type interface{}
|
||||
Config data.FieldConfig
|
||||
}
|
||||
|
||||
type TraceTableData struct {
|
||||
traceIdHidden string
|
||||
spanID string
|
||||
time time.Time
|
||||
name string
|
||||
duration float64
|
||||
attributes map[string]interface{}
|
||||
}
|
||||
|
||||
func (s *Service) Search(ctx context.Context, pCtx backend.PluginContext, query backend.DataQuery) (*backend.DataResponse, error) {
|
||||
ctxLogger := s.logger.FromContext(ctx)
|
||||
model := &dataquery.TempoQuery{}
|
||||
result := &backend.DataResponse{}
|
||||
|
||||
dsInfo, err := s.getDSInfo(ctx, pCtx)
|
||||
if err != nil {
|
||||
ctxLogger.Error("Failed to get datasource information", "error", err, "function", logEntrypoint())
|
||||
return nil, backend.DownstreamErrorf("failed to get datasource information: %w", err)
|
||||
}
|
||||
|
||||
err = json.Unmarshal(query.JSON, model)
|
||||
if err != nil {
|
||||
ctxLogger.Error("Failed to unmarshall Tempo query model", "error", err, "function", logEntrypoint())
|
||||
return nil, backend.DownstreamErrorf("failed to unmarshall Tempo query model: %w", err)
|
||||
}
|
||||
|
||||
req, err := createSearchRequest(ctx, dsInfo, model, query.TimeRange.From.Unix(), query.TimeRange.To.Unix())
|
||||
if err != nil {
|
||||
ctxLogger.Error("Failed to create search request", "error", err, "function", logEntrypoint())
|
||||
return nil, backend.DownstreamErrorf("failed to create search request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := dsInfo.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
ctxLogger.Error("Failed to send request to Tempo", "error", err, "function", logEntrypoint())
|
||||
if backend.IsDownstreamHTTPError(err) {
|
||||
return nil, backend.DownstreamError(err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if resp != nil && resp.Body != nil {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
ctxLogger.Error("Failed to close response body", "error", err, "function", logEntrypoint())
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
ctxLogger.Error("Failed to read response body", "error", err, "function", logEntrypoint())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
ctxLogger.Error("Failed to execute search query", "error", err, "function", logEntrypoint())
|
||||
err := fmt.Errorf("failed to execute search query status: %s", resp.Status)
|
||||
if backend.ErrorSourceFromHTTPStatus(resp.StatusCode) == backend.ErrorSourceDownstream {
|
||||
return nil, backend.DownstreamError(err)
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response tempopb.SearchResponse
|
||||
err = jsonpb.Unmarshal(bytes.NewReader(body), &response)
|
||||
|
||||
if err != nil {
|
||||
ctxLogger.Error("Failed to unmarshal response to SearchResponse", "error", err, "function", logEntrypoint())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Match frontend behavior: when tableType isn't set, default to the "table" view (traces).
|
||||
tableType := dataquery.SearchTableTypeTraces
|
||||
if model.TableType != nil {
|
||||
tableType = *model.TableType
|
||||
}
|
||||
|
||||
switch tableType {
|
||||
case dataquery.SearchTableTypeSpans:
|
||||
frames, err := transformSpanSearchResponse(pCtx, &response)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Frames = frames
|
||||
case dataquery.SearchTableTypeRaw:
|
||||
frames, err := transformRawSearchResponse(&response)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Frames = frames
|
||||
default:
|
||||
frames, err := transformTraceSearchResponse(pCtx, &response)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Frames = frames
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func createSearchRequest(ctx context.Context, dsInfo *DatasourceInfo, model *dataquery.TempoQuery, start int64, end int64) (*http.Request, error) {
|
||||
baseURL, err := url.Parse(dsInfo.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid base URL: %w", err)
|
||||
}
|
||||
|
||||
searchURL, err := url.JoinPath(baseURL.String(), "api", "search")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to join URL path: %w", err)
|
||||
}
|
||||
|
||||
parsedURL, err := url.Parse(searchURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse search URL: %w", err)
|
||||
}
|
||||
|
||||
query := parsedURL.Query()
|
||||
|
||||
if model.Query != nil && *model.Query != "" {
|
||||
query.Set("q", *model.Query)
|
||||
}
|
||||
|
||||
if model.Limit != nil && *model.Limit > 0 {
|
||||
query.Set("limit", fmt.Sprintf("%d", *model.Limit))
|
||||
}
|
||||
|
||||
if model.Spss != nil && *model.Spss > 0 {
|
||||
query.Set("spss", fmt.Sprintf("%d", *model.Spss))
|
||||
}
|
||||
|
||||
if start != 0 && end != 0 {
|
||||
query.Set("start", fmt.Sprintf("%d", start))
|
||||
query.Set("end", fmt.Sprintf("%d", end))
|
||||
}
|
||||
|
||||
parsedURL.RawQuery = query.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", parsedURL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func transformTraceSearchResponse(pCtx backend.PluginContext, response *tempopb.SearchResponse) ([]*data.Frame, error) {
|
||||
tracesFrame := data.NewFrame("Traces")
|
||||
tracesFrame.Fields = append(tracesFrame.Fields, data.NewField("traceID", nil, []string{}).SetConfig(&data.FieldConfig{
|
||||
DisplayNameFromDS: "Trace ID",
|
||||
Links: []data.DataLink{
|
||||
{
|
||||
Title: "Trace: ${__value.raw}",
|
||||
URL: "",
|
||||
Internal: &data.InternalDataLink{
|
||||
DatasourceUID: pCtx.DataSourceInstanceSettings.UID,
|
||||
DatasourceName: pCtx.DataSourceInstanceSettings.Name,
|
||||
Query: map[string]interface{}{
|
||||
"query": "${__value.raw}",
|
||||
"queryType": "traceql",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
tracesFrame.Fields = append(tracesFrame.Fields, data.NewField("startTime", nil, []time.Time{}).SetConfig(&data.FieldConfig{
|
||||
DisplayNameFromDS: "Start time",
|
||||
}))
|
||||
tracesFrame.Fields = append(tracesFrame.Fields, data.NewField("traceService", nil, []string{}).SetConfig(&data.FieldConfig{
|
||||
DisplayNameFromDS: "Service",
|
||||
}))
|
||||
tracesFrame.Fields = append(tracesFrame.Fields, data.NewField("traceName", nil, []string{}).SetConfig(&data.FieldConfig{
|
||||
DisplayNameFromDS: "Name",
|
||||
}))
|
||||
tracesFrame.Fields = append(tracesFrame.Fields, data.NewField("traceDuration", nil, []*float64{}).SetConfig(&data.FieldConfig{
|
||||
DisplayNameFromDS: "Duration",
|
||||
Unit: "ms",
|
||||
NoValue: "<1 ms",
|
||||
}))
|
||||
tracesFrame.Fields = append(tracesFrame.Fields, data.NewField("nested", nil, []json.RawMessage{}))
|
||||
|
||||
tracesFrame.Meta = &data.FrameMeta{
|
||||
PreferredVisualization: data.VisTypeTable,
|
||||
UniqueRowIDFields: []int{0},
|
||||
}
|
||||
|
||||
if response == nil {
|
||||
return []*data.Frame{tracesFrame}, nil
|
||||
}
|
||||
|
||||
if len(response.Traces) == 0 {
|
||||
return []*data.Frame{tracesFrame}, nil
|
||||
}
|
||||
|
||||
traces := make([]*tempopb.TraceSearchMetadata, len(response.Traces))
|
||||
copy(traces, response.Traces)
|
||||
|
||||
sort.Slice(traces, func(i, j int) bool {
|
||||
return traces[i].StartTimeUnixNano > traces[j].StartTimeUnixNano
|
||||
})
|
||||
|
||||
for _, trace := range traces {
|
||||
var traceDurationMs *float64
|
||||
if trace.DurationMs >= 1 {
|
||||
val := float64(trace.DurationMs)
|
||||
traceDurationMs = &val
|
||||
} else {
|
||||
traceDurationMs = nil
|
||||
}
|
||||
|
||||
nestedFrames := []json.RawMessage{}
|
||||
|
||||
// Collect the union of dynamic-attribute fields across every span set on this
|
||||
// trace before building any subframe. Consumers like the Table visualization
|
||||
// require every frame nested under one cell to share the same schema, so each
|
||||
// subframe must declare the same fields in the same order even when individual
|
||||
// span sets only carry a subset of those attributes.
|
||||
var spanSets []*tempopb.SpanSet
|
||||
if trace.SpanSet != nil {
|
||||
spanSets = []*tempopb.SpanSet{trace.SpanSet}
|
||||
} else if len(trace.SpanSets) > 0 {
|
||||
spanSets = trace.SpanSets
|
||||
}
|
||||
|
||||
spanDynamicAttributes, spanAttributeNames, hasNameAttribute := collectSpanSetsSchema(spanSets)
|
||||
|
||||
for _, spanSet := range spanSets {
|
||||
subFrame := transformTraceSearchResponseSubFrame(trace, spanSet, pCtx, spanAttributeNames, spanDynamicAttributes, hasNameAttribute)
|
||||
subFrameJSON, err := json.Marshal(subFrame)
|
||||
if err != nil {
|
||||
backend.Logger.Error("Failed to marshal subFrame", "error", err)
|
||||
nestedFrames = append(nestedFrames, json.RawMessage("{}"))
|
||||
} else {
|
||||
nestedFrames = append(nestedFrames, json.RawMessage(subFrameJSON))
|
||||
}
|
||||
}
|
||||
|
||||
nestedFramesBytes, _ := json.Marshal(nestedFrames)
|
||||
nestedFramesJSON := json.RawMessage(nestedFramesBytes)
|
||||
tracesFrame.Fields[5].Append(nestedFramesJSON)
|
||||
|
||||
tracesFrame.Fields[0].Append(trace.TraceID)
|
||||
tracesFrame.Fields[1].Append(time.Unix(0, int64(trace.StartTimeUnixNano)))
|
||||
tracesFrame.Fields[2].Append(trace.RootServiceName)
|
||||
tracesFrame.Fields[3].Append(trace.RootTraceName)
|
||||
tracesFrame.Fields[4].Append(traceDurationMs)
|
||||
}
|
||||
|
||||
return []*data.Frame{tracesFrame}, nil
|
||||
}
|
||||
|
||||
// collectSpanSetsSchema walks every span set (and every span within them) and
|
||||
// returns the union of dynamic-attribute fields keyed by attribute name, the
|
||||
// stable sorted list of those names, and whether any span carries a name. The
|
||||
// caller passes this back into transformTraceSearchResponseSubFrame so every
|
||||
// subframe nested under a single trace row exposes the same fields in the same
|
||||
// order — required for the Table visualization's nestedFrames contract.
|
||||
func collectSpanSetsSchema(spanSets []*tempopb.SpanSet) (map[string]*DataFrameField, []string, bool) {
|
||||
spanDynamicAttributes := make(map[string]*DataFrameField)
|
||||
hasNameAttribute := false
|
||||
|
||||
for _, spanSet := range spanSets {
|
||||
for _, attribute := range spanSet.Attributes {
|
||||
spanDynamicAttributes[attribute.Key] = &DataFrameField{
|
||||
Name: attribute.Key,
|
||||
Type: getTypeForAttribute(attribute),
|
||||
Config: data.FieldConfig{DisplayNameFromDS: attribute.Key},
|
||||
}
|
||||
}
|
||||
for _, span := range spanSet.Spans {
|
||||
if span.Name != "" {
|
||||
hasNameAttribute = true
|
||||
}
|
||||
for _, attribute := range span.Attributes {
|
||||
spanDynamicAttributes[attribute.Key] = &DataFrameField{
|
||||
Name: attribute.Key,
|
||||
Type: getTypeForAttribute(attribute),
|
||||
Config: data.FieldConfig{DisplayNameFromDS: attribute.Key},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
spanAttributeNames := make([]string, 0, len(spanDynamicAttributes))
|
||||
for name := range spanDynamicAttributes {
|
||||
spanAttributeNames = append(spanAttributeNames, name)
|
||||
}
|
||||
sort.Strings(spanAttributeNames)
|
||||
|
||||
return spanDynamicAttributes, spanAttributeNames, hasNameAttribute
|
||||
}
|
||||
|
||||
func transformTraceSearchResponseSubFrame(
|
||||
trace *tempopb.TraceSearchMetadata,
|
||||
spanSet *tempopb.SpanSet,
|
||||
pCtx backend.PluginContext,
|
||||
spanAttributeNames []string,
|
||||
spanDynamicAttributes map[string]*DataFrameField,
|
||||
hasNameAttribute bool,
|
||||
) *data.Frame {
|
||||
frame := data.NewFrame("Spans")
|
||||
panelsState := data.ExplorePanelsState(map[string]interface{}{"trace": map[string]interface{}{"spanId": "${__value.raw}"}})
|
||||
frame.Fields = append(frame.Fields, data.NewField("traceIdHidden", nil, []string{}).SetConfig(&data.FieldConfig{
|
||||
Custom: map[string]interface{}{"hideFrom": map[string]interface{}{"viz": true}},
|
||||
}))
|
||||
frame.Fields = append(frame.Fields, data.NewField("spanID", nil, []string{}).SetConfig(&data.FieldConfig{
|
||||
DisplayNameFromDS: "Span ID",
|
||||
Unit: "string",
|
||||
Custom: map[string]interface{}{"width": 200},
|
||||
Links: []data.DataLink{
|
||||
{
|
||||
Title: "Span: ${__value.raw}",
|
||||
URL: "",
|
||||
Internal: &data.InternalDataLink{
|
||||
DatasourceUID: pCtx.DataSourceInstanceSettings.UID,
|
||||
DatasourceName: pCtx.DataSourceInstanceSettings.Name,
|
||||
Query: map[string]interface{}{
|
||||
"query": "${__data.fields.traceIdHidden}",
|
||||
"queryType": "traceql",
|
||||
},
|
||||
ExplorePanelsState: &panelsState,
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
frame.Fields = append(frame.Fields, data.NewField("time", nil, []time.Time{}).SetConfig(&data.FieldConfig{
|
||||
DisplayNameFromDS: "Start time",
|
||||
Custom: map[string]interface{}{"width": 200},
|
||||
}))
|
||||
frame.Fields = append(frame.Fields, data.NewField("name", nil, []string{}).SetConfig(&data.FieldConfig{
|
||||
DisplayNameFromDS: "Name",
|
||||
Custom: map[string]interface{}{"hideFrom": map[string]interface{}{"viz": !hasNameAttribute}},
|
||||
}))
|
||||
for _, attributeName := range spanAttributeNames {
|
||||
field := spanDynamicAttributes[attributeName]
|
||||
frame.Fields = append(frame.Fields, data.NewField(field.Name, nil, field.Type).SetConfig(&field.Config))
|
||||
}
|
||||
frame.Fields = append(frame.Fields, data.NewField("duration", nil, []float64{}).SetConfig(&data.FieldConfig{
|
||||
DisplayNameFromDS: "Duration",
|
||||
Unit: "ns",
|
||||
Custom: map[string]interface{}{"width": 120},
|
||||
}))
|
||||
|
||||
frame.Meta = &data.FrameMeta{
|
||||
PreferredVisualization: data.VisTypeTable,
|
||||
}
|
||||
|
||||
for _, span := range spanSet.Spans {
|
||||
traceData := transformSpanToTraceData(span, spanSet, trace)
|
||||
frame.Fields[0].Append(traceData.traceIdHidden)
|
||||
frame.Fields[1].Append(traceData.spanID)
|
||||
frame.Fields[2].Append(traceData.time)
|
||||
frame.Fields[3].Append(traceData.name)
|
||||
attributeIndex := 4
|
||||
for _, attributeName := range spanAttributeNames {
|
||||
if attribute, ok := traceData.attributes[attributeName]; ok {
|
||||
frame.Fields[attributeIndex].Append(attribute)
|
||||
} else {
|
||||
frame.Fields[attributeIndex].Append(nil)
|
||||
}
|
||||
attributeIndex++
|
||||
}
|
||||
frame.Fields[attributeIndex].Append(traceData.duration)
|
||||
}
|
||||
|
||||
return frame
|
||||
}
|
||||
|
||||
func transformSpanSearchResponse(pCtx backend.PluginContext, response *tempopb.SearchResponse) ([]*data.Frame, error) {
|
||||
// Aggregate every span set across every trace into one flat list so the schema
|
||||
// is the union over the whole response — a single Spans frame must declare one
|
||||
// stable set of dynamic-attribute columns for all rows it emits below.
|
||||
var allSpanSets []*tempopb.SpanSet
|
||||
if response != nil {
|
||||
for _, trace := range response.Traces {
|
||||
allSpanSets = append(allSpanSets, trace.SpanSets...)
|
||||
}
|
||||
}
|
||||
spanDynamicAttributes, spanAttributeNames, hasNameAttribute := collectSpanSetsSchema(allSpanSets)
|
||||
|
||||
spansFrame := data.NewFrame("Spans")
|
||||
panelsState := data.ExplorePanelsState(map[string]interface{}{"trace": map[string]interface{}{"spanId": "${__value.raw}"}})
|
||||
spansFrame.Fields = append(spansFrame.Fields, data.NewField("traceIdHidden", nil, []string{}).SetConfig(&data.FieldConfig{
|
||||
Custom: map[string]interface{}{"hideFrom": map[string]interface{}{"viz": true}},
|
||||
}))
|
||||
spansFrame.Fields = append(spansFrame.Fields, data.NewField("traceService", nil, []string{}).SetConfig(&data.FieldConfig{
|
||||
DisplayNameFromDS: "Trace Service",
|
||||
Custom: map[string]interface{}{"width": 200},
|
||||
}))
|
||||
spansFrame.Fields = append(spansFrame.Fields, data.NewField("traceName", nil, []string{}).SetConfig(&data.FieldConfig{
|
||||
DisplayNameFromDS: "Trace Name",
|
||||
Custom: map[string]interface{}{"width": 200},
|
||||
}))
|
||||
spansFrame.Fields = append(spansFrame.Fields, data.NewField("spanID", nil, []string{}).SetConfig(&data.FieldConfig{
|
||||
DisplayNameFromDS: "Span ID",
|
||||
Unit: "string",
|
||||
Custom: map[string]interface{}{"width": 200},
|
||||
Links: []data.DataLink{
|
||||
{
|
||||
Title: "Span: ${__value.raw}",
|
||||
URL: "",
|
||||
Internal: &data.InternalDataLink{
|
||||
DatasourceUID: pCtx.DataSourceInstanceSettings.UID,
|
||||
DatasourceName: pCtx.DataSourceInstanceSettings.Name,
|
||||
Query: map[string]interface{}{
|
||||
"query": "${__data.fields.traceIdHidden}",
|
||||
"queryType": "traceql",
|
||||
},
|
||||
ExplorePanelsState: &panelsState,
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
spansFrame.Fields = append(spansFrame.Fields, data.NewField("time", nil, []time.Time{}).SetConfig(&data.FieldConfig{
|
||||
DisplayNameFromDS: "Start time",
|
||||
}))
|
||||
spansFrame.Fields = append(spansFrame.Fields, data.NewField("name", nil, []string{}).SetConfig(&data.FieldConfig{
|
||||
DisplayNameFromDS: "Name",
|
||||
Custom: map[string]interface{}{"hideFrom": map[string]interface{}{"viz": !hasNameAttribute}},
|
||||
}))
|
||||
for _, attributeName := range spanAttributeNames {
|
||||
field := spanDynamicAttributes[attributeName]
|
||||
spansFrame.Fields = append(spansFrame.Fields, data.NewField(field.Name, nil, field.Type).SetConfig(&field.Config))
|
||||
}
|
||||
spansFrame.Fields = append(spansFrame.Fields, data.NewField("duration", nil, []float64{}).SetConfig(&data.FieldConfig{
|
||||
DisplayNameFromDS: "Duration",
|
||||
Unit: "ns",
|
||||
Custom: map[string]interface{}{"width": 120},
|
||||
}))
|
||||
|
||||
spansFrame.Meta = &data.FrameMeta{
|
||||
PreferredVisualization: data.VisTypeTable,
|
||||
}
|
||||
|
||||
if response == nil {
|
||||
return []*data.Frame{spansFrame}, nil
|
||||
}
|
||||
|
||||
if len(response.Traces) == 0 {
|
||||
return []*data.Frame{spansFrame}, nil
|
||||
}
|
||||
|
||||
traces := make([]*tempopb.TraceSearchMetadata, len(response.Traces))
|
||||
copy(traces, response.Traces)
|
||||
|
||||
sort.Slice(traces, func(i, j int) bool {
|
||||
return traces[i].StartTimeUnixNano > traces[j].StartTimeUnixNano
|
||||
})
|
||||
|
||||
for _, trace := range traces {
|
||||
for _, spanSet := range trace.SpanSets {
|
||||
for _, span := range spanSet.Spans {
|
||||
traceData := transformSpanToTraceData(span, spanSet, trace)
|
||||
spansFrame.Fields[0].Append(traceData.traceIdHidden)
|
||||
spansFrame.Fields[1].Append(trace.RootServiceName)
|
||||
spansFrame.Fields[2].Append(trace.RootTraceName)
|
||||
spansFrame.Fields[3].Append(traceData.spanID)
|
||||
spansFrame.Fields[4].Append(traceData.time)
|
||||
spansFrame.Fields[5].Append(traceData.name)
|
||||
attributeIndex := 6
|
||||
for _, attributeName := range spanAttributeNames {
|
||||
if attribute, ok := traceData.attributes[attributeName]; ok {
|
||||
spansFrame.Fields[attributeIndex].Append(attribute)
|
||||
} else {
|
||||
spansFrame.Fields[attributeIndex].Append(nil)
|
||||
}
|
||||
attributeIndex++
|
||||
}
|
||||
spansFrame.Fields[attributeIndex].Append(traceData.duration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return []*data.Frame{spansFrame}, nil
|
||||
}
|
||||
|
||||
func transformRawSearchResponse(response *tempopb.SearchResponse) ([]*data.Frame, error) {
|
||||
rawFrame := data.NewFrame("Raw response")
|
||||
rawFrame.Fields = append(rawFrame.Fields, data.NewField("response", nil, []string{}))
|
||||
|
||||
raw, err := json.MarshalIndent(response, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rawFrame.Fields[0].Append(string(raw))
|
||||
return []*data.Frame{rawFrame}, nil
|
||||
}
|
||||
|
||||
func transformSpanToTraceData(span *tempopb.Span, spanSet *tempopb.SpanSet, trace *tempopb.TraceSearchMetadata) *TraceTableData {
|
||||
attributes := make(map[string]interface{})
|
||||
allAttributes := make([]*v1.KeyValue, 0, len(spanSet.Attributes)+len(span.Attributes))
|
||||
|
||||
if spanSet.Attributes != nil {
|
||||
allAttributes = append(allAttributes, spanSet.Attributes...)
|
||||
}
|
||||
|
||||
if span.Attributes != nil {
|
||||
allAttributes = append(allAttributes, span.Attributes...)
|
||||
}
|
||||
|
||||
for _, attribute := range allAttributes {
|
||||
switch attribute.Value.GetValue().(type) {
|
||||
case *v1.AnyValue_StringValue:
|
||||
val := attribute.Value.GetStringValue()
|
||||
attributes[attribute.Key] = &val
|
||||
case *v1.AnyValue_IntValue:
|
||||
// Use float64 for int tags so dynamic columns stay consistent when the same key
|
||||
// appears as IntValue on some spans and DoubleValue on others (see getTypeForAttribute).
|
||||
v := float64(attribute.Value.GetIntValue())
|
||||
attributes[attribute.Key] = &v
|
||||
case *v1.AnyValue_DoubleValue:
|
||||
val := attribute.Value.GetDoubleValue()
|
||||
attributes[attribute.Key] = &val
|
||||
case *v1.AnyValue_BoolValue:
|
||||
val := attribute.Value.GetBoolValue()
|
||||
attributes[attribute.Key] = &val
|
||||
case *v1.AnyValue_BytesValue:
|
||||
val := attribute.Value.GetBytesValue()
|
||||
attributes[attribute.Key] = &val
|
||||
default:
|
||||
val := attribute.Value.GetValue()
|
||||
attributes[attribute.Key] = &val
|
||||
}
|
||||
}
|
||||
|
||||
return &TraceTableData{
|
||||
traceIdHidden: trace.TraceID,
|
||||
spanID: span.SpanID,
|
||||
time: time.Unix(0, int64(span.StartTimeUnixNano)),
|
||||
name: span.Name,
|
||||
duration: float64(span.DurationNanos),
|
||||
attributes: attributes,
|
||||
}
|
||||
}
|
||||
|
||||
func getTypeForAttribute(attribute *v1.KeyValue) interface{} {
|
||||
switch attribute.Value.GetValue().(type) {
|
||||
case *v1.AnyValue_StringValue:
|
||||
return []*string{}
|
||||
case *v1.AnyValue_IntValue:
|
||||
// Match transformSpanToTraceData: ints are stored as *float64 so one column can
|
||||
// hold both OTLP integer and double values for the same attribute key.
|
||||
return []*float64{}
|
||||
case *v1.AnyValue_DoubleValue:
|
||||
return []*float64{}
|
||||
case *v1.AnyValue_BoolValue:
|
||||
return []*bool{}
|
||||
case *v1.AnyValue_BytesValue:
|
||||
return []*[]byte{}
|
||||
}
|
||||
return []*string{}
|
||||
}
|
||||
|
|
@ -1,200 +0,0 @@
|
|||
package tempo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/tracing"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/grafana/grafana/pkg/tsdb/tempo/kinds/dataquery"
|
||||
"github.com/grafana/tempo/pkg/tempopb"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
)
|
||||
|
||||
const SearchPathPrefix = "search/"
|
||||
|
||||
type ExtendedResponse struct {
|
||||
*tempopb.SearchResponse
|
||||
State dataquery.SearchStreamingState
|
||||
}
|
||||
|
||||
type StreamSender interface {
|
||||
SendFrame(frame *data.Frame, include data.FrameInclude) error
|
||||
SendJSON(data []byte) error
|
||||
SendBytes(data []byte) error
|
||||
}
|
||||
|
||||
func (s *Service) runSearchStream(ctx context.Context, req *backend.RunStreamRequest, sender *backend.StreamSender, datasource *DatasourceInfo) error {
|
||||
ctx, span := tracing.DefaultTracer().Start(ctx, "datasource.tempo.runSearchStream")
|
||||
defer span.End()
|
||||
|
||||
response := &backend.DataResponse{}
|
||||
|
||||
var backendQuery *backend.DataQuery
|
||||
err := json.Unmarshal(req.Data, &backendQuery)
|
||||
if err != nil {
|
||||
response.Error = fmt.Errorf("error unmarshaling backend query model: %v", err)
|
||||
span.RecordError(response.Error)
|
||||
span.SetStatus(codes.Error, response.Error.Error())
|
||||
return backend.DownstreamErrorf("error unmarshaling backend query model: %v", err)
|
||||
}
|
||||
|
||||
var sr *tempopb.SearchRequest
|
||||
err = json.Unmarshal(req.Data, &sr)
|
||||
if err != nil {
|
||||
response.Error = fmt.Errorf("error unmarshaling Tempo query model: %v", err)
|
||||
span.RecordError(response.Error)
|
||||
span.SetStatus(codes.Error, response.Error.Error())
|
||||
return backend.DownstreamErrorf("failed to unmarshall Tempo query model: %w", err)
|
||||
}
|
||||
|
||||
if sr.GetQuery() == "" {
|
||||
return backend.DownstreamErrorf("tempo search query cannot be empty")
|
||||
}
|
||||
|
||||
sr.Start = uint32(backendQuery.TimeRange.From.Unix())
|
||||
sr.End = uint32(backendQuery.TimeRange.To.Unix())
|
||||
|
||||
stream, err := datasource.StreamingClient.Search(ctx, sr)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
s.logger.Error("Error Search()", "err", err)
|
||||
if backend.IsDownstreamHTTPError(err) {
|
||||
return backend.DownstreamError(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return s.processStream(ctx, stream, sender)
|
||||
}
|
||||
|
||||
func (s *Service) processStream(ctx context.Context, stream tempopb.StreamingQuerier_SearchClient, sender StreamSender) error {
|
||||
ctx, span := tracing.DefaultTracer().Start(ctx, "datasource.tempo.processStream")
|
||||
defer span.End()
|
||||
var traceList []*tempopb.TraceSearchMetadata
|
||||
var metrics *tempopb.SearchMetrics
|
||||
messageCount := 0
|
||||
for {
|
||||
msg, err := stream.Recv()
|
||||
messageCount++
|
||||
span.SetAttributes(attribute.Int("message_count", messageCount))
|
||||
if errors.Is(err, io.EOF) {
|
||||
if err := s.sendSearchResponse(ctx, &ExtendedResponse{
|
||||
State: dataquery.SearchStreamingStateDone,
|
||||
SearchResponse: &tempopb.SearchResponse{
|
||||
Metrics: metrics,
|
||||
Traces: traceList,
|
||||
},
|
||||
}, sender); err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Error("Error receiving message", "err", err)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
metrics = msg.Metrics
|
||||
traceList = append(traceList, msg.Traces...)
|
||||
traceList = removeDuplicates(traceList)
|
||||
span.SetAttributes(attribute.Int("traces_count", len(traceList)))
|
||||
|
||||
if err := s.sendSearchResponse(ctx, &ExtendedResponse{
|
||||
State: dataquery.SearchStreamingStateStreaming,
|
||||
SearchResponse: &tempopb.SearchResponse{
|
||||
Metrics: metrics,
|
||||
Traces: traceList,
|
||||
},
|
||||
}, sender); err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) sendSearchResponse(ctx context.Context, response *ExtendedResponse, sender StreamSender) error {
|
||||
_, span := tracing.DefaultTracer().Start(ctx, "datasource.tempo.sendSearchResponse")
|
||||
defer span.End()
|
||||
frame := createResponseDataFrame()
|
||||
|
||||
if response != nil {
|
||||
span.SetAttributes(attribute.Int("trace_count", len(response.Traces)), attribute.String("state", string(response.State)))
|
||||
return s.sendResponse(ctx, response.Traces, response.Metrics, response.State, sender)
|
||||
}
|
||||
|
||||
return sender.SendFrame(frame, data.IncludeAll)
|
||||
}
|
||||
|
||||
func (s *Service) sendResponse(ctx context.Context, result interface{}, metrics *tempopb.SearchMetrics, state dataquery.SearchStreamingState, sender StreamSender) error {
|
||||
_, span := tracing.DefaultTracer().Start(ctx, "datasource.tempo.sendResponse")
|
||||
defer span.End()
|
||||
frame := createResponseDataFrame()
|
||||
|
||||
tracesAsJson, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tracesRawMessage := json.RawMessage(tracesAsJson)
|
||||
frame.Fields[0].Append(tracesRawMessage)
|
||||
|
||||
metricsAsJson, err := json.Marshal(metrics)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
metricsRawMessage := json.RawMessage(metricsAsJson)
|
||||
frame.Fields[1].Append(metricsRawMessage)
|
||||
frame.Fields[2].Append(string(state))
|
||||
frame.Fields[3].Append("")
|
||||
|
||||
return sender.SendFrame(frame, data.IncludeAll)
|
||||
}
|
||||
|
||||
func sendError(searchErr error, sender StreamSender) error {
|
||||
frame := createResponseDataFrame()
|
||||
|
||||
if searchErr != nil {
|
||||
frame.Fields[0].Append(json.RawMessage{})
|
||||
frame.Fields[1].Append(json.RawMessage{})
|
||||
frame.Fields[2].Append(string(dataquery.SearchStreamingStateError))
|
||||
frame.Fields[3].Append(searchErr.Error())
|
||||
}
|
||||
|
||||
return sender.SendFrame(frame, data.IncludeAll)
|
||||
}
|
||||
|
||||
func createResponseDataFrame() *data.Frame {
|
||||
frame := data.NewFrame("response")
|
||||
frame.Fields = append(frame.Fields, data.NewField("result", nil, []json.RawMessage{}))
|
||||
frame.Fields = append(frame.Fields, data.NewField("metrics", nil, []json.RawMessage{}))
|
||||
frame.Fields = append(frame.Fields, data.NewField("state", nil, []string{}))
|
||||
frame.Fields = append(frame.Fields, data.NewField("error", nil, []string{}))
|
||||
|
||||
return frame
|
||||
}
|
||||
|
||||
func removeDuplicates(traceList []*tempopb.TraceSearchMetadata) []*tempopb.TraceSearchMetadata {
|
||||
keys := make(map[string]bool)
|
||||
var list []*tempopb.TraceSearchMetadata
|
||||
|
||||
for _, entry := range traceList {
|
||||
if _, value := keys[entry.TraceID]; !value {
|
||||
keys[entry.TraceID] = true
|
||||
list = append(list, entry)
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
|
@ -1,231 +0,0 @@
|
|||
package tempo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/grafana/grafana/pkg/tsdb/tempo/kinds/dataquery"
|
||||
"github.com/grafana/tempo/pkg/tempopb"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
func TestProcessStream_ValidInput_ReturnsNoError(t *testing.T) {
|
||||
logger := backend.NewLoggerWith("logger", "tsdb.tempo.test")
|
||||
service := &Service{
|
||||
logger: logger,
|
||||
}
|
||||
searchClient := &mockStreamer{}
|
||||
streamSender := &mockSender{}
|
||||
err := service.processStream(context.Background(), searchClient, streamSender)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, but got %s", err)
|
||||
}
|
||||
}
|
||||
func TestProcessStream_InvalidInput_ReturnsError(t *testing.T) {
|
||||
logger := backend.NewLoggerWith("logger", "tsdb.tempo.test")
|
||||
service := &Service{
|
||||
logger: logger,
|
||||
}
|
||||
searchClient := &mockStreamer{err: errors.New("invalid input")}
|
||||
streamSender := &mockSender{}
|
||||
err := service.processStream(context.Background(), searchClient, streamSender)
|
||||
if err != nil {
|
||||
if !strings.Contains(err.Error(), "invalid input") {
|
||||
t.Errorf("Expected error message to contain 'invalid input', but got %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestProcessStream_ValidInput_ReturnsExpectedOutput(t *testing.T) {
|
||||
logger := backend.NewLoggerWith("logger", "tsdb.tempo.test")
|
||||
service := &Service{
|
||||
logger: logger,
|
||||
}
|
||||
searchClient := &mockStreamer{
|
||||
tracingMetadata: []*tempopb.TraceSearchMetadata{
|
||||
{TraceID: "abcdefg", StartTimeUnixNano: 1234},
|
||||
{TraceID: "hijklmn", StartTimeUnixNano: 5678},
|
||||
},
|
||||
metrics: &tempopb.SearchMetrics{
|
||||
CompletedJobs: 2,
|
||||
TotalJobs: 5,
|
||||
InspectedBytes: 123456789,
|
||||
TotalBlockBytes: 987654321,
|
||||
InspectedTraces: 123,
|
||||
},
|
||||
expectedResponses: []ExtendedResponse{
|
||||
{
|
||||
SearchResponse: &tempopb.SearchResponse{
|
||||
Traces: []*tempopb.TraceSearchMetadata{
|
||||
{TraceID: "abcdefg", StartTimeUnixNano: 1234},
|
||||
},
|
||||
Metrics: &tempopb.SearchMetrics{
|
||||
CompletedJobs: 2,
|
||||
TotalJobs: 5,
|
||||
InspectedBytes: 123456789,
|
||||
TotalBlockBytes: 987654321,
|
||||
InspectedTraces: 123,
|
||||
},
|
||||
},
|
||||
State: dataquery.SearchStreamingStateStreaming,
|
||||
},
|
||||
{
|
||||
SearchResponse: &tempopb.SearchResponse{
|
||||
Traces: []*tempopb.TraceSearchMetadata{
|
||||
{TraceID: "abcdefg", StartTimeUnixNano: 1234},
|
||||
{TraceID: "hijklmn", StartTimeUnixNano: 5678},
|
||||
},
|
||||
Metrics: &tempopb.SearchMetrics{
|
||||
CompletedJobs: 2,
|
||||
TotalJobs: 5,
|
||||
InspectedBytes: 123456789,
|
||||
TotalBlockBytes: 987654321,
|
||||
InspectedTraces: 123,
|
||||
},
|
||||
},
|
||||
State: dataquery.SearchStreamingStateStreaming,
|
||||
},
|
||||
|
||||
{
|
||||
SearchResponse: &tempopb.SearchResponse{
|
||||
Traces: []*tempopb.TraceSearchMetadata{
|
||||
{TraceID: "abcdefg", StartTimeUnixNano: 1234},
|
||||
{TraceID: "hijklmn", StartTimeUnixNano: 5678},
|
||||
},
|
||||
Metrics: &tempopb.SearchMetrics{
|
||||
CompletedJobs: 2,
|
||||
TotalJobs: 5,
|
||||
InspectedBytes: 123456789,
|
||||
TotalBlockBytes: 987654321,
|
||||
InspectedTraces: 123,
|
||||
},
|
||||
},
|
||||
State: dataquery.SearchStreamingStateDone,
|
||||
},
|
||||
},
|
||||
}
|
||||
streamSender := &mockSender{}
|
||||
err := service.processStream(context.Background(), searchClient, streamSender)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, but got %s", err)
|
||||
return
|
||||
}
|
||||
if len(streamSender.responses) != 3 {
|
||||
t.Errorf("Expected 3 responses, but got %d", len(streamSender.responses))
|
||||
return
|
||||
}
|
||||
|
||||
for i, frame := range streamSender.responses {
|
||||
expectedMetrics := searchClient.expectedResponses[i].Metrics
|
||||
expectedTraces := searchClient.expectedResponses[i].Traces
|
||||
expectedState := string(searchClient.expectedResponses[i].State)
|
||||
|
||||
if len(frame.Fields) != 4 {
|
||||
t.Errorf("Expected 4 fields in data frame, but was '%d'", len(frame.Fields))
|
||||
return
|
||||
}
|
||||
var traceList []*tempopb.TraceSearchMetadata
|
||||
if err := json.Unmarshal(frame.Fields[0].At(0).(json.RawMessage), &traceList); err != nil {
|
||||
t.Errorf("Error unmarshaling trace list: %s", err)
|
||||
} else {
|
||||
if !reflect.DeepEqual(traceList, expectedTraces) {
|
||||
t.Errorf("Expected response traces to be '%+v', but was '%+v'",
|
||||
expectedTraces, traceList)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var metrics *tempopb.SearchMetrics
|
||||
if err := json.Unmarshal(frame.Fields[1].At(0).(json.RawMessage), &metrics); err != nil {
|
||||
t.Errorf("Error unmarshaling metrics: %s", err)
|
||||
} else {
|
||||
if !reflect.DeepEqual(metrics, expectedMetrics) {
|
||||
t.Errorf("Expected response metrics to be '%+v', but was '%+v'",
|
||||
expectedMetrics, metrics)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
state := frame.Fields[2].At(0).(string)
|
||||
if state != expectedState {
|
||||
t.Errorf("Expected response state to be '%+v', but was '%+v'", expectedState,
|
||||
state)
|
||||
return
|
||||
}
|
||||
frameErr := frame.Fields[3].At(0).(string)
|
||||
if frameErr != "" {
|
||||
t.Errorf("Didn't expect error but got '%+v'", frameErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type mockSender struct {
|
||||
backend.StreamSender
|
||||
responses []*data.Frame
|
||||
}
|
||||
|
||||
func (s *mockSender) SendFrame(frame *data.Frame, include data.FrameInclude) error {
|
||||
s.responses = append(s.responses, frame)
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockStreamer struct {
|
||||
tracingMetadata []*tempopb.TraceSearchMetadata
|
||||
copyOfTracingMetadata []*tempopb.TraceSearchMetadata
|
||||
metrics *tempopb.SearchMetrics
|
||||
expectedResponses []ExtendedResponse
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockStreamer) Recv() (*tempopb.SearchResponse, error) {
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
}
|
||||
if m.copyOfTracingMetadata == nil {
|
||||
m.copyOfTracingMetadata = make([]*tempopb.TraceSearchMetadata, len(m.tracingMetadata))
|
||||
copy(m.copyOfTracingMetadata, m.tracingMetadata)
|
||||
}
|
||||
if len(m.copyOfTracingMetadata) == 0 {
|
||||
return &tempopb.SearchResponse{
|
||||
Metrics: m.metrics,
|
||||
Traces: m.tracingMetadata,
|
||||
}, io.EOF
|
||||
}
|
||||
traceMetadata := m.copyOfTracingMetadata[0]
|
||||
m.copyOfTracingMetadata = m.copyOfTracingMetadata[1:]
|
||||
return &tempopb.SearchResponse{
|
||||
Metrics: m.metrics,
|
||||
Traces: []*tempopb.TraceSearchMetadata{traceMetadata},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockStreamer) Header() (metadata.MD, error) {
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (m *mockStreamer) Trailer() metadata.MD {
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (m *mockStreamer) CloseSend() error {
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (m *mockStreamer) Context() context.Context {
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (m *mockStreamer) SendMsg(a any) error {
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (m *mockStreamer) RecvMsg(a any) error {
|
||||
panic("implement me")
|
||||
}
|
||||
|
|
@ -1,530 +0,0 @@
|
|||
package tempo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana/pkg/tsdb/tempo/kinds/dataquery"
|
||||
"github.com/grafana/tempo/pkg/tempopb"
|
||||
v1 "github.com/grafana/tempo/pkg/tempopb/common/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCreateSearchRequest(t *testing.T) {
|
||||
datasource := &DatasourceInfo{URL: "http://localhost:3200"}
|
||||
var qstring = "{service.name=\"svc\"}"
|
||||
var limit int64 = 10
|
||||
var spss int64 = 3
|
||||
|
||||
query := &dataquery.TempoQuery{Query: &qstring, Limit: &limit, Spss: &spss}
|
||||
req, err := createSearchRequest(context.Background(), datasource, query, 100, 200)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, `{service.name="svc"}`, req.URL.Query().Get("q"))
|
||||
assert.Equal(t, "10", req.URL.Query().Get("limit"))
|
||||
assert.Equal(t, "3", req.URL.Query().Get("spss"))
|
||||
assert.Equal(t, "100", req.URL.Query().Get("start"))
|
||||
assert.Equal(t, "200", req.URL.Query().Get("end"))
|
||||
}
|
||||
|
||||
func TestTransformTraceSearchResponse(t *testing.T) {
|
||||
pCtx := backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{UID: "u", Name: "n"}}
|
||||
resp := &tempopb.SearchResponse{Traces: []*tempopb.TraceSearchMetadata{{
|
||||
TraceID: "test-trace-id-x",
|
||||
RootServiceName: "test-service-name",
|
||||
RootTraceName: "test-root-trace-name",
|
||||
StartTimeUnixNano: 2000000,
|
||||
DurationMs: 5,
|
||||
SpanSet: &tempopb.SpanSet{Spans: []*tempopb.Span{{SpanID: "span1", Name: "op", StartTimeUnixNano: 1000000, DurationNanos: 1000}}},
|
||||
}, {
|
||||
TraceID: "test-trace-id-y",
|
||||
RootServiceName: "test-service-name",
|
||||
RootTraceName: "test-root-trace-name",
|
||||
StartTimeUnixNano: 1000000,
|
||||
DurationMs: 10,
|
||||
SpanSet: &tempopb.SpanSet{
|
||||
Spans: []*tempopb.Span{
|
||||
{
|
||||
SpanID: "span1",
|
||||
Name: "op",
|
||||
StartTimeUnixNano: 1000000,
|
||||
DurationNanos: 1000,
|
||||
Attributes: []*v1.KeyValue{{Key: "http.method", Value: &v1.AnyValue{Value: &v1.AnyValue_StringValue{StringValue: "GET"}}}},
|
||||
},
|
||||
{
|
||||
SpanID: "span2",
|
||||
Name: "op",
|
||||
StartTimeUnixNano: 1000000,
|
||||
DurationNanos: 1000,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}}
|
||||
|
||||
frames, err := transformTraceSearchResponse(pCtx, resp)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, frames, 1)
|
||||
|
||||
assert.Equal(t, 2, frames[0].Rows())
|
||||
assert.Equal(t, "test-trace-id-x", frames[0].Fields[0].At(0))
|
||||
assert.Equal(t, "test-trace-id-y", frames[0].Fields[0].At(1))
|
||||
assert.Equal(t, time.Unix(0, 2000000), frames[0].Fields[1].At(0))
|
||||
assert.Equal(t, "test-service-name", frames[0].Fields[2].At(0))
|
||||
assert.Equal(t, "test-root-trace-name", frames[0].Fields[3].At(0))
|
||||
assert.Equal(t, 5.0, *(frames[0].Fields[4].At(0).(*float64)))
|
||||
assert.NotEmpty(t, frames[0].Fields[5].At(0))
|
||||
}
|
||||
|
||||
func TestTransformSpanSearchResponse(t *testing.T) {
|
||||
pCtx := backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{UID: "u", Name: "n"}}
|
||||
resp := &tempopb.SearchResponse{Traces: []*tempopb.TraceSearchMetadata{{
|
||||
TraceID: "test-trace-id",
|
||||
RootServiceName: "test-service-name",
|
||||
RootTraceName: "test-root-trace-name",
|
||||
StartTimeUnixNano: 1000,
|
||||
SpanSets: []*tempopb.SpanSet{{
|
||||
Attributes: []*v1.KeyValue{{Key: "service.name", Value: &v1.AnyValue{Value: &v1.AnyValue_StringValue{StringValue: "test-service-name"}}}},
|
||||
Spans: []*tempopb.Span{
|
||||
{
|
||||
SpanID: "test-span-id",
|
||||
Name: "test-span-name",
|
||||
StartTimeUnixNano: 2000,
|
||||
DurationNanos: 3000,
|
||||
Attributes: []*v1.KeyValue{{Key: "http.method", Value: &v1.AnyValue{Value: &v1.AnyValue_StringValue{StringValue: "GET"}}}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
}}}
|
||||
|
||||
frames, err := transformSpanSearchResponse(pCtx, resp)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, frames, 1)
|
||||
|
||||
assert.Equal(t, 1, frames[0].Rows())
|
||||
assert.Equal(t, "test-trace-id", frames[0].Fields[0].At(0))
|
||||
assert.Equal(t, "test-service-name", frames[0].Fields[1].At(0))
|
||||
assert.Equal(t, "test-root-trace-name", frames[0].Fields[2].At(0))
|
||||
assert.Equal(t, "test-span-id", frames[0].Fields[3].At(0))
|
||||
assert.Equal(t, time.Unix(0, 2000), frames[0].Fields[4].At(0))
|
||||
assert.Equal(t, "test-span-name", frames[0].Fields[5].At(0))
|
||||
assert.Equal(t, "GET", *(frames[0].Fields[6].At(0).(*string)))
|
||||
assert.Equal(t, "test-service-name", *(frames[0].Fields[7].At(0).(*string)))
|
||||
assert.Equal(t, 3000.0, frames[0].Fields[8].At(0))
|
||||
}
|
||||
|
||||
func TestTransformRawSearchResponse(t *testing.T) {
|
||||
resp := &tempopb.SearchResponse{Traces: []*tempopb.TraceSearchMetadata{{
|
||||
TraceID: "test-trace-id-raw",
|
||||
}}}
|
||||
|
||||
frames, err := transformRawSearchResponse(resp)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, frames, 1)
|
||||
|
||||
require.Equal(t, 1, frames[0].Fields[0].Len())
|
||||
raw := frames[0].Fields[0].At(0).(string)
|
||||
expected := "{\n \"traces\": [\n {\n \"traceID\": \"test-trace-id-raw\"\n }\n ]\n}"
|
||||
assert.Equal(t, expected, raw)
|
||||
}
|
||||
|
||||
func TestTransformTraceSearchResponse_DurationBelowOneMsIsNil(t *testing.T) {
|
||||
pCtx := backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{UID: "u", Name: "n"}}
|
||||
resp := &tempopb.SearchResponse{Traces: []*tempopb.TraceSearchMetadata{{
|
||||
TraceID: "test-trace-id",
|
||||
RootServiceName: "test-service-name",
|
||||
RootTraceName: "test-root-trace-name",
|
||||
StartTimeUnixNano: 2000000,
|
||||
DurationMs: 0,
|
||||
SpanSet: &tempopb.SpanSet{Spans: []*tempopb.Span{{SpanID: "span1", Name: "op", StartTimeUnixNano: 1000000, DurationNanos: 1000}}},
|
||||
}}}
|
||||
|
||||
frames, err := transformTraceSearchResponse(pCtx, resp)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, frames, 1)
|
||||
require.Equal(t, 1, frames[0].Rows())
|
||||
assert.Nil(t, frames[0].Fields[4].At(0))
|
||||
}
|
||||
|
||||
func TestTransformTraceSearchResponse_UsesSpanSetsWhenSpanSetMissing(t *testing.T) {
|
||||
pCtx := backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{UID: "u", Name: "n"}}
|
||||
resp := &tempopb.SearchResponse{Traces: []*tempopb.TraceSearchMetadata{{
|
||||
TraceID: "test-trace-id",
|
||||
RootServiceName: "test-service-name",
|
||||
RootTraceName: "test-root-trace-name",
|
||||
StartTimeUnixNano: 1000000,
|
||||
DurationMs: 10,
|
||||
SpanSets: []*tempopb.SpanSet{
|
||||
{Spans: []*tempopb.Span{{SpanID: "span1", Name: "op1", StartTimeUnixNano: 1000000, DurationNanos: 1000}}},
|
||||
{Spans: []*tempopb.Span{{SpanID: "span2", Name: "op2", StartTimeUnixNano: 1001000, DurationNanos: 1000}}},
|
||||
},
|
||||
}}}
|
||||
|
||||
frames, err := transformTraceSearchResponse(pCtx, resp)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, frames, 1)
|
||||
|
||||
require.Equal(t, 1, frames[0].Rows())
|
||||
require.NotNil(t, frames[0].Fields[5].At(0))
|
||||
|
||||
var nestedFrames []json.RawMessage
|
||||
require.NoError(t, json.Unmarshal(frames[0].Fields[5].At(0).(json.RawMessage), &nestedFrames))
|
||||
assert.Len(t, nestedFrames, 2)
|
||||
}
|
||||
|
||||
func TestTransformTraceSearchResponse_SingularSpanSet(t *testing.T) {
|
||||
// A single (singular) trace.SpanSet must still drive the unified-schema path:
|
||||
// it produces exactly one nested frame whose dynamic-attribute columns carry
|
||||
// the span's values. The plural trace.SpanSets case is covered separately.
|
||||
pCtx := backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{UID: "u", Name: "n"}}
|
||||
resp := &tempopb.SearchResponse{Traces: []*tempopb.TraceSearchMetadata{{
|
||||
TraceID: "test-trace-id",
|
||||
RootServiceName: "test-service-name",
|
||||
RootTraceName: "test-root-trace-name",
|
||||
StartTimeUnixNano: 1000000,
|
||||
DurationMs: 10,
|
||||
SpanSet: &tempopb.SpanSet{
|
||||
Spans: []*tempopb.Span{{
|
||||
SpanID: "span1",
|
||||
Name: "op1",
|
||||
StartTimeUnixNano: 1000000,
|
||||
DurationNanos: 1000,
|
||||
Attributes: []*v1.KeyValue{
|
||||
{Key: "http.method", Value: &v1.AnyValue{Value: &v1.AnyValue_StringValue{StringValue: "GET"}}},
|
||||
},
|
||||
}},
|
||||
},
|
||||
}}}
|
||||
|
||||
frames, err := transformTraceSearchResponse(pCtx, resp)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, frames, 1)
|
||||
require.Equal(t, 1, frames[0].Rows())
|
||||
|
||||
var nestedFrames []json.RawMessage
|
||||
require.NoError(t, json.Unmarshal(frames[0].Fields[5].At(0).(json.RawMessage), &nestedFrames))
|
||||
require.Len(t, nestedFrames, 1)
|
||||
|
||||
type schemaField struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
type frameEnvelope struct {
|
||||
Schema struct {
|
||||
Fields []schemaField `json:"fields"`
|
||||
} `json:"schema"`
|
||||
Data struct {
|
||||
Values [][]any `json:"values"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
var env frameEnvelope
|
||||
require.NoError(t, json.Unmarshal(nestedFrames[0], &env))
|
||||
|
||||
names := make([]string, 0, len(env.Schema.Fields))
|
||||
for _, f := range env.Schema.Fields {
|
||||
names = append(names, f.Name)
|
||||
}
|
||||
require.Contains(t, names, "http.method")
|
||||
|
||||
methodIdx := -1
|
||||
for i, n := range names {
|
||||
if n == "http.method" {
|
||||
methodIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
require.GreaterOrEqual(t, methodIdx, 0)
|
||||
require.Len(t, env.Data.Values[methodIdx], 1)
|
||||
assert.Equal(t, "GET", env.Data.Values[methodIdx][0])
|
||||
}
|
||||
|
||||
func TestTransformTraceSearchResponseSubFrame_MissingDynamicAttributeUsesNil(t *testing.T) {
|
||||
pCtx := backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{UID: "u", Name: "n"}}
|
||||
trace := &tempopb.TraceSearchMetadata{
|
||||
TraceID: "test-trace-id",
|
||||
RootServiceName: "test-service-name",
|
||||
RootTraceName: "test-root-trace-name",
|
||||
StartTimeUnixNano: 1000000,
|
||||
DurationMs: 10,
|
||||
}
|
||||
spanSet := &tempopb.SpanSet{
|
||||
Spans: []*tempopb.Span{
|
||||
{
|
||||
SpanID: "span1",
|
||||
Name: "op1",
|
||||
StartTimeUnixNano: 1000000,
|
||||
DurationNanos: 1000,
|
||||
Attributes: []*v1.KeyValue{
|
||||
{
|
||||
Key: "http.method",
|
||||
Value: &v1.AnyValue{Value: &v1.AnyValue_StringValue{StringValue: "GET"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
SpanID: "span2",
|
||||
Name: "op2",
|
||||
StartTimeUnixNano: 1001000,
|
||||
DurationNanos: 1000,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
spanDynamicAttributes, spanAttributeNames, hasNameAttribute := collectSpanSetsSchema([]*tempopb.SpanSet{spanSet})
|
||||
frame := transformTraceSearchResponseSubFrame(trace, spanSet, pCtx, spanAttributeNames, spanDynamicAttributes, hasNameAttribute)
|
||||
require.NotNil(t, frame)
|
||||
require.Equal(t, 2, frame.Rows())
|
||||
require.GreaterOrEqual(t, len(frame.Fields), 6)
|
||||
assert.Equal(t, "http.method", frame.Fields[4].Name)
|
||||
assert.Equal(t, "GET", *(frame.Fields[4].At(0).(*string)))
|
||||
assert.True(t, frame.Fields[4].NilAt(1))
|
||||
}
|
||||
|
||||
func TestTransformSpanSearchResponse_MissingDynamicAttributeUsesNil(t *testing.T) {
|
||||
pCtx := backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{UID: "u", Name: "n"}}
|
||||
resp := &tempopb.SearchResponse{Traces: []*tempopb.TraceSearchMetadata{{
|
||||
TraceID: "test-trace-id",
|
||||
RootServiceName: "test-service-name",
|
||||
RootTraceName: "test-root-trace-name",
|
||||
StartTimeUnixNano: 1000,
|
||||
SpanSets: []*tempopb.SpanSet{{
|
||||
Spans: []*tempopb.Span{
|
||||
{
|
||||
SpanID: "test-span-id-1",
|
||||
Name: "test-span-name-1",
|
||||
StartTimeUnixNano: 2000,
|
||||
DurationNanos: 3000,
|
||||
Attributes: []*v1.KeyValue{
|
||||
{
|
||||
Key: "http.method",
|
||||
Value: &v1.AnyValue{Value: &v1.AnyValue_StringValue{StringValue: "GET"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
SpanID: "test-span-id-2",
|
||||
Name: "test-span-name-2",
|
||||
StartTimeUnixNano: 3000,
|
||||
DurationNanos: 4000,
|
||||
},
|
||||
},
|
||||
}},
|
||||
}}}
|
||||
|
||||
frames, err := transformSpanSearchResponse(pCtx, resp)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, frames, 1)
|
||||
require.Equal(t, 2, frames[0].Rows())
|
||||
|
||||
assert.Equal(t, "GET", *(frames[0].Fields[6].At(0).(*string)))
|
||||
assert.True(t, frames[0].Fields[6].NilAt(1))
|
||||
}
|
||||
|
||||
func TestTransformTraceSearchResponseSubFrame_SameNumericKeyIntAndDouble(t *testing.T) {
|
||||
pCtx := backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{UID: "u", Name: "n"}}
|
||||
trace := &tempopb.TraceSearchMetadata{
|
||||
TraceID: "t1",
|
||||
RootServiceName: "svc",
|
||||
RootTraceName: "op",
|
||||
StartTimeUnixNano: 1000000,
|
||||
DurationMs: 10,
|
||||
}
|
||||
// Last attribute seen for "count" during schema scan is int; first span row has double — must not panic on Append.
|
||||
spanSet := &tempopb.SpanSet{
|
||||
Spans: []*tempopb.Span{
|
||||
{
|
||||
SpanID: "s1",
|
||||
Name: "a",
|
||||
StartTimeUnixNano: 1000000,
|
||||
DurationNanos: 1000,
|
||||
Attributes: []*v1.KeyValue{
|
||||
{Key: "count", Value: &v1.AnyValue{Value: &v1.AnyValue_DoubleValue{DoubleValue: 1.5}}},
|
||||
},
|
||||
},
|
||||
{
|
||||
SpanID: "s2",
|
||||
Name: "b",
|
||||
StartTimeUnixNano: 1001000,
|
||||
DurationNanos: 1000,
|
||||
Attributes: []*v1.KeyValue{
|
||||
{Key: "count", Value: &v1.AnyValue{Value: &v1.AnyValue_IntValue{IntValue: 2}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
spanDynamicAttributes, spanAttributeNames, hasNameAttribute := collectSpanSetsSchema([]*tempopb.SpanSet{spanSet})
|
||||
frame := transformTraceSearchResponseSubFrame(trace, spanSet, pCtx, spanAttributeNames, spanDynamicAttributes, hasNameAttribute)
|
||||
require.NotNil(t, frame)
|
||||
require.Equal(t, 2, frame.Rows())
|
||||
idx := -1
|
||||
for i, f := range frame.Fields {
|
||||
if f.Name == "count" {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
require.GreaterOrEqual(t, idx, 0, "expected dynamic field count")
|
||||
assert.Equal(t, 1.5, *(frame.Fields[idx].At(0).(*float64)))
|
||||
assert.Equal(t, 2.0, *(frame.Fields[idx].At(1).(*float64)))
|
||||
}
|
||||
|
||||
func TestTransformTraceSearchResponse_NestedFramesShareUnifiedSchema(t *testing.T) {
|
||||
// Regression for grafana/grafana#121740: when a trace has multiple SpanSets
|
||||
// whose dynamic attributes differ, every nested frame stored under the same
|
||||
// nestedFrames cell must declare the same fields in the same order. The Table
|
||||
// visualization (and other consumers) assumes a single schema per nestedFrames
|
||||
// cell and drops cells that don't match.
|
||||
pCtx := backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{UID: "u", Name: "n"}}
|
||||
resp := &tempopb.SearchResponse{Traces: []*tempopb.TraceSearchMetadata{{
|
||||
TraceID: "test-trace-id",
|
||||
RootServiceName: "test-service-name",
|
||||
RootTraceName: "test-root-trace-name",
|
||||
StartTimeUnixNano: 1000000,
|
||||
DurationMs: 10,
|
||||
SpanSets: []*tempopb.SpanSet{
|
||||
{
|
||||
Spans: []*tempopb.Span{{
|
||||
SpanID: "span1",
|
||||
Name: "op1",
|
||||
StartTimeUnixNano: 1000000,
|
||||
DurationNanos: 1000,
|
||||
Attributes: []*v1.KeyValue{
|
||||
{Key: "http.method", Value: &v1.AnyValue{Value: &v1.AnyValue_StringValue{StringValue: "GET"}}},
|
||||
},
|
||||
}},
|
||||
},
|
||||
{
|
||||
Spans: []*tempopb.Span{{
|
||||
SpanID: "span2",
|
||||
Name: "op2",
|
||||
StartTimeUnixNano: 1001000,
|
||||
DurationNanos: 1000,
|
||||
Attributes: []*v1.KeyValue{
|
||||
{Key: "db.system", Value: &v1.AnyValue{Value: &v1.AnyValue_StringValue{StringValue: "postgres"}}},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}}}
|
||||
|
||||
frames, err := transformTraceSearchResponse(pCtx, resp)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, frames, 1)
|
||||
require.Equal(t, 1, frames[0].Rows())
|
||||
|
||||
var nestedFrames []json.RawMessage
|
||||
require.NoError(t, json.Unmarshal(frames[0].Fields[5].At(0).(json.RawMessage), &nestedFrames))
|
||||
require.Len(t, nestedFrames, 2)
|
||||
|
||||
// Decode each nested frame just enough to inspect its schema and the first-row
|
||||
// value of every column. data.values is column-major: values[col][row].
|
||||
type schemaField struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
type frameSchema struct {
|
||||
Fields []schemaField `json:"fields"`
|
||||
}
|
||||
type frameData struct {
|
||||
Values [][]any `json:"values"`
|
||||
}
|
||||
type frameEnvelope struct {
|
||||
Schema frameSchema `json:"schema"`
|
||||
Data frameData `json:"data"`
|
||||
}
|
||||
|
||||
decode := func(raw json.RawMessage) frameEnvelope {
|
||||
var env frameEnvelope
|
||||
require.NoError(t, json.Unmarshal(raw, &env))
|
||||
return env
|
||||
}
|
||||
schemaNames := func(env frameEnvelope) []string {
|
||||
names := make([]string, 0, len(env.Schema.Fields))
|
||||
for _, f := range env.Schema.Fields {
|
||||
names = append(names, f.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
indexOf := func(names []string, want string) int {
|
||||
for i, n := range names {
|
||||
if n == want {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
firstEnv := decode(nestedFrames[0])
|
||||
secondEnv := decode(nestedFrames[1])
|
||||
firstNames := schemaNames(firstEnv)
|
||||
secondNames := schemaNames(secondEnv)
|
||||
|
||||
assert.Equal(t, firstNames, secondNames, "nested frames under one nestedFrames cell must share a schema")
|
||||
assert.Contains(t, firstNames, "http.method")
|
||||
assert.Contains(t, firstNames, "db.system")
|
||||
|
||||
// Each subframe owns one of the two attributes; the other column must exist but
|
||||
// be nil for the row so consumers don't misalign columns across subframes.
|
||||
methodIdx := indexOf(firstNames, "http.method")
|
||||
dbIdx := indexOf(firstNames, "db.system")
|
||||
require.GreaterOrEqual(t, methodIdx, 0)
|
||||
require.GreaterOrEqual(t, dbIdx, 0)
|
||||
|
||||
require.Len(t, firstEnv.Data.Values[methodIdx], 1)
|
||||
require.Len(t, firstEnv.Data.Values[dbIdx], 1)
|
||||
assert.Equal(t, "GET", firstEnv.Data.Values[methodIdx][0])
|
||||
assert.Nil(t, firstEnv.Data.Values[dbIdx][0])
|
||||
|
||||
require.Len(t, secondEnv.Data.Values[methodIdx], 1)
|
||||
require.Len(t, secondEnv.Data.Values[dbIdx], 1)
|
||||
assert.Nil(t, secondEnv.Data.Values[methodIdx][0])
|
||||
assert.Equal(t, "postgres", secondEnv.Data.Values[dbIdx][0])
|
||||
}
|
||||
|
||||
func TestTransformSpanSearchResponse_NoSpanAttributes(t *testing.T) {
|
||||
pCtx := backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{UID: "u", Name: "n"}}
|
||||
resp := &tempopb.SearchResponse{Traces: []*tempopb.TraceSearchMetadata{{
|
||||
TraceID: "test-trace-id",
|
||||
RootServiceName: "test-service-name",
|
||||
RootTraceName: "test-root-trace-name",
|
||||
StartTimeUnixNano: 1000,
|
||||
SpanSets: []*tempopb.SpanSet{{
|
||||
Spans: []*tempopb.Span{
|
||||
{
|
||||
SpanID: "test-span-id-1",
|
||||
Name: "test-span-name-1",
|
||||
StartTimeUnixNano: 2000,
|
||||
DurationNanos: 3000,
|
||||
},
|
||||
{
|
||||
SpanID: "test-span-id-2",
|
||||
Name: "test-span-name-2",
|
||||
StartTimeUnixNano: 3000,
|
||||
DurationNanos: 4000,
|
||||
},
|
||||
},
|
||||
}},
|
||||
}}}
|
||||
|
||||
frames, err := transformSpanSearchResponse(pCtx, resp)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, frames, 1)
|
||||
require.Equal(t, 2, frames[0].Rows())
|
||||
require.Len(t, frames[0].Fields, 7)
|
||||
|
||||
assert.Equal(t, "traceIdHidden", frames[0].Fields[0].Name)
|
||||
assert.Equal(t, "traceService", frames[0].Fields[1].Name)
|
||||
assert.Equal(t, "traceName", frames[0].Fields[2].Name)
|
||||
assert.Equal(t, "spanID", frames[0].Fields[3].Name)
|
||||
assert.Equal(t, "time", frames[0].Fields[4].Name)
|
||||
assert.Equal(t, "name", frames[0].Fields[5].Name)
|
||||
assert.Equal(t, "duration", frames[0].Fields[6].Name)
|
||||
|
||||
assert.Equal(t, "test-span-id-1", frames[0].Fields[3].At(0))
|
||||
assert.Equal(t, "test-span-id-2", frames[0].Fields[3].At(1))
|
||||
assert.Equal(t, 3000.0, frames[0].Fields[6].At(0))
|
||||
assert.Equal(t, 4000.0, frames[0].Fields[6].At(1))
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.opentelemetry.io/otel/trace/noop"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt"
|
||||
|
||||
tempo "github.com/grafana/grafana/pkg/tsdb/tempo"
|
||||
)
|
||||
|
||||
var (
|
||||
_ backend.CheckHealthHandler = (*Datasource)(nil)
|
||||
_ backend.QueryDataHandler = (*Datasource)(nil)
|
||||
_ backend.StreamHandler = (*Datasource)(nil)
|
||||
_ backend.CallResourceHandler = (*Datasource)(nil)
|
||||
)
|
||||
|
||||
type Datasource struct {
|
||||
Service *tempo.Service
|
||||
}
|
||||
|
||||
func NewDatasource(c context.Context, b backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
|
||||
return &Datasource{
|
||||
Service: tempo.ProvideService(httpclient.NewProvider(), noop.NewTracerProvider().Tracer("tempo")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *Datasource) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
|
||||
return d.Service.CheckHealth(ctx, req)
|
||||
}
|
||||
|
||||
func (d *Datasource) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
|
||||
return d.Service.QueryData(ctx, req)
|
||||
}
|
||||
|
||||
func (d *Datasource) SubscribeStream(ctx context.Context, req *backend.SubscribeStreamRequest) (*backend.SubscribeStreamResponse, error) {
|
||||
return d.Service.SubscribeStream(ctx, req)
|
||||
}
|
||||
|
||||
func (d *Datasource) PublishStream(ctx context.Context, req *backend.PublishStreamRequest) (*backend.PublishStreamResponse, error) {
|
||||
return d.Service.PublishStream(ctx, req)
|
||||
}
|
||||
|
||||
func (d *Datasource) RunStream(ctx context.Context, req *backend.RunStreamRequest, sender *backend.StreamSender) error {
|
||||
return d.Service.RunStream(ctx, req, sender)
|
||||
}
|
||||
|
||||
func (d *Datasource) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
|
||||
return d.Service.CallResource(ctx, req, sender)
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/datasource"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := datasource.Manage("tempo", NewDatasource, datasource.ManageOpts{}); err != nil {
|
||||
log.DefaultLogger.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
package tempo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
stream_utils "github.com/grafana/grafana/pkg/tsdb/tempo/utils"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
func (s *Service) SubscribeStream(_ context.Context, req *backend.SubscribeStreamRequest) (*backend.SubscribeStreamResponse, error) {
|
||||
s.logger.Debug("Allowing access to stream", "path", req.Path, "user", req.PluginContext.User)
|
||||
|
||||
if strings.HasPrefix(req.Path, SearchPathPrefix) {
|
||||
return &backend.SubscribeStreamResponse{
|
||||
Status: backend.SubscribeStreamStatusOK,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(req.Path, MetricsPathPrefix) {
|
||||
return &backend.SubscribeStreamResponse{
|
||||
Status: backend.SubscribeStreamStatusOK,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &backend.SubscribeStreamResponse{
|
||||
Status: backend.SubscribeStreamStatusPermissionDenied,
|
||||
}, backend.DownstreamErrorf("stream path not supported: %s", req.Path)
|
||||
}
|
||||
|
||||
func (s *Service) PublishStream(_ context.Context, _ *backend.PublishStreamRequest) (*backend.PublishStreamResponse, error) {
|
||||
s.logger.Debug("PublishStream called")
|
||||
|
||||
// Do not allow publishing at all.
|
||||
return &backend.PublishStreamResponse{
|
||||
Status: backend.PublishStreamStatusPermissionDenied,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) RunStream(ctx context.Context, request *backend.RunStreamRequest, sender *backend.StreamSender) error {
|
||||
s.logger.Debug("New stream call", "path", request.Path)
|
||||
tempoDatasource, dsInfoErr := s.getDSInfo(ctx, request.PluginContext)
|
||||
if dsInfoErr != nil {
|
||||
return backend.DownstreamErrorf("failed to get datasource information: %w", dsInfoErr)
|
||||
}
|
||||
|
||||
// get incoming and team http headers and append to stream request.
|
||||
headers, err := stream_utils.GetHeadersFromIncomingContext(ctx, s.logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Headers = headers
|
||||
|
||||
// add them to the outgoing context.
|
||||
// this is mainly needed for the api server as in that case, the outgoing context is empty and it is the incoming context that contains the metadata (if any)
|
||||
for key, value := range headers {
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, key, value)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(request.Path, SearchPathPrefix) {
|
||||
if err = s.runSearchStream(ctx, request, sender, tempoDatasource); err != nil {
|
||||
return sendError(err, sender)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(request.Path, MetricsPathPrefix) {
|
||||
if err = s.runMetricsStream(ctx, request, sender, tempoDatasource); err != nil {
|
||||
return sendError(err, sender)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("unknown path %s", request.Path)
|
||||
}
|
||||
|
|
@ -1,407 +0,0 @@
|
|||
package tempo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/datasource"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter"
|
||||
"github.com/grafana/grafana/pkg/tsdb/tempo/kinds/dataquery"
|
||||
"github.com/grafana/tempo/pkg/tempopb"
|
||||
)
|
||||
|
||||
var (
|
||||
_ backend.QueryDataHandler = (*Service)(nil)
|
||||
_ backend.CallResourceHandler = (*Service)(nil)
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
im instancemgmt.InstanceManager
|
||||
logger log.Logger
|
||||
tracer trace.Tracer
|
||||
resourceHandler backend.CallResourceHandler
|
||||
}
|
||||
|
||||
type DatasourceInfo struct {
|
||||
HTTPClient *http.Client
|
||||
StreamingClient tempopb.StreamingQuerierClient
|
||||
URL string
|
||||
}
|
||||
|
||||
func ProvideService(httpClientProvider *httpclient.Provider, tracer trace.Tracer) *Service {
|
||||
s := &Service{
|
||||
im: datasource.NewInstanceManager(newInstanceSettings(httpClientProvider)),
|
||||
logger: backend.NewLoggerWith("logger", "tsdb.tempo"),
|
||||
tracer: tracer,
|
||||
}
|
||||
|
||||
// Set up resource routes using httpadapter
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/tags", s.handleTags)
|
||||
mux.HandleFunc("/tag-values", s.handleTagValues)
|
||||
s.resourceHandler = httpadapter.New(mux)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func newInstanceSettings(httpClientProvider *httpclient.Provider) datasource.InstanceFactoryFunc {
|
||||
return func(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
|
||||
ctxLogger := backend.NewLoggerWith("logger", "tsdb.tempo").FromContext(ctx)
|
||||
opts, err := settings.HTTPClientOptions(ctx)
|
||||
if err != nil {
|
||||
ctxLogger.Error("Failed to get HTTP client options", "error", err, "function", logEntrypoint())
|
||||
return nil, backend.DownstreamErrorf("error reading settings: %w", err)
|
||||
}
|
||||
|
||||
opts.ForwardHTTPHeaders = true
|
||||
|
||||
client, err := httpClientProvider.New(opts)
|
||||
if err != nil {
|
||||
ctxLogger.Error("Failed to get HTTP client provider", "error", err, "function", logEntrypoint())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
streamingClient, err := newGrpcClient(ctx, settings, opts)
|
||||
if err != nil {
|
||||
ctxLogger.Error("Failed to get gRPC client", "error", err, "function", logEntrypoint())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
model := &DatasourceInfo{
|
||||
HTTPClient: client,
|
||||
StreamingClient: streamingClient,
|
||||
URL: settings.URL,
|
||||
}
|
||||
return model, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
|
||||
ctxLogger := s.logger.FromContext(ctx)
|
||||
ctxLogger.Debug("Processing queries", "queryLength", len(req.Queries), "function", logEntrypoint())
|
||||
|
||||
// create response struct
|
||||
response := backend.NewQueryDataResponse()
|
||||
|
||||
// loop over queries and execute them individually.
|
||||
for i, q := range req.Queries {
|
||||
ctxLogger.Debug("Processing query", "counter", i, "function", logEntrypoint())
|
||||
|
||||
var res *backend.DataResponse
|
||||
var err error
|
||||
|
||||
switch q.QueryType {
|
||||
case string(dataquery.TempoQueryTypeTraceId):
|
||||
res, err = s.getTrace(ctx, req.PluginContext, q)
|
||||
if err != nil {
|
||||
ctxLogger.Error("Error processing TraceId query", "error", err)
|
||||
response.Responses[q.RefID] = backend.ErrorResponseWithErrorSource(err)
|
||||
continue
|
||||
}
|
||||
|
||||
case string(dataquery.TempoQueryTypeTraceqlSearch):
|
||||
fallthrough
|
||||
case string(dataquery.TempoQueryTypeTraceql):
|
||||
res, err = s.runTraceQlQuery(ctx, req.PluginContext, q)
|
||||
if err != nil {
|
||||
ctxLogger.Error("Error processing TraceQL query", "error", err)
|
||||
response.Responses[q.RefID] = backend.ErrorResponseWithErrorSource(err)
|
||||
continue
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, backend.DownstreamErrorf("unsupported query type: '%s' for query with refID '%s'", q.QueryType, q.RefID)
|
||||
}
|
||||
|
||||
if res != nil {
|
||||
ctxLogger.Debug("Query processed", "counter", i, "function", logEntrypoint())
|
||||
response.Responses[q.RefID] = *res
|
||||
} else {
|
||||
ctxLogger.Debug("Query resulted in empty response", "counter", i, "function", logEntrypoint())
|
||||
}
|
||||
}
|
||||
|
||||
ctxLogger.Debug("All queries processed", "function", logEntrypoint())
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *Service) getDSInfo(ctx context.Context, pluginCtx backend.PluginContext) (*DatasourceInfo, error) {
|
||||
i, err := s.im.Get(ctx, pluginCtx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
instance, ok := i.(*DatasourceInfo)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("failed to cast datsource info")
|
||||
}
|
||||
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
func (s *Service) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
|
||||
return s.resourceHandler.CallResource(ctx, req, sender)
|
||||
}
|
||||
|
||||
func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
|
||||
var streamingEnabled bool
|
||||
var jsonData map[string]interface{}
|
||||
|
||||
pluginCtx := backend.PluginConfigFromContext(ctx)
|
||||
dsInfo, err := s.getDSInfo(ctx, pluginCtx)
|
||||
if err != nil {
|
||||
return &backend.CheckHealthResult{
|
||||
Status: backend.HealthStatusError,
|
||||
Message: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
if pluginCtx.DataSourceInstanceSettings != nil && pluginCtx.DataSourceInstanceSettings.JSONData != nil {
|
||||
if err := json.Unmarshal(pluginCtx.DataSourceInstanceSettings.JSONData, &jsonData); err == nil {
|
||||
if streaming, ok := jsonData["streamingEnabled"].(map[string]interface{}); ok {
|
||||
if searchEnabled, ok := streaming["search"].(bool); ok && searchEnabled {
|
||||
streamingEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if streamingEnabled {
|
||||
if dsInfo.StreamingClient == nil {
|
||||
return &backend.CheckHealthResult{
|
||||
Status: backend.HealthStatusError,
|
||||
Message: "Streaming client is not available",
|
||||
}, nil
|
||||
}
|
||||
|
||||
currentTime := time.Now()
|
||||
queryStartTime := currentTime.Add(-15 * time.Minute)
|
||||
searchRequest := &tempopb.SearchRequest{
|
||||
Query: "{}",
|
||||
Start: uint32(queryStartTime.Unix()),
|
||||
End: uint32(currentTime.Unix()),
|
||||
Limit: 1,
|
||||
}
|
||||
|
||||
streamingConnection, err := dsInfo.StreamingClient.Search(ctx, searchRequest)
|
||||
if err != nil {
|
||||
return &backend.CheckHealthResult{
|
||||
Status: backend.HealthStatusError,
|
||||
Message: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
_, err = streamingConnection.Recv()
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return &backend.CheckHealthResult{
|
||||
Status: backend.HealthStatusError,
|
||||
Message: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &backend.CheckHealthResult{
|
||||
Status: backend.HealthStatusOk,
|
||||
Message: "Data source is working. Streaming test succeeded.",
|
||||
}, nil
|
||||
}
|
||||
|
||||
parsedURL, err := url.Parse(dsInfo.URL)
|
||||
if err != nil {
|
||||
return &backend.CheckHealthResult{
|
||||
Status: backend.HealthStatusError,
|
||||
Message: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
parsedURL.Path = path.Join(parsedURL.Path, "api/echo")
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "GET", parsedURL.String(), nil)
|
||||
if err != nil {
|
||||
return &backend.CheckHealthResult{
|
||||
Status: backend.HealthStatusError,
|
||||
Message: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
resp, err := dsInfo.HTTPClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return &backend.CheckHealthResult{
|
||||
Status: backend.HealthStatusError,
|
||||
Message: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
s.logger.Warn("Failed to close response body", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return &backend.CheckHealthResult{
|
||||
Status: backend.HealthStatusError,
|
||||
Message: fmt.Sprintf("Tempo echo endpoint returned status %d", resp.StatusCode),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &backend.CheckHealthResult{
|
||||
Status: backend.HealthStatusOk,
|
||||
Message: "Data source is working",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// handleTags handles requests to /tags resource
|
||||
func (s *Service) handleTags(rw http.ResponseWriter, req *http.Request) {
|
||||
s.proxyToTempo(rw, req, "api/v2/search/tags")
|
||||
}
|
||||
|
||||
// handleTagValues handles requests to /tag-values resource
|
||||
func (s *Service) handleTagValues(rw http.ResponseWriter, req *http.Request) {
|
||||
// Extract the encoded tag from query parameters
|
||||
encodedTag := req.URL.Query().Get("tag")
|
||||
if encodedTag == "" {
|
||||
http.Error(rw, "Missing required 'tag' parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// escape tag
|
||||
tag, err := url.PathUnescape(encodedTag)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to unescape", "error", err, "tag", encodedTag)
|
||||
http.Error(rw, "Invalid 'tag' parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
for _, segment := range strings.Split(tag, "/") {
|
||||
if segment == "." || segment == ".." {
|
||||
s.logger.Error("Invalid tag parameter", "tag", tag)
|
||||
http.Error(rw, "Invalid 'tag' parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
tempoPath := fmt.Sprintf("api/v2/search/tag/%s/values", tag)
|
||||
s.proxyToTempo(rw, req, tempoPath)
|
||||
}
|
||||
|
||||
// proxyToTempo is the shared function that builds the URL and proxies requests to Tempo
|
||||
func (s *Service) proxyToTempo(rw http.ResponseWriter, req *http.Request, tempoPath string) {
|
||||
ctx := req.Context()
|
||||
pCtx := backend.PluginConfigFromContext(ctx)
|
||||
|
||||
// Get datasource info
|
||||
dsInfo, err := s.getDSInfo(ctx, pCtx)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get data source info", "error", err)
|
||||
http.Error(rw, "Failed to get data source configuration", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, span := s.tracer.Start(ctx, "datasource.tempo.proxyToTempo", trace.WithAttributes(
|
||||
attribute.String("tempoPath", tempoPath),
|
||||
))
|
||||
defer span.End()
|
||||
|
||||
// Build the full URL to Tempo
|
||||
parsedURL, err := url.Parse(dsInfo.URL)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
s.logger.Error("Failed to parse data source URL", "error", err, "url", dsInfo.URL)
|
||||
http.Error(rw, "Invalid data source URL", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Join the tempo path with the base URL
|
||||
parsedURL.Path = path.Join(parsedURL.Path, tempoPath)
|
||||
// Preserve query parameters from the original request
|
||||
parsedURL.RawQuery = req.URL.RawQuery
|
||||
|
||||
s.logger.Debug("Making resource request to Tempo", "url", parsedURL.String())
|
||||
start := time.Now()
|
||||
|
||||
// Create the request to Tempo
|
||||
httpReq, err := http.NewRequestWithContext(ctx, req.Method, parsedURL.String(), req.Body)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
s.logger.Error("Failed to create HTTP request", "error", err)
|
||||
http.Error(rw, "Failed to create request", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Copy headers from the original request
|
||||
for name, values := range req.Header {
|
||||
for _, value := range values {
|
||||
httpReq.Header.Add(name, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Make the request to Tempo
|
||||
resp, err := dsInfo.HTTPClient.Do(httpReq) // #nosec G704 -- datasource client targets operator-configured URL
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
s.logger.Error("Failed resource call to Tempo", "error", err, "url", parsedURL.String(), "duration", time.Since(start))
|
||||
http.Error(rw, "Failed to connect to Tempo", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
s.logger.Warn("Failed to close response body", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
s.logger.Debug("Response received from Tempo", "statusCode", resp.StatusCode, "contentLength", resp.Header.Get("Content-Length"), "duration", time.Since(start))
|
||||
|
||||
// Copy response headers
|
||||
for name, values := range resp.Header {
|
||||
for _, value := range values {
|
||||
rw.Header().Add(name, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Set the status code
|
||||
rw.WriteHeader(resp.StatusCode)
|
||||
|
||||
// Copy the response body
|
||||
_, err = io.Copy(rw, resp.Body)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
s.logger.Error("Failed to copy response body", "error", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Return the file, line, and (full-path) function name of the caller
|
||||
func getRunContext() (string, int, string) {
|
||||
pc := make([]uintptr, 10)
|
||||
runtime.Callers(2, pc)
|
||||
f := runtime.FuncForPC(pc[0])
|
||||
file, line := f.FileLine(pc[0])
|
||||
return file, line, f.Name()
|
||||
}
|
||||
|
||||
func logEntrypoint() string {
|
||||
file, line, pathToFunction := getRunContext()
|
||||
parts := strings.Split(pathToFunction, "/")
|
||||
functionName := parts[len(parts)-1]
|
||||
return fmt.Sprintf("%s:%d[%s]", file, line, functionName)
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
package tempo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/datasource"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCheckHealth(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
httpStatusCode int
|
||||
expectedStatus backend.HealthStatus
|
||||
expectedMessage string
|
||||
}{
|
||||
{
|
||||
name: "successful health check",
|
||||
httpStatusCode: 200,
|
||||
expectedStatus: backend.HealthStatusOk,
|
||||
expectedMessage: "Data source is working",
|
||||
},
|
||||
{
|
||||
name: "http error",
|
||||
httpStatusCode: 500,
|
||||
expectedStatus: backend.HealthStatusError,
|
||||
expectedMessage: "Tempo echo endpoint returned status 500",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(tt.httpStatusCode)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
pluginCtx := backend.PluginContext{
|
||||
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{
|
||||
URL: server.URL,
|
||||
},
|
||||
}
|
||||
|
||||
im := datasource.NewInstanceManager(func(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
|
||||
dsInfo := &DatasourceInfo{
|
||||
URL: server.URL,
|
||||
HTTPClient: server.Client(),
|
||||
StreamingClient: nil,
|
||||
}
|
||||
return dsInfo, nil
|
||||
})
|
||||
|
||||
service := &Service{im: im}
|
||||
ctx := backend.WithPluginContext(context.Background(), pluginCtx)
|
||||
result, err := service.CheckHealth(ctx, &backend.CheckHealthRequest{})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.expectedStatus, result.Status)
|
||||
assert.Contains(t, result.Message, tt.expectedMessage)
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,229 +0,0 @@
|
|||
package tempo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"github.com/gogo/protobuf/proto"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/tracing"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/grafana/grafana/pkg/tsdb/tempo/kinds/dataquery"
|
||||
"github.com/grafana/tempo/pkg/tempopb"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
var traceIDPattern = regexp.MustCompile(`^[0-9A-Fa-f]+$`)
|
||||
|
||||
func (s *Service) getTrace(ctx context.Context, pCtx backend.PluginContext, query backend.DataQuery) (*backend.DataResponse, error) {
|
||||
ctxLogger := s.logger.FromContext(ctx)
|
||||
ctxLogger.Debug("Getting trace", "function", logEntrypoint())
|
||||
|
||||
result := &backend.DataResponse{}
|
||||
refID := query.RefID
|
||||
|
||||
ctx, span := tracing.DefaultTracer().Start(ctx, "datasource.tempo.getTrace", trace.WithAttributes(
|
||||
attribute.String("queryType", query.QueryType),
|
||||
))
|
||||
defer span.End()
|
||||
|
||||
model := &dataquery.TempoQuery{}
|
||||
err := json.Unmarshal(query.JSON, model)
|
||||
if err != nil {
|
||||
ctxLogger.Error("Failed to unmarshall Tempo query model", "error", err, "function", logEntrypoint())
|
||||
return result, backend.DownstreamErrorf("failed to unmarshall Tempo query model: %w", err)
|
||||
}
|
||||
|
||||
dsInfo, err := s.getDSInfo(ctx, pCtx)
|
||||
if err != nil {
|
||||
ctxLogger.Error("Failed to get datasource information", "error", err, "function", logEntrypoint())
|
||||
return nil, backend.DownstreamErrorf("failed to get datasource information: %w", err)
|
||||
}
|
||||
|
||||
if model.Query == nil || *model.Query == "" {
|
||||
err := fmt.Errorf("trace id is required")
|
||||
ctxLogger.Error("Failed to validate model query", "error", err, "function", logEntrypoint())
|
||||
return result, backend.DownstreamErrorf("failed to validate model query: %w", err)
|
||||
}
|
||||
|
||||
var apiVersion = TraceRequestApiVersionV2
|
||||
//nolint:bodyclose
|
||||
resp, traceBody, err := s.performTraceRequest(ctx, dsInfo, apiVersion, model, query, span)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
// If the endpoint is not found, try the v1 endpoint, we might be communicating with an older Tempo version
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
apiVersion = TraceRequestApiVersionV1
|
||||
//nolint:bodyclose
|
||||
resp, traceBody, err = s.performTraceRequest(ctx, dsInfo, apiVersion, model, query, span)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
ctxLogger.Error("Failed to get trace", "error", err, "function", logEntrypoint())
|
||||
err := fmt.Errorf("failed to get trace with id: %s Status: %s Body: %s", *model.Query, resp.Status, string(traceBody))
|
||||
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
|
||||
if backend.ErrorSourceFromHTTPStatus(resp.StatusCode) == backend.ErrorSourceDownstream {
|
||||
return nil, backend.DownstreamError(err)
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var frame *data.Frame
|
||||
|
||||
if apiVersion == TraceRequestApiVersionV1 {
|
||||
var otTrace tempopb.Trace
|
||||
err = proto.Unmarshal(traceBody, &otTrace)
|
||||
|
||||
if err != nil {
|
||||
ctxLogger.Error("Failed to convert tempo response to Otlp", "error", err, "function", logEntrypoint())
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return &backend.DataResponse{}, fmt.Errorf("failed to convert tempo response to Otlp: %w", err)
|
||||
}
|
||||
|
||||
frame, err = TraceToFrame(otTrace.GetResourceSpans())
|
||||
if err != nil {
|
||||
ctxLogger.Error("Failed to transform trace to data frame", "error", err, "function", logEntrypoint())
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return &backend.DataResponse{}, fmt.Errorf("failed to transform trace %v to data frame: %w", model.Query, err)
|
||||
}
|
||||
|
||||
if frame == nil {
|
||||
result.Status = http.StatusNotFound
|
||||
err := fmt.Errorf("failed to get trace with id: %s Status: %s", *model.Query, result.Status)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return nil, backend.DownstreamError(err)
|
||||
}
|
||||
} else {
|
||||
var tr tempopb.TraceByIDResponse
|
||||
err = proto.Unmarshal(traceBody, &tr)
|
||||
|
||||
if err != nil {
|
||||
ctxLogger.Error("Failed to convert tempo response to Otlp", "error", err, "function", logEntrypoint())
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return &backend.DataResponse{}, fmt.Errorf("failed to convert tempo response to Otlp: %w", err)
|
||||
}
|
||||
|
||||
frame, err = TraceToFrame(tr.Trace.ResourceSpans)
|
||||
if err != nil {
|
||||
ctxLogger.Error("Failed to transform trace to data frame", "error", err, "function", logEntrypoint())
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return &backend.DataResponse{}, fmt.Errorf("failed to transform trace %v to data frame: %w", model.Query, err)
|
||||
}
|
||||
|
||||
if frame == nil {
|
||||
result.Status = http.StatusNotFound
|
||||
err := fmt.Errorf("failed to get trace with id: %s Status: %s", *model.Query, result.Status)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return nil, backend.DownstreamError(err)
|
||||
}
|
||||
|
||||
frame.Meta.Custom = map[string]interface{}{
|
||||
"partial": tr.GetStatus() == tempopb.PartialStatus_PARTIAL,
|
||||
"message": tr.GetMessage(),
|
||||
}
|
||||
}
|
||||
|
||||
frame.RefID = refID
|
||||
frames := []*data.Frame{frame}
|
||||
result.Frames = frames
|
||||
ctxLogger.Debug("Successfully got trace", "function", logEntrypoint())
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) performTraceRequest(ctx context.Context, dsInfo *DatasourceInfo, apiVersion TraceRequestApiVersion, model *dataquery.TempoQuery, query backend.DataQuery, span trace.Span) (*http.Response, []byte, error) {
|
||||
ctxLogger := s.logger.FromContext(ctx)
|
||||
request, err := s.createRequest(ctx, dsInfo, apiVersion, *model.Query, query.TimeRange.From.Unix(), query.TimeRange.To.Unix())
|
||||
|
||||
if err != nil {
|
||||
ctxLogger.Error("Failed to create request", "error", err, "function", logEntrypoint())
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return nil, nil, backend.DownstreamErrorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := dsInfo.HTTPClient.Do(request)
|
||||
if err != nil {
|
||||
ctxLogger.Error("Failed to send request to Tempo", "error", err, "function", logEntrypoint())
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
if backend.IsDownstreamHTTPError(err) {
|
||||
return nil, nil, backend.DownstreamError(err)
|
||||
}
|
||||
return nil, nil, fmt.Errorf("failed get to tempo: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if resp != nil && resp.Body != nil {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
ctxLogger.Error("Failed to close response body", "error", err, "function", logEntrypoint())
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
ctxLogger.Error("Failed to read response body", "error", err, "function", logEntrypoint())
|
||||
return nil, nil, err
|
||||
}
|
||||
return resp, body, nil
|
||||
}
|
||||
|
||||
type TraceRequestApiVersion int
|
||||
|
||||
const (
|
||||
TraceRequestApiVersionV1 TraceRequestApiVersion = iota
|
||||
TraceRequestApiVersionV2
|
||||
)
|
||||
|
||||
func (s *Service) createRequest(ctx context.Context, dsInfo *DatasourceInfo, apiVersion TraceRequestApiVersion, traceID string, start int64, end int64) (*http.Request, error) {
|
||||
ctxLogger := s.logger.FromContext(ctx)
|
||||
var baseUrl string
|
||||
var tempoQuery string
|
||||
|
||||
if !traceIDPattern.MatchString(traceID) {
|
||||
return nil, backend.DownstreamErrorf("invalid trace id")
|
||||
}
|
||||
|
||||
if apiVersion == TraceRequestApiVersionV1 {
|
||||
baseUrl = fmt.Sprintf("%s/api/traces/%s", dsInfo.URL, traceID)
|
||||
} else {
|
||||
baseUrl = fmt.Sprintf("%s/api/v2/traces/%s", dsInfo.URL, traceID)
|
||||
}
|
||||
|
||||
if start == 0 || end == 0 {
|
||||
tempoQuery = baseUrl
|
||||
} else {
|
||||
tempoQuery = fmt.Sprintf("%s?start=%d&end=%d", baseUrl, start, end)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", tempoQuery, nil)
|
||||
if err != nil {
|
||||
ctxLogger.Error("Failed to create request", "error", err, "function", logEntrypoint())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/protobuf")
|
||||
return req, nil
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
package tempo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/datasource"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTempo(t *testing.T) {
|
||||
t.Run("createRequest v1 without time range - success", func(t *testing.T) {
|
||||
service := &Service{logger: backend.NewLoggerWith("logger", "tempo-test")}
|
||||
req, err := service.createRequest(context.Background(), &DatasourceInfo{}, TraceRequestApiVersionV1, "abc123", 0, 0)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, len(req.Header))
|
||||
assert.Equal(t, "/api/traces/abc123", req.URL.String())
|
||||
})
|
||||
|
||||
t.Run("createRequest v1 with time range - success", func(t *testing.T) {
|
||||
service := &Service{logger: backend.NewLoggerWith("logger", "tempo-test")}
|
||||
req, err := service.createRequest(context.Background(), &DatasourceInfo{}, TraceRequestApiVersionV1, "abc123", 1, 2)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, len(req.Header))
|
||||
assert.Equal(t, "/api/traces/abc123?start=1&end=2", req.URL.String())
|
||||
})
|
||||
|
||||
t.Run("createRequest v2 without time range - success", func(t *testing.T) {
|
||||
service := &Service{logger: backend.NewLoggerWith("logger", "tempo-test")}
|
||||
req, err := service.createRequest(context.Background(), &DatasourceInfo{}, TraceRequestApiVersionV2, "abc123", 0, 0)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, len(req.Header))
|
||||
assert.Equal(t, "/api/v2/traces/abc123", req.URL.String())
|
||||
})
|
||||
|
||||
t.Run("createRequest v2 with time range - success", func(t *testing.T) {
|
||||
service := &Service{logger: backend.NewLoggerWith("logger", "tempo-test")}
|
||||
req, err := service.createRequest(context.Background(), &DatasourceInfo{}, TraceRequestApiVersionV2, "abc123", 1, 2)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, len(req.Header))
|
||||
assert.Equal(t, "/api/v2/traces/abc123?start=1&end=2", req.URL.String())
|
||||
})
|
||||
|
||||
t.Run("getTrace v1 empty ResourceSpans returns downstream error", func(t *testing.T) {
|
||||
v1Called := false
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "/api/v2/traces/") {
|
||||
w.WriteHeader(http.StatusNotFound) // trigger v1 fallback
|
||||
} else if strings.Contains(r.URL.Path, "/api/traces/") {
|
||||
v1Called = true
|
||||
w.WriteHeader(http.StatusOK) // empty body → empty ResourceSpans → nil frame
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
im := datasource.NewInstanceManager(func(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
|
||||
return &DatasourceInfo{URL: server.URL, HTTPClient: server.Client()}, nil
|
||||
})
|
||||
|
||||
service := &Service{
|
||||
im: im,
|
||||
logger: backend.NewLoggerWith("logger", "tempo-test"),
|
||||
}
|
||||
|
||||
pluginCtx := backend.PluginContext{
|
||||
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{URL: server.URL},
|
||||
}
|
||||
query := backend.DataQuery{JSON: []byte(`{"query": "abc123"}`)}
|
||||
|
||||
res, err := service.getTrace(context.Background(), pluginCtx, query)
|
||||
|
||||
assert.True(t, v1Called, "expected v1 endpoint (/api/traces/) to be called")
|
||||
assert.Nil(t, res)
|
||||
require.Error(t, err)
|
||||
assert.True(t, backend.IsDownstreamError(err))
|
||||
})
|
||||
}
|
||||
|
|
@ -1,397 +0,0 @@
|
|||
package tempo
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
commonv11 "github.com/grafana/tempo/pkg/tempopb/common/v1"
|
||||
v1 "github.com/grafana/tempo/pkg/tempopb/resource/v1"
|
||||
tracev11 "github.com/grafana/tempo/pkg/tempopb/trace/v1"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.17.0"
|
||||
)
|
||||
|
||||
// serviceNamespaceAltKey is an alternative to the OTel semconv canonical service.namespace (e.g. used by some SDKs).
|
||||
const serviceNamespaceAltKey = "service.namespace.name"
|
||||
|
||||
type KeyValue struct {
|
||||
Value any `json:"value"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
type TraceLog struct {
|
||||
// Millisecond epoch time
|
||||
Timestamp float64 `json:"timestamp"`
|
||||
Fields []*KeyValue `json:"fields"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
type TraceReference struct {
|
||||
SpanID string `json:"spanID"`
|
||||
TraceID string `json:"traceID"`
|
||||
Tags []*KeyValue `json:"tags"`
|
||||
}
|
||||
|
||||
func TraceToFrame(resourceSpans []*tracev11.ResourceSpans) (*data.Frame, error) {
|
||||
// In open telemetry format the spans are grouped first by resource/service they originated in and inside that
|
||||
// resource they are grouped by the instrumentation library which created them.
|
||||
|
||||
if len(resourceSpans) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
frame := &data.Frame{
|
||||
Name: "Trace",
|
||||
Fields: []*data.Field{
|
||||
data.NewField("traceID", nil, []string{}),
|
||||
data.NewField("spanID", nil, []string{}),
|
||||
data.NewField("parentSpanID", nil, []string{}),
|
||||
data.NewField("operationName", nil, []string{}),
|
||||
data.NewField("serviceName", nil, []string{}),
|
||||
data.NewField("serviceNamespace", nil, []string{}),
|
||||
data.NewField("kind", nil, []string{}),
|
||||
data.NewField("statusCode", nil, []int64{}),
|
||||
data.NewField("statusMessage", nil, []string{}),
|
||||
data.NewField("instrumentationLibraryName", nil, []string{}),
|
||||
data.NewField("instrumentationLibraryVersion", nil, []string{}),
|
||||
data.NewField("traceState", nil, []string{}),
|
||||
data.NewField("serviceTags", nil, []json.RawMessage{}),
|
||||
data.NewField("startTime", nil, []float64{}),
|
||||
data.NewField("duration", nil, []float64{}),
|
||||
data.NewField("logs", nil, []json.RawMessage{}),
|
||||
data.NewField("references", nil, []json.RawMessage{}),
|
||||
data.NewField("tags", nil, []json.RawMessage{}),
|
||||
},
|
||||
Meta: &data.FrameMeta{
|
||||
PreferredVisualization: data.VisTypeTrace,
|
||||
},
|
||||
}
|
||||
|
||||
for i := 0; i < len(resourceSpans); i++ {
|
||||
rs := resourceSpans[i]
|
||||
rows, err := resourceSpansToRows(rs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
frame.AppendRow(row...)
|
||||
}
|
||||
}
|
||||
|
||||
return frame, nil
|
||||
}
|
||||
|
||||
// resourceSpansToRows processes all the spans for a particular resource/service
|
||||
func resourceSpansToRows(rs *tracev11.ResourceSpans) ([][]any, error) {
|
||||
resource := rs.Resource
|
||||
ilss := rs.ScopeSpans
|
||||
|
||||
if len(resource.Attributes) == 0 || len(ilss) == 0 {
|
||||
return [][]any{}, nil
|
||||
}
|
||||
|
||||
// Approximate the number of the spans as the number of the spans in the first
|
||||
// instrumentation library info.
|
||||
rows := make([][]any, 0, len(ilss[0].Spans))
|
||||
|
||||
for i := 0; i < len(ilss); i++ {
|
||||
ils := ilss[i]
|
||||
|
||||
// These are finally the actual spans
|
||||
spans := ils.Spans
|
||||
|
||||
for j := 0; j < len(spans); j++ {
|
||||
span := spans[j]
|
||||
row, err := spanToSpanRow(span, ils.Scope, resource)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row != nil {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func spanToSpanRow(span *tracev11.Span, libraryTags *commonv11.InstrumentationScope, resource *v1.Resource) ([]any, error) {
|
||||
// If the id representation changed from hexstring to something else we need to change the transformBase64IDToHexString in the frontend code
|
||||
traceID := span.TraceId
|
||||
traceIDHex := hex.EncodeToString(traceID[:])
|
||||
|
||||
spanID := span.SpanId
|
||||
spanIDHex := hex.EncodeToString(spanID[:])
|
||||
|
||||
parentSpanID := span.ParentSpanId
|
||||
parentSpanIDHex := hex.EncodeToString(parentSpanID[:])
|
||||
|
||||
startTime := float64(span.StartTimeUnixNano) / 1_000_000
|
||||
serviceName, serviceNamespace, serviceTags := resourceToProcess(resource)
|
||||
|
||||
status := span.Status
|
||||
statusCode := int64(status.Code)
|
||||
statusMessage := status.Message
|
||||
|
||||
libraryName := libraryTags.Name
|
||||
libraryVersion := libraryTags.Version
|
||||
traceState := span.TraceState
|
||||
|
||||
serviceTagsJson, err := json.Marshal(serviceTags)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal service tags: %w", err)
|
||||
}
|
||||
|
||||
// Get both span tags and scope tags and combine them
|
||||
spanTagsList := getSpanTags(span)
|
||||
scopeTagsList := getScopeTags(libraryTags)
|
||||
spanTagsList = append(spanTagsList, scopeTagsList...)
|
||||
|
||||
spanTags, err := json.Marshal(spanTagsList)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal span tags: %w", err)
|
||||
}
|
||||
|
||||
logs, err := json.Marshal(spanEventsToLogs(span.Events))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal span logs: %w", err)
|
||||
}
|
||||
|
||||
references, err := json.Marshal(spanLinksToReferences(span.Links))
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal span links: %w", err)
|
||||
}
|
||||
|
||||
// Order matters (look at dataframe order)
|
||||
return []any{
|
||||
traceIDHex,
|
||||
spanIDHex,
|
||||
parentSpanIDHex,
|
||||
span.Name,
|
||||
serviceName,
|
||||
serviceNamespace,
|
||||
getSpanKind(span.Kind),
|
||||
statusCode,
|
||||
statusMessage,
|
||||
libraryName,
|
||||
libraryVersion,
|
||||
traceState,
|
||||
json.RawMessage(serviceTagsJson),
|
||||
startTime,
|
||||
float64(span.EndTimeUnixNano-span.StartTimeUnixNano) / 1_000_000,
|
||||
json.RawMessage(logs),
|
||||
json.RawMessage(references),
|
||||
json.RawMessage(spanTags),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resourceToProcess(resource *v1.Resource) (string, string, []*KeyValue) {
|
||||
attrs := resource.Attributes
|
||||
serviceName := ResourceNoServiceName
|
||||
var serviceNamespace, serviceNamespaceAlt string
|
||||
if len(attrs) == 0 {
|
||||
return serviceName, serviceNamespace, nil
|
||||
}
|
||||
|
||||
tags := make([]*KeyValue, 0, len(attrs)-1)
|
||||
for _, attr := range attrs {
|
||||
if attribute.Key(attr.Key) == semconv.ServiceNameKey {
|
||||
serviceName = attr.GetValue().GetStringValue()
|
||||
}
|
||||
if attribute.Key(attr.Key) == semconv.ServiceNamespaceKey {
|
||||
serviceNamespace = attr.GetValue().GetStringValue()
|
||||
}
|
||||
if attribute.Key(attr.Key) == attribute.Key(serviceNamespaceAltKey) {
|
||||
serviceNamespaceAlt = attr.GetValue().GetStringValue()
|
||||
}
|
||||
val, err := getAttributeVal(attr.Value)
|
||||
if err != nil {
|
||||
logger.Debug("error transforming resource to process", "err", err)
|
||||
}
|
||||
tags = append(tags, &KeyValue{Key: attr.Key, Value: val})
|
||||
}
|
||||
|
||||
// Coalesce: prefer OTel semconv canonical service.namespace, fallback to service.namespace.name
|
||||
if serviceNamespace == "" && serviceNamespaceAlt != "" {
|
||||
serviceNamespace = serviceNamespaceAlt
|
||||
}
|
||||
return serviceName, serviceNamespace, tags
|
||||
}
|
||||
|
||||
func getAttributeVal(attr *commonv11.AnyValue) (any, error) {
|
||||
switch attr.GetValue().(type) {
|
||||
case *commonv11.AnyValue_StringValue:
|
||||
return attr.GetStringValue(), nil
|
||||
case *commonv11.AnyValue_IntValue:
|
||||
return attr.GetIntValue(), nil
|
||||
case *commonv11.AnyValue_BoolValue:
|
||||
return attr.GetBoolValue(), nil
|
||||
case *commonv11.AnyValue_DoubleValue:
|
||||
f := attr.GetDoubleValue()
|
||||
switch {
|
||||
case math.IsNaN(f):
|
||||
return "NaN", nil
|
||||
case math.IsInf(f, 1):
|
||||
return "Inf", nil
|
||||
case math.IsInf(f, -1):
|
||||
return "-Inf", nil
|
||||
default:
|
||||
return f, nil
|
||||
}
|
||||
case *commonv11.AnyValue_KvlistValue:
|
||||
return kvListAsString(attr.GetKvlistValue())
|
||||
case *commonv11.AnyValue_ArrayValue:
|
||||
return arrayAsString(attr.GetArrayValue())
|
||||
default:
|
||||
return attr.GetStringValue(), nil
|
||||
}
|
||||
}
|
||||
|
||||
func arrayAsString(list *commonv11.ArrayValue) (string, error) {
|
||||
vals := make([]any, len(list.GetValues()))
|
||||
|
||||
for i, val := range list.GetValues() {
|
||||
v, err := getAttributeVal(val)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get attribute value: %w", err)
|
||||
}
|
||||
vals[i] = v
|
||||
}
|
||||
|
||||
res, err := json.Marshal(vals)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal array: %w", err)
|
||||
}
|
||||
return string(res), nil
|
||||
}
|
||||
|
||||
func kvListAsString(list *commonv11.KeyValueList) (string, error) {
|
||||
vals := make(map[string]any, len(list.GetValues()))
|
||||
|
||||
for _, val := range list.GetValues() {
|
||||
v, err := getAttributeVal(val.GetValue())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get attribute value: %w", err)
|
||||
}
|
||||
vals[val.GetKey()] = v
|
||||
}
|
||||
|
||||
res, err := json.Marshal(vals)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal kvlist: %w", err)
|
||||
}
|
||||
return string(res), nil
|
||||
}
|
||||
|
||||
func getSpanTags(span *tracev11.Span) []*KeyValue {
|
||||
tags := make([]*KeyValue, len(span.Attributes))
|
||||
for i, attr := range span.Attributes {
|
||||
val, err := getAttributeVal(attr.Value)
|
||||
if err != nil {
|
||||
logger.Debug("error transforming span tags", "err", err)
|
||||
}
|
||||
tags[i] = &KeyValue{Key: attr.Key, Value: val}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
func getScopeTags(scope *commonv11.InstrumentationScope) []*KeyValue {
|
||||
if scope == nil || len(scope.Attributes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
tags := make([]*KeyValue, len(scope.Attributes))
|
||||
for i, attr := range scope.Attributes {
|
||||
val, err := getAttributeVal(attr.Value)
|
||||
if err != nil {
|
||||
logger.Debug("error transforming scope attributes", "err", err)
|
||||
}
|
||||
tags[i] = &KeyValue{Key: attr.Key, Value: val}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
func getSpanKind(spanKind tracev11.Span_SpanKind) string {
|
||||
var tagStr string
|
||||
switch spanKind {
|
||||
case tracev11.Span_SPAN_KIND_CLIENT:
|
||||
tagStr = string(OpenTracingSpanKindClient)
|
||||
case tracev11.Span_SPAN_KIND_SERVER:
|
||||
tagStr = string(OpenTracingSpanKindServer)
|
||||
case tracev11.Span_SPAN_KIND_PRODUCER:
|
||||
tagStr = string(OpenTracingSpanKindProducer)
|
||||
case tracev11.Span_SPAN_KIND_CONSUMER:
|
||||
tagStr = string(OpenTracingSpanKindConsumer)
|
||||
case tracev11.Span_SPAN_KIND_INTERNAL:
|
||||
tagStr = string(OpenTracingSpanKindInternal)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
return tagStr
|
||||
}
|
||||
|
||||
func spanEventsToLogs(events []*tracev11.Span_Event) []*TraceLog {
|
||||
if len(events) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
logs := make([]*TraceLog, 0, len(events))
|
||||
for i := 0; i < len(events); i++ {
|
||||
event := events[i]
|
||||
fields := make([]*KeyValue, 0, len(event.Attributes)+1)
|
||||
for _, attr := range event.Attributes {
|
||||
val, err := getAttributeVal(attr.Value)
|
||||
if err != nil {
|
||||
logger.Debug("error transforming span events to logs", "err", err)
|
||||
}
|
||||
fields = append(fields, &KeyValue{Key: attr.Key, Value: val})
|
||||
}
|
||||
logs = append(logs, &TraceLog{
|
||||
Timestamp: float64(event.TimeUnixNano) / 1_000_000,
|
||||
Fields: fields,
|
||||
Name: event.Name,
|
||||
})
|
||||
}
|
||||
|
||||
return logs
|
||||
}
|
||||
|
||||
func spanLinksToReferences(links []*tracev11.Span_Link) []*TraceReference {
|
||||
if len(links) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
references := make([]*TraceReference, 0, len(links))
|
||||
for i := 0; i < len(links); i++ {
|
||||
link := links[i]
|
||||
|
||||
traceID := link.TraceId
|
||||
traceIDHex := hex.EncodeToString(traceID[:])
|
||||
|
||||
spanID := link.SpanId
|
||||
spanIDHex := hex.EncodeToString(spanID[:])
|
||||
|
||||
tags := make([]*KeyValue, 0, len(link.Attributes))
|
||||
for _, attr := range link.Attributes {
|
||||
val, err := getAttributeVal(attr.Value)
|
||||
if err != nil {
|
||||
logger.Debug("error transforming span links to references", "err", err)
|
||||
}
|
||||
tags = append(tags, &KeyValue{Key: attr.Key, Value: val})
|
||||
}
|
||||
|
||||
references = append(references, &TraceReference{
|
||||
TraceID: traceIDHex,
|
||||
SpanID: spanIDHex,
|
||||
Tags: tags,
|
||||
})
|
||||
}
|
||||
|
||||
return references
|
||||
}
|
||||
|
|
@ -1,387 +0,0 @@
|
|||
package tempo
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
//nolint:all
|
||||
"github.com/golang/protobuf/jsonpb"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/grafana/tempo/pkg/tempopb"
|
||||
commonv11 "github.com/grafana/tempo/pkg/tempopb/common/v1"
|
||||
resourcev1 "github.com/grafana/tempo/pkg/tempopb/resource/v1"
|
||||
v1 "github.com/grafana/tempo/pkg/tempopb/trace/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTraceToFrame(t *testing.T) {
|
||||
t.Run("should transform tempo protobuf response into dataframe", func(t *testing.T) {
|
||||
jsonResponse, err := os.Open("testData/trace.json")
|
||||
require.NoError(t, err)
|
||||
|
||||
var otTrace tempopb.Trace
|
||||
err = jsonpb.Unmarshal(jsonResponse, &otTrace)
|
||||
require.NoError(t, err)
|
||||
|
||||
// For some reason the trace does not have named events (probably was generated some time ago) so we just set
|
||||
// one here for testing
|
||||
origSpan := findSpan(otTrace, "7198307df9748606")
|
||||
origSpan.GetEvents()[0].Name = "test event"
|
||||
|
||||
frame, err := TraceToFrame(otTrace.GetResourceSpans())
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, 30, frame.Rows())
|
||||
require.ElementsMatch(t, fields, fieldNames(frame))
|
||||
|
||||
bFrame := &BetterFrame{frame}
|
||||
root := rootSpan(bFrame)
|
||||
require.NotNil(t, root)
|
||||
|
||||
require.Equal(t, "HTTP GET - loki_api_v1_query_range", root["operationName"])
|
||||
require.Equal(t, "loki-all", root["serviceName"])
|
||||
require.Equal(t, json.RawMessage("[{\"value\":\"loki-all\",\"key\":\"service.name\"},{\"value\":\"Jaeger-Go-2.25.0\",\"key\":\"opencensus.exporterversion\"},{\"value\":\"4d019a031941\",\"key\":\"host.hostname\"},{\"value\":\"172.18.0.6\",\"key\":\"ip\"},{\"value\":\"4b19ace06df8e4de\",\"key\":\"client-uuid\"}]"), root["serviceTags"])
|
||||
require.Equal(t, 1616072924070.497, root["startTime"])
|
||||
require.Equal(t, 8.421, root["duration"])
|
||||
require.Equal(t, json.RawMessage("null"), root["logs"])
|
||||
require.Equal(t, json.RawMessage("[{\"value\":\"const\",\"key\":\"sampler.type\"},{\"value\":true,\"key\":\"sampler.param\"},{\"value\":200,\"key\":\"http.status_code\"},{\"value\":\"GET\",\"key\":\"http.method\"},{\"value\":\"/loki/api/v1/query_range?direction=BACKWARD\\u0026limit=1000\\u0026query=%7Bcompose_project%3D%22devenv%22%7D%20%7C%3D%22trace_id%22\\u0026start=1616070921000000000\\u0026end=1616072722000000000\\u0026step=2\",\"key\":\"http.url\"},{\"value\":\"net/http\",\"key\":\"component\"},{\"value\":\"[\\\"value1\\\",\\\"value2\\\"]\",\"key\":\"arrayAttribute\"},{\"value\":\"{\\\"key1\\\":\\\"value1\\\",\\\"key2\\\":\\\"value2\\\"}\",\"key\":\"kvlistAttribute\"}]"), root["tags"])
|
||||
|
||||
span := bFrame.FindRowWithValue("spanID", "7198307df9748606")
|
||||
|
||||
require.Equal(t, "GetParallelChunks", span["operationName"])
|
||||
require.Equal(t, "loki-all", span["serviceName"])
|
||||
require.Equal(t, json.RawMessage("[{\"value\":\"loki-all\",\"key\":\"service.name\"},{\"value\":\"Jaeger-Go-2.25.0\",\"key\":\"opencensus.exporterversion\"},{\"value\":\"4d019a031941\",\"key\":\"host.hostname\"},{\"value\":\"172.18.0.6\",\"key\":\"ip\"},{\"value\":\"4b19ace06df8e4de\",\"key\":\"client-uuid\"}]"), span["serviceTags"])
|
||||
require.Equal(t, 1616072924072.852, span["startTime"])
|
||||
require.Equal(t, 0.094, span["duration"])
|
||||
expectedLogs := `
|
||||
[
|
||||
{
|
||||
"timestamp": 1616072924072.856,
|
||||
"name": "test event",
|
||||
"fields": [
|
||||
{
|
||||
"value": 1,
|
||||
"key": "chunks requested"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"timestamp": 1616072924072.9448,
|
||||
"fields": [
|
||||
{
|
||||
"value": 1,
|
||||
"key": "chunks fetched"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
`
|
||||
assert.JSONEq(t, expectedLogs, string(span["logs"].(json.RawMessage)))
|
||||
})
|
||||
|
||||
t.Run("should transform correct traceID", func(t *testing.T) {
|
||||
jsonResponse, err := os.Open("testData/trace.json")
|
||||
require.NoError(t, err)
|
||||
|
||||
var otTrace tempopb.Trace
|
||||
err = jsonpb.Unmarshal(jsonResponse, &otTrace)
|
||||
require.NoError(t, err)
|
||||
|
||||
var index int
|
||||
for _, resourceSpans := range otTrace.GetResourceSpans() {
|
||||
for _, scopeSpans := range resourceSpans.GetScopeSpans() {
|
||||
for _, span := range scopeSpans.GetSpans() {
|
||||
if index == 0 {
|
||||
span.TraceId = []byte{0, 1, 2, 3, 4, 5, 6, 7, 0, 1, 2, 3, 4, 5, 6, 7}
|
||||
}
|
||||
if index == 1 {
|
||||
span.TraceId = []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7}
|
||||
}
|
||||
index++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
frame, err := TraceToFrame(otTrace.GetResourceSpans())
|
||||
require.NoError(t, err)
|
||||
bFrame := &BetterFrame{frame}
|
||||
|
||||
traceID128Bit := bFrame.GetRow(0)
|
||||
require.NotNil(t, traceID128Bit)
|
||||
require.Equal(t, "00010203040506070001020304050607", traceID128Bit["traceID"])
|
||||
|
||||
traceID64Bit := bFrame.GetRow(1)
|
||||
require.NotNil(t, traceID64Bit)
|
||||
require.Equal(t, "00000000000000000001020304050607", traceID64Bit["traceID"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestScopeAttributesAddedToTags(t *testing.T) {
|
||||
span := &v1.Span{
|
||||
TraceId: []byte{0, 1, 2, 3, 4, 5, 6, 7, 0, 1, 2, 3, 4, 5, 6, 7},
|
||||
SpanId: []byte{0, 1, 2, 3, 4, 5, 6, 7},
|
||||
ParentSpanId: []byte{0, 0, 0, 0, 0, 0, 0, 0},
|
||||
Name: "test-span",
|
||||
StartTimeUnixNano: 1616072924070497000,
|
||||
EndTimeUnixNano: 1616072924078918000,
|
||||
Attributes: []*commonv11.KeyValue{
|
||||
{
|
||||
Key: "span.attribute",
|
||||
Value: &commonv11.AnyValue{
|
||||
Value: &commonv11.AnyValue_StringValue{
|
||||
StringValue: "span-value",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Status: &v1.Status{},
|
||||
}
|
||||
|
||||
// Create instrumentation scope with attributes
|
||||
scope := &commonv11.InstrumentationScope{
|
||||
Name: "my.library",
|
||||
Version: "1.0.0",
|
||||
Attributes: []*commonv11.KeyValue{
|
||||
{
|
||||
Key: "scope.attribute",
|
||||
Value: &commonv11.AnyValue{
|
||||
Value: &commonv11.AnyValue_StringValue{
|
||||
StringValue: "scope-value",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resource := &resourcev1.Resource{
|
||||
Attributes: []*commonv11.KeyValue{
|
||||
{
|
||||
Key: "service.name",
|
||||
Value: &commonv11.AnyValue{
|
||||
Value: &commonv11.AnyValue_StringValue{
|
||||
StringValue: "test-service",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
row, err := spanToSpanRow(span, scope, resource)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check that both span and scope attributes are in tags
|
||||
// Tags are at index 17 in the row (based on frame field ordering: traceID, spanID, parentSpanID, operationName, serviceName, serviceNamespace, kind, statusCode, statusMessage, instrumentationLibraryName, instrumentationLibraryVersion, traceState, serviceTags, startTime, duration, logs, references, tags)
|
||||
tagsJson := row[17].(json.RawMessage)
|
||||
|
||||
var tags []*KeyValue
|
||||
err = json.Unmarshal(tagsJson, &tags)
|
||||
require.NoError(t, err)
|
||||
|
||||
foundSpanAttr := false
|
||||
foundScopeAttr := false
|
||||
|
||||
for _, tag := range tags {
|
||||
if tag.Key == "span.attribute" && tag.Value == "span-value" {
|
||||
foundSpanAttr = true
|
||||
}
|
||||
if tag.Key == "scope.attribute" && tag.Value == "scope-value" {
|
||||
foundScopeAttr = true
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, foundSpanAttr, "Span attribute should be present in tags")
|
||||
assert.True(t, foundScopeAttr, "Scope attribute should be present in tags")
|
||||
}
|
||||
|
||||
func makeSpanWithDoubleAttr(key string, val float64) (*v1.Span, *resourcev1.Resource) {
|
||||
span := &v1.Span{
|
||||
TraceId: []byte{0, 1, 2, 3, 4, 5, 6, 7, 0, 1, 2, 3, 4, 5, 6, 7},
|
||||
SpanId: []byte{0, 1, 2, 3, 4, 5, 6, 7},
|
||||
ParentSpanId: []byte{0, 0, 0, 0, 0, 0, 0, 0},
|
||||
Name: "test-span",
|
||||
StartTimeUnixNano: 1616072924070497000,
|
||||
EndTimeUnixNano: 1616072924078918000,
|
||||
Attributes: []*commonv11.KeyValue{
|
||||
{
|
||||
Key: key,
|
||||
Value: &commonv11.AnyValue{Value: &commonv11.AnyValue_DoubleValue{DoubleValue: val}},
|
||||
},
|
||||
},
|
||||
Status: &v1.Status{},
|
||||
}
|
||||
resource := &resourcev1.Resource{
|
||||
Attributes: []*commonv11.KeyValue{
|
||||
{
|
||||
Key: "service.name",
|
||||
Value: &commonv11.AnyValue{Value: &commonv11.AnyValue_StringValue{StringValue: "test-service"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
return span, resource
|
||||
}
|
||||
|
||||
func TestTraceToFrame_SpecialFloatDoubleValue(t *testing.T) {
|
||||
// OTel spec explicitly allows NaN, Infinity, -Infinity as floating point values.
|
||||
// https://opentelemetry.io/docs/specs/otel/common/#floating-point-numbers
|
||||
// json.Marshal rejects these — must be serialized as strings.
|
||||
tests := []struct {
|
||||
name string
|
||||
val float64
|
||||
expected string
|
||||
}{
|
||||
{"NaN", math.NaN(), "NaN"},
|
||||
{"Inf", math.Inf(1), "Inf"},
|
||||
{"-Inf", math.Inf(-1), "-Inf"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
span, resource := makeSpanWithDoubleAttr("span.app.payment.amount", tc.val)
|
||||
|
||||
row, err := spanToSpanRow(span, &commonv11.InstrumentationScope{}, resource)
|
||||
require.NoError(t, err)
|
||||
|
||||
tagsJson, ok := row[17].(json.RawMessage)
|
||||
require.True(t, ok)
|
||||
|
||||
var tags []*KeyValue
|
||||
require.NoError(t, json.Unmarshal(tagsJson, &tags))
|
||||
|
||||
require.Len(t, tags, 1)
|
||||
assert.Equal(t, tc.expected, tags[0].Value)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type Row map[string]any
|
||||
type BetterFrame struct {
|
||||
frame *data.Frame
|
||||
}
|
||||
|
||||
func (f *BetterFrame) GetRow(index int) Row {
|
||||
row := f.frame.RowCopy(index)
|
||||
betterRow := make(map[string]any)
|
||||
for i, field := range row {
|
||||
betterRow[f.frame.Fields[i].Name] = field
|
||||
}
|
||||
|
||||
return betterRow
|
||||
}
|
||||
|
||||
func (f *BetterFrame) FindRow(fn func(row Row) bool) Row {
|
||||
for i := 0; i < f.frame.Rows(); i++ {
|
||||
row := f.GetRow(i)
|
||||
if fn(row) {
|
||||
return row
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *BetterFrame) FindRowWithValue(fieldName string, value any) Row {
|
||||
return f.FindRow(func(row Row) bool {
|
||||
return row[fieldName] == value
|
||||
})
|
||||
}
|
||||
|
||||
func rootSpan(frame *BetterFrame) Row {
|
||||
return frame.FindRowWithValue("parentSpanID", "0000000000000000")
|
||||
}
|
||||
|
||||
func fieldNames(frame *data.Frame) []string {
|
||||
names := make([]string, len(frame.Fields))
|
||||
for i, f := range frame.Fields {
|
||||
names[i] = f.Name
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
func TestSpanToSpanRowTraceIDNotLeftTrimmed(t *testing.T) {
|
||||
// A trace ID whose first 8 bytes are all zeros should be returned as a full
|
||||
// 32-character hex string, not left-trimmed to 16 characters.
|
||||
span := &v1.Span{
|
||||
TraceId: []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7},
|
||||
SpanId: []byte{0, 1, 2, 3, 4, 5, 6, 7},
|
||||
ParentSpanId: []byte{0, 0, 0, 0, 0, 0, 0, 0},
|
||||
Name: "test-span",
|
||||
StartTimeUnixNano: 1616072924070497000,
|
||||
EndTimeUnixNano: 1616072924078918000,
|
||||
Status: &v1.Status{},
|
||||
}
|
||||
resource := &resourcev1.Resource{
|
||||
Attributes: []*commonv11.KeyValue{
|
||||
{
|
||||
Key: "service.name",
|
||||
Value: &commonv11.AnyValue{
|
||||
Value: &commonv11.AnyValue_StringValue{StringValue: "test-service"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
scope := &commonv11.InstrumentationScope{Name: "test", Version: "1.0"}
|
||||
row, err := spanToSpanRow(span, scope, resource)
|
||||
require.NoError(t, err)
|
||||
|
||||
// traceID is the first field in the row
|
||||
require.Equal(t, "00000000000000000001020304050607", row[0],
|
||||
"trace ID with leading zero bytes must not be left-trimmed")
|
||||
}
|
||||
|
||||
func TestSpanLinksToReferencesTraceIDNotLeftTrimmed(t *testing.T) {
|
||||
// A trace ID with leading zero bytes in a span link should be returned as a
|
||||
// full 32-character hex string, not left-trimmed.
|
||||
links := []*v1.Span_Link{
|
||||
{
|
||||
TraceId: []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7},
|
||||
SpanId: []byte{1, 2, 3, 4, 5, 6, 7, 8},
|
||||
},
|
||||
}
|
||||
|
||||
refs := spanLinksToReferences(links)
|
||||
require.Len(t, refs, 1)
|
||||
require.Equal(t, "00000000000000000001020304050607", refs[0].TraceID,
|
||||
"trace ID with leading zero bytes in span links must not be left-trimmed")
|
||||
}
|
||||
|
||||
func findSpan(trace tempopb.Trace, spanId string) *v1.Span {
|
||||
for i := 0; i < len(trace.GetResourceSpans()); i++ {
|
||||
scope := trace.GetResourceSpans()[i].GetScopeSpans()
|
||||
for j := 0; j < len(scope); j++ {
|
||||
spans := scope[j].GetSpans()
|
||||
for k := 0; k < len(spans); k++ {
|
||||
if hex.EncodeToString(spans[k].GetSpanId()[:]) == spanId {
|
||||
return spans[k]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var fields = []string{
|
||||
"traceID",
|
||||
"spanID",
|
||||
"parentSpanID",
|
||||
"operationName",
|
||||
"serviceName",
|
||||
"serviceNamespace",
|
||||
"kind",
|
||||
"statusCode",
|
||||
"statusMessage",
|
||||
"instrumentationLibraryName",
|
||||
"instrumentationLibraryVersion",
|
||||
"traceState",
|
||||
"serviceTags",
|
||||
"startTime",
|
||||
"duration",
|
||||
"logs",
|
||||
"references",
|
||||
"tags",
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
package traceql
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/grafana/tempo/pkg/tempopb"
|
||||
)
|
||||
|
||||
func transformExemplarToFrame(name string, series *tempopb.TimeSeries) *data.Frame {
|
||||
exemplars := series.Exemplars
|
||||
|
||||
// Setup fields for basic data
|
||||
fields := make([]*data.Field, 0, 3+len(series.Labels))
|
||||
fields = append(fields,
|
||||
data.NewField("Time", nil, []time.Time{}),
|
||||
data.NewField("Value", nil, []float64{}),
|
||||
data.NewField("traceId", nil, []string{}),
|
||||
)
|
||||
|
||||
fields[2].Config = &data.FieldConfig{
|
||||
DisplayName: "Trace ID",
|
||||
}
|
||||
|
||||
// Add fields for each label to be able to link exemplars to the series
|
||||
for _, label := range series.Labels {
|
||||
fields = append(fields, data.NewField(label.GetKey(), nil, []string{}))
|
||||
}
|
||||
|
||||
frame := &data.Frame{
|
||||
RefID: name,
|
||||
Name: "exemplar",
|
||||
Fields: fields,
|
||||
Meta: &data.FrameMeta{
|
||||
DataTopic: data.DataTopicAnnotations,
|
||||
},
|
||||
}
|
||||
|
||||
for _, exemplar := range exemplars {
|
||||
_, labels := transformLabelsAndGetName(exemplar.GetLabels())
|
||||
traceId := labels["trace:id"]
|
||||
if traceId != "" {
|
||||
traceId = strings.ReplaceAll(traceId, "\"", "")
|
||||
}
|
||||
|
||||
// Skip exemplars with invalid data
|
||||
if exemplar.GetValue() == 0 || exemplar.GetTimestampMs() <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Add basic data
|
||||
frame.AppendRow(time.UnixMilli(exemplar.GetTimestampMs()), exemplar.GetValue(), traceId)
|
||||
|
||||
// Add labels
|
||||
for _, label := range series.Labels {
|
||||
field, _ := frame.FieldByName(label.GetKey())
|
||||
if field != nil {
|
||||
val, _ := metricsValueToString(label.GetValue())
|
||||
field.Append(val)
|
||||
}
|
||||
}
|
||||
}
|
||||
return frame
|
||||
}
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
package traceql
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/tempo/pkg/tempopb"
|
||||
v1 "github.com/grafana/tempo/pkg/tempopb/common/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTransformExemplarToFrame_EmptyExemplars(t *testing.T) {
|
||||
frame := transformExemplarToFrame("test", &tempopb.TimeSeries{
|
||||
Labels: nil,
|
||||
Samples: nil,
|
||||
Exemplars: make([]tempopb.Exemplar, 0),
|
||||
})
|
||||
assert.NotNil(t, frame)
|
||||
assert.Equal(t, "test", frame.RefID)
|
||||
assert.Equal(t, "exemplar", frame.Name)
|
||||
assert.Len(t, frame.Fields, 3)
|
||||
assert.Empty(t, frame.Fields[0].Len())
|
||||
assert.Empty(t, frame.Fields[1].Len())
|
||||
assert.Empty(t, frame.Fields[2].Len())
|
||||
}
|
||||
|
||||
func TestTransformExemplarToFrame_SingleExemplar(t *testing.T) {
|
||||
exemplars := []tempopb.Exemplar{
|
||||
{
|
||||
TimestampMs: 1638316800000,
|
||||
Value: 1.23,
|
||||
Labels: []v1.KeyValue{
|
||||
{Key: "trace:id", Value: &v1.AnyValue{Value: &v1.AnyValue_StringValue{StringValue: "trace-123"}}},
|
||||
},
|
||||
},
|
||||
}
|
||||
frame := transformExemplarToFrame("test", &tempopb.TimeSeries{
|
||||
Labels: nil,
|
||||
Samples: nil,
|
||||
Exemplars: exemplars,
|
||||
})
|
||||
assert.NotNil(t, frame)
|
||||
assert.Equal(t, "test", frame.RefID)
|
||||
assert.Equal(t, "exemplar", frame.Name)
|
||||
assert.Len(t, frame.Fields, 3)
|
||||
assert.Equal(t, time.UnixMilli(1638316800000), frame.Fields[0].At(0))
|
||||
assert.Equal(t, 1.23, frame.Fields[1].At(0))
|
||||
assert.Equal(t, "trace-123", frame.Fields[2].At(0))
|
||||
}
|
||||
|
||||
func TestTransformExemplarToFrame_SingleExemplarHistogram(t *testing.T) {
|
||||
exemplars := []tempopb.Exemplar{
|
||||
{
|
||||
TimestampMs: 1638316800000,
|
||||
Value: 1.23,
|
||||
Labels: []v1.KeyValue{
|
||||
{Key: "trace:id", Value: &v1.AnyValue{Value: &v1.AnyValue_StringValue{StringValue: "trace-123"}}},
|
||||
{Key: "__bucket", Value: &v1.AnyValue{Value: &v1.AnyValue_DoubleValue{DoubleValue: 1.23}}},
|
||||
},
|
||||
},
|
||||
}
|
||||
frame := transformExemplarToFrame("test", &tempopb.TimeSeries{
|
||||
Labels: []v1.KeyValue{
|
||||
{Key: "__bucket", Value: &v1.AnyValue{Value: &v1.AnyValue_DoubleValue{DoubleValue: 1.23}}},
|
||||
},
|
||||
Samples: nil,
|
||||
Exemplars: exemplars,
|
||||
})
|
||||
assert.NotNil(t, frame)
|
||||
assert.Equal(t, "test", frame.RefID)
|
||||
assert.Equal(t, "exemplar", frame.Name)
|
||||
assert.Len(t, frame.Fields, 4)
|
||||
assert.Equal(t, time.UnixMilli(1638316800000), frame.Fields[0].At(0))
|
||||
assert.Equal(t, 1.23, frame.Fields[1].At(0))
|
||||
assert.Equal(t, "trace-123", frame.Fields[2].At(0))
|
||||
}
|
||||
|
||||
func TestTransformExemplarToFrame_MultipleExemplars(t *testing.T) {
|
||||
exemplars := []tempopb.Exemplar{
|
||||
{
|
||||
TimestampMs: 1638316800000,
|
||||
Value: 1.23,
|
||||
Labels: []v1.KeyValue{
|
||||
{Key: "trace:id", Value: &v1.AnyValue{Value: &v1.AnyValue_StringValue{StringValue: "trace-123"}}},
|
||||
},
|
||||
},
|
||||
{
|
||||
TimestampMs: 1638316801000,
|
||||
Value: 4.56,
|
||||
Labels: []v1.KeyValue{
|
||||
{Key: "trace:id", Value: &v1.AnyValue{Value: &v1.AnyValue_StringValue{StringValue: "trace-456"}}},
|
||||
},
|
||||
},
|
||||
}
|
||||
frame := transformExemplarToFrame("test", &tempopb.TimeSeries{
|
||||
Labels: nil,
|
||||
Samples: nil,
|
||||
Exemplars: exemplars,
|
||||
})
|
||||
assert.NotNil(t, frame)
|
||||
assert.Equal(t, "test", frame.RefID)
|
||||
assert.Equal(t, "exemplar", frame.Name)
|
||||
assert.Len(t, frame.Fields, 3)
|
||||
assert.Equal(t, time.UnixMilli(1638316800000), frame.Fields[0].At(0))
|
||||
assert.Equal(t, 1.23, frame.Fields[1].At(0))
|
||||
assert.Equal(t, "trace-123", frame.Fields[2].At(0))
|
||||
assert.Equal(t, time.UnixMilli(1638316801000), frame.Fields[0].At(1))
|
||||
assert.Equal(t, 4.56, frame.Fields[1].At(1))
|
||||
assert.Equal(t, "trace-456", frame.Fields[2].At(1))
|
||||
}
|
||||
|
||||
func TestTransformExemplarToFrame_ExemplarWithoutTraceId(t *testing.T) {
|
||||
exemplars := []tempopb.Exemplar{
|
||||
{
|
||||
TimestampMs: 1638316800000,
|
||||
Value: 1.23,
|
||||
Labels: []v1.KeyValue{},
|
||||
},
|
||||
}
|
||||
frame := transformExemplarToFrame("test", &tempopb.TimeSeries{
|
||||
Labels: nil,
|
||||
Samples: nil,
|
||||
Exemplars: exemplars,
|
||||
})
|
||||
assert.NotNil(t, frame)
|
||||
assert.Equal(t, "test", frame.RefID)
|
||||
assert.Equal(t, "exemplar", frame.Name)
|
||||
assert.Len(t, frame.Fields, 3)
|
||||
assert.Equal(t, time.UnixMilli(1638316800000), frame.Fields[0].At(0))
|
||||
assert.Equal(t, 1.23, frame.Fields[1].At(0))
|
||||
assert.Equal(t, "", frame.Fields[2].At(0))
|
||||
}
|
||||
|
|
@ -1,142 +0,0 @@
|
|||
package traceql
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/grafana/tempo/pkg/tempopb"
|
||||
v1 "github.com/grafana/tempo/pkg/tempopb/common/v1"
|
||||
)
|
||||
|
||||
func TransformMetricsResponse(query string, resp tempopb.QueryRangeResponse) []*data.Frame {
|
||||
// prealloc frames
|
||||
frames := make([]*data.Frame, len(resp.Series)) //nolint:prealloc
|
||||
var exemplarFrames []*data.Frame
|
||||
|
||||
for i, series := range resp.Series {
|
||||
name, labels := transformLabelsAndGetName(series.Labels)
|
||||
|
||||
valueField := data.NewField(name, labels, []float64{})
|
||||
valueField.Config = &data.FieldConfig{
|
||||
DisplayName: name,
|
||||
}
|
||||
|
||||
timeField := data.NewField("time", nil, []time.Time{})
|
||||
|
||||
frame := &data.Frame{
|
||||
RefID: name,
|
||||
Name: name,
|
||||
Fields: []*data.Field{
|
||||
timeField,
|
||||
valueField,
|
||||
},
|
||||
Meta: &data.FrameMeta{
|
||||
PreferredVisualization: data.VisTypeGraph,
|
||||
Type: data.FrameTypeTimeSeriesMulti,
|
||||
},
|
||||
}
|
||||
|
||||
isHistogram := isHistogramQuery(query)
|
||||
if isHistogram {
|
||||
frame.Meta.PreferredVisualizationPluginID = "heatmap"
|
||||
}
|
||||
|
||||
for _, sample := range series.Samples {
|
||||
frame.AppendRow(time.UnixMilli(sample.GetTimestampMs()), sample.GetValue())
|
||||
}
|
||||
|
||||
if len(series.Exemplars) > 0 {
|
||||
exFrame := transformExemplarToFrame(name, series)
|
||||
exemplarFrames = append(exemplarFrames, exFrame)
|
||||
}
|
||||
|
||||
frames[i] = frame
|
||||
}
|
||||
return append(frames, exemplarFrames...)
|
||||
}
|
||||
|
||||
func TransformInstantMetricsResponse(resp tempopb.QueryInstantResponse) []*data.Frame {
|
||||
frames := make([]*data.Frame, len(resp.Series))
|
||||
|
||||
for i, series := range resp.Series {
|
||||
name, labels := transformLabelsAndGetName(series.Labels)
|
||||
|
||||
timeField := data.NewField("time", nil, []time.Time{})
|
||||
valueField := data.NewField("value", labels, []float64{})
|
||||
valueField.Config = &data.FieldConfig{
|
||||
DisplayName: name,
|
||||
}
|
||||
|
||||
frame := &data.Frame{
|
||||
RefID: name,
|
||||
Name: name,
|
||||
Fields: append([]*data.Field{timeField}, valueField),
|
||||
Meta: &data.FrameMeta{
|
||||
PreferredVisualization: data.VisTypeTable,
|
||||
},
|
||||
}
|
||||
|
||||
row := append([]interface{}{time.Now()}, series.GetValue())
|
||||
frame.AppendRow(row...)
|
||||
|
||||
frames[i] = frame
|
||||
}
|
||||
return frames
|
||||
}
|
||||
|
||||
func metricsValueToString(value *v1.AnyValue) (string, string) {
|
||||
switch value.GetValue().(type) {
|
||||
case *v1.AnyValue_DoubleValue:
|
||||
res := strconv.FormatFloat(value.GetDoubleValue(), 'f', -1, 64)
|
||||
return res, res
|
||||
case *v1.AnyValue_IntValue:
|
||||
res := strconv.FormatInt(value.GetIntValue(), 10)
|
||||
return res, res
|
||||
case *v1.AnyValue_StringValue:
|
||||
// return the value wrapped in quotes since it's accurate and "1" is different from 1
|
||||
// the second value is returned without quotes for display purposes
|
||||
return fmt.Sprintf("\"%s\"", value.GetStringValue()), value.GetStringValue()
|
||||
case *v1.AnyValue_BoolValue:
|
||||
res := strconv.FormatBool(value.GetBoolValue())
|
||||
return res, res
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func transformLabelsAndGetName(seriesLabels []v1.KeyValue) (string, data.Labels) {
|
||||
labels := make(data.Labels)
|
||||
for _, label := range seriesLabels {
|
||||
labels[label.GetKey()], _ = metricsValueToString(label.GetValue())
|
||||
}
|
||||
name := ""
|
||||
if len(seriesLabels) > 0 {
|
||||
if len(seriesLabels) == 1 {
|
||||
_, name = metricsValueToString(seriesLabels[0].GetValue())
|
||||
} else {
|
||||
keys := make([]string, 0, len(labels))
|
||||
|
||||
for k := range labels {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
labelStrings := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
labelStrings = append(labelStrings, fmt.Sprintf("%s=%s", key, labels[key]))
|
||||
}
|
||||
|
||||
name = fmt.Sprintf("{%s}", strings.Join(labelStrings, ", "))
|
||||
}
|
||||
}
|
||||
return name, labels
|
||||
}
|
||||
|
||||
func isHistogramQuery(query string) bool {
|
||||
match, _ := regexp.MatchString("\\|\\s*(histogram_over_time)\\s*\\(", query)
|
||||
return match
|
||||
}
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
package traceql
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/grafana/tempo/pkg/tempopb"
|
||||
v1 "github.com/grafana/tempo/pkg/tempopb/common/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTransformMetricsResponse_EmptyResponse(t *testing.T) {
|
||||
resp := tempopb.QueryRangeResponse{}
|
||||
frames := TransformMetricsResponse("", resp)
|
||||
assert.Empty(t, frames)
|
||||
}
|
||||
|
||||
func TestTransformMetricsResponse_SingleSeriesSingleLabel(t *testing.T) {
|
||||
resp := tempopb.QueryRangeResponse{
|
||||
Series: []*tempopb.TimeSeries{
|
||||
{
|
||||
Labels: []v1.KeyValue{
|
||||
{Key: "label1", Value: &v1.AnyValue{Value: &v1.AnyValue_StringValue{StringValue: "value1"}}},
|
||||
},
|
||||
Samples: []tempopb.Sample{
|
||||
{TimestampMs: 1638316800000, Value: 1.23},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
frames := TransformMetricsResponse("", resp)
|
||||
assert.Len(t, frames, 1)
|
||||
assert.Equal(t, "value1", frames[0].RefID)
|
||||
assert.Equal(t, "value1", frames[0].Name)
|
||||
assert.Len(t, frames[0].Fields, 2)
|
||||
assert.Equal(t, "time", frames[0].Fields[0].Name)
|
||||
assert.Equal(t, "value1", frames[0].Fields[1].Name)
|
||||
assert.Equal(t, data.VisTypeGraph, frames[0].Meta.PreferredVisualization)
|
||||
assert.Equal(t, time.UnixMilli(1638316800000), frames[0].Fields[0].At(0))
|
||||
assert.Equal(t, 1.23, frames[0].Fields[1].At(0))
|
||||
}
|
||||
|
||||
func TestTransformMetricsResponse_SingleSeriesMultipleLabels(t *testing.T) {
|
||||
resp := tempopb.QueryRangeResponse{
|
||||
Series: []*tempopb.TimeSeries{
|
||||
{
|
||||
Labels: []v1.KeyValue{
|
||||
{Key: "label1", Value: &v1.AnyValue{Value: &v1.AnyValue_StringValue{StringValue: "value1"}}},
|
||||
{Key: "label2", Value: &v1.AnyValue{Value: &v1.AnyValue_IntValue{IntValue: 123}}},
|
||||
{Key: "label3", Value: &v1.AnyValue{Value: &v1.AnyValue_DoubleValue{DoubleValue: 123.456}}},
|
||||
{Key: "label4", Value: &v1.AnyValue{Value: &v1.AnyValue_BoolValue{BoolValue: true}}},
|
||||
},
|
||||
Samples: []tempopb.Sample{
|
||||
{TimestampMs: 1638316800000, Value: 1.23},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
frames := TransformMetricsResponse("", resp)
|
||||
assert.Len(t, frames, 1)
|
||||
assert.Equal(t, "{label1=\"value1\", label2=123, label3=123.456, label4=true}", frames[0].RefID)
|
||||
assert.Equal(t, "{label1=\"value1\", label2=123, label3=123.456, label4=true}", frames[0].Name)
|
||||
assert.Len(t, frames[0].Fields, 2)
|
||||
assert.Equal(t, "time", frames[0].Fields[0].Name)
|
||||
assert.Equal(t, "{label1=\"value1\", label2=123, label3=123.456, label4=true}", frames[0].Fields[1].Name)
|
||||
assert.Equal(t, data.VisTypeGraph, frames[0].Meta.PreferredVisualization)
|
||||
assert.Equal(t, time.UnixMilli(1638316800000), frames[0].Fields[0].At(0))
|
||||
assert.Equal(t, 1.23, frames[0].Fields[1].At(0))
|
||||
}
|
||||
|
||||
func TestTransformMetricsResponse_MultipleSeries(t *testing.T) {
|
||||
resp := tempopb.QueryRangeResponse{
|
||||
Series: []*tempopb.TimeSeries{
|
||||
{
|
||||
Labels: []v1.KeyValue{
|
||||
{Key: "label1", Value: &v1.AnyValue{Value: &v1.AnyValue_StringValue{StringValue: "value1"}}},
|
||||
},
|
||||
Samples: []tempopb.Sample{
|
||||
{TimestampMs: 1638316800000, Value: 1.23},
|
||||
},
|
||||
},
|
||||
{
|
||||
Labels: []v1.KeyValue{
|
||||
{Key: "label2", Value: &v1.AnyValue{Value: &v1.AnyValue_IntValue{IntValue: 456}}},
|
||||
},
|
||||
Samples: []tempopb.Sample{
|
||||
{TimestampMs: 1638316800000, Value: 4.56},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
frames := TransformMetricsResponse("", resp)
|
||||
assert.Len(t, frames, 2)
|
||||
assert.Equal(t, "value1", frames[0].RefID)
|
||||
assert.Equal(t, "value1", frames[0].Name)
|
||||
assert.Len(t, frames[0].Fields, 2)
|
||||
assert.Equal(t, "time", frames[0].Fields[0].Name)
|
||||
assert.Equal(t, "value1", frames[0].Fields[1].Name)
|
||||
assert.Equal(t, data.VisTypeGraph, frames[0].Meta.PreferredVisualization)
|
||||
assert.Equal(t, time.UnixMilli(1638316800000), frames[0].Fields[0].At(0))
|
||||
assert.Equal(t, 1.23, frames[0].Fields[1].At(0))
|
||||
|
||||
assert.Equal(t, "456", frames[1].RefID)
|
||||
assert.Equal(t, "456", frames[1].Name)
|
||||
assert.Len(t, frames[1].Fields, 2)
|
||||
assert.Equal(t, "time", frames[1].Fields[0].Name)
|
||||
assert.Equal(t, "456", frames[1].Fields[1].Name)
|
||||
assert.Equal(t, data.VisTypeGraph, frames[1].Meta.PreferredVisualization)
|
||||
assert.Equal(t, time.UnixMilli(1638316800000), frames[1].Fields[0].At(0))
|
||||
assert.Equal(t, 4.56, frames[1].Fields[1].At(0))
|
||||
}
|
||||
|
||||
func TestTransformInstantMetricsResponse(t *testing.T) {
|
||||
resp := tempopb.QueryInstantResponse{
|
||||
Series: []*tempopb.InstantSeries{
|
||||
{
|
||||
Value: 123.45,
|
||||
Labels: []v1.KeyValue{
|
||||
{Key: "label1", Value: &v1.AnyValue{Value: &v1.AnyValue_StringValue{StringValue: "value1"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
frames := TransformInstantMetricsResponse(resp)
|
||||
|
||||
assert.Len(t, frames, 1)
|
||||
frame := frames[0]
|
||||
|
||||
assert.Len(t, frame.Fields, 2)
|
||||
|
||||
timeField := frame.Fields[0]
|
||||
assert.Equal(t, "time", timeField.Name)
|
||||
assert.Equal(t, 1, timeField.Len())
|
||||
assert.IsType(t, time.Time{}, timeField.At(0))
|
||||
|
||||
valueField := frame.Fields[1]
|
||||
assert.Equal(t, "value", valueField.Name)
|
||||
assert.Equal(t, 1, valueField.Len())
|
||||
assert.IsType(t, 0.0, valueField.At(0))
|
||||
assert.Equal(t, 123.45, valueField.At(0).(float64))
|
||||
}
|
||||
|
|
@ -1,224 +0,0 @@
|
|||
package tempo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
//nolint:all
|
||||
"github.com/golang/protobuf/jsonpb"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/tracing"
|
||||
"github.com/grafana/grafana/pkg/tsdb/tempo/kinds/dataquery"
|
||||
"github.com/grafana/grafana/pkg/tsdb/tempo/traceql"
|
||||
"github.com/grafana/tempo/pkg/tempopb"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
func (s *Service) runTraceQlQuery(ctx context.Context, pCtx backend.PluginContext, backendQuery backend.DataQuery) (*backend.DataResponse, error) {
|
||||
ctxLogger := s.logger.FromContext(ctx)
|
||||
ctxLogger.Debug("Running TraceQL query", "function", logEntrypoint())
|
||||
|
||||
tempoQuery := &dataquery.TempoQuery{}
|
||||
err := json.Unmarshal(backendQuery.JSON, tempoQuery)
|
||||
if err != nil {
|
||||
ctxLogger.Error("Failed to unmarshall Tempo query model", "error", err, "function", logEntrypoint())
|
||||
return nil, backend.DownstreamErrorf("failed to unmarshall Tempo query model: %w", err)
|
||||
}
|
||||
|
||||
if tempoQuery.Query == nil || *tempoQuery.Query == "" {
|
||||
return nil, backend.DownstreamErrorf("query is required")
|
||||
}
|
||||
|
||||
if isMetricsQuery(*tempoQuery.Query) {
|
||||
return s.runTraceQlQueryMetrics(ctx, pCtx, backendQuery, tempoQuery)
|
||||
}
|
||||
|
||||
return s.runTraceQlQuerySearch(ctx, pCtx, backendQuery)
|
||||
}
|
||||
|
||||
func (s *Service) runTraceQlQuerySearch(ctx context.Context, pCtx backend.PluginContext, query backend.DataQuery) (*backend.DataResponse, error) {
|
||||
return s.Search(ctx, pCtx, query)
|
||||
}
|
||||
|
||||
func (s *Service) runTraceQlQueryMetrics(ctx context.Context, pCtx backend.PluginContext, backendQuery backend.DataQuery, tempoQuery *dataquery.TempoQuery) (*backend.DataResponse, error) {
|
||||
ctxLogger := s.logger.FromContext(ctx)
|
||||
ctxLogger.Debug("Running TraceQL Metrics query", "function", logEntrypoint())
|
||||
|
||||
ctx, span := tracing.DefaultTracer().Start(ctx, "datasource.tempo.runTraceQLQuery", trace.WithAttributes(
|
||||
attribute.String("queryType", backendQuery.QueryType),
|
||||
))
|
||||
defer span.End()
|
||||
|
||||
result := &backend.DataResponse{}
|
||||
|
||||
dsInfo, err := s.getDSInfo(ctx, pCtx)
|
||||
if err != nil {
|
||||
ctxLogger.Error("Failed to get datasource information", "error", err, "function", logEntrypoint())
|
||||
return nil, backend.DownstreamErrorf("failed to get datasource information: %w", err)
|
||||
}
|
||||
|
||||
if tempoQuery.Query == nil || *tempoQuery.Query == "" {
|
||||
err := fmt.Errorf("query is required")
|
||||
ctxLogger.Error("Failed to validate model query", "error", err, "function", logEntrypoint())
|
||||
return result, backend.DownstreamErrorf("failed to validate model query: %w", err)
|
||||
}
|
||||
|
||||
resp, responseBody, err := s.performMetricsQuery(ctx, dsInfo, tempoQuery, backendQuery, span)
|
||||
defer func() {
|
||||
if resp != nil && resp.Body != nil {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
ctxLogger.Error("Failed to close response body", "error", err, "function", logEntrypoint())
|
||||
}
|
||||
}
|
||||
}()
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
ctxLogger.Error("Failed to execute TraceQL query", "error", err, "function", logEntrypoint())
|
||||
err := fmt.Errorf("failed to execute TraceQL query: %s Status: %s Body: %s", *tempoQuery.Query, resp.Status, string(responseBody))
|
||||
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
|
||||
if backend.ErrorSourceFromHTTPStatus(resp.StatusCode) == backend.ErrorSourceDownstream {
|
||||
err = backend.DownstreamError(err)
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if isInstantQuery(tempoQuery.MetricsQueryType) {
|
||||
var queryResponse tempopb.QueryInstantResponse
|
||||
err = jsonpb.Unmarshal(bytes.NewReader(responseBody), &queryResponse)
|
||||
|
||||
if res, err := handleConversionError(ctxLogger, span, err); err != nil {
|
||||
return res, err
|
||||
}
|
||||
|
||||
frames := traceql.TransformInstantMetricsResponse(queryResponse)
|
||||
result.Frames = frames
|
||||
} else {
|
||||
var queryResponse tempopb.QueryRangeResponse
|
||||
// Temporarily allow extra fields until proto changes are available (https://github.com/grafana/tempo/pull/4525)
|
||||
unmarshaler := jsonpb.Unmarshaler{
|
||||
AllowUnknownFields: true,
|
||||
}
|
||||
|
||||
err = unmarshaler.Unmarshal(bytes.NewReader(responseBody), &queryResponse)
|
||||
|
||||
if res, err := handleConversionError(ctxLogger, span, err); err != nil {
|
||||
return res, err
|
||||
}
|
||||
|
||||
frames := traceql.TransformMetricsResponse(*tempoQuery.Query, queryResponse)
|
||||
result.Frames = frames
|
||||
}
|
||||
|
||||
ctxLogger.Debug("Successfully performed TraceQL query", "function", logEntrypoint())
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func handleConversionError(ctxLogger log.Logger, span trace.Span, err error) (*backend.DataResponse, error) {
|
||||
if err != nil {
|
||||
ctxLogger.Error("Failed to convert response to type", "error", err, "function", logEntrypoint())
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return &backend.DataResponse{}, fmt.Errorf("failed to convert response to type: %w", err)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *Service) performMetricsQuery(ctx context.Context, dsInfo *DatasourceInfo, model *dataquery.TempoQuery, query backend.DataQuery, span trace.Span) (*http.Response, []byte, error) {
|
||||
ctxLogger := s.logger.FromContext(ctx)
|
||||
request, err := s.createMetricsQuery(ctx, dsInfo, model, query.TimeRange.From.Unix(), query.TimeRange.To.Unix())
|
||||
if err != nil {
|
||||
ctxLogger.Error("Failed to create request", "error", err, "function", logEntrypoint())
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return nil, nil, backend.DownstreamErrorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := dsInfo.HTTPClient.Do(request)
|
||||
if err != nil {
|
||||
ctxLogger.Error("Failed to send request to Tempo", "error", err, "function", logEntrypoint())
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
if backend.IsDownstreamHTTPError(err) {
|
||||
return nil, nil, backend.DownstreamError(err)
|
||||
}
|
||||
return nil, nil, fmt.Errorf("failed to send request to Tempo: %w", err)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
ctxLogger.Error("Failed to read response body", "error", err, "function", logEntrypoint())
|
||||
return nil, nil, err
|
||||
}
|
||||
return resp, body, nil
|
||||
}
|
||||
|
||||
func (s *Service) createMetricsQuery(ctx context.Context, dsInfo *DatasourceInfo, query *dataquery.TempoQuery, start int64, end int64) (*http.Request, error) {
|
||||
ctxLogger := s.logger.FromContext(ctx)
|
||||
|
||||
queryType := "query_range"
|
||||
if isInstantQuery(query.MetricsQueryType) {
|
||||
queryType = "query"
|
||||
}
|
||||
|
||||
rawUrl := fmt.Sprintf("%s/api/metrics/%s", dsInfo.URL, queryType)
|
||||
searchUrl, err := url.Parse(rawUrl)
|
||||
if err != nil {
|
||||
ctxLogger.Error("Failed to parse URL", "url", rawUrl, "error", err, "function", logEntrypoint())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
q := searchUrl.Query()
|
||||
q.Set("q", *query.Query)
|
||||
if start > 0 {
|
||||
q.Set("start", strconv.FormatInt(start, 10))
|
||||
}
|
||||
if end > 0 {
|
||||
q.Set("end", strconv.FormatInt(end, 10))
|
||||
}
|
||||
if query.Step != nil {
|
||||
q.Set("step", *query.Step)
|
||||
}
|
||||
if query.Exemplars != nil {
|
||||
q.Set("exemplars", strconv.FormatInt(*query.Exemplars, 10))
|
||||
}
|
||||
|
||||
searchUrl.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", searchUrl.String(), nil)
|
||||
if err != nil {
|
||||
ctxLogger.Error("Failed to create request", "error", err, "function", logEntrypoint())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func isInstantQuery(metricQueryType *dataquery.MetricsQueryType) bool {
|
||||
if metricQueryType == nil {
|
||||
return false
|
||||
}
|
||||
return *metricQueryType == dataquery.MetricsQueryTypeInstant
|
||||
}
|
||||
|
||||
func isMetricsQuery(query string) bool {
|
||||
match, _ := regexp.MatchString("\\|\\s*(rate|count_over_time|avg_over_time|sum_over_time|max_over_time|min_over_time|quantile_over_time|histogram_over_time|compare)\\s*\\(", query)
|
||||
return match
|
||||
}
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
package tempo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana/pkg/tsdb/tempo/kinds/dataquery"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCreateMetricsQuery_Success(t *testing.T) {
|
||||
logger := backend.NewLoggerWith("logger", "tsdb.tempo.test")
|
||||
service := &Service{
|
||||
logger: logger,
|
||||
}
|
||||
dsInfo := &DatasourceInfo{
|
||||
URL: "http://tempo:3100",
|
||||
}
|
||||
queryVal := "{attribute=\"value\"}"
|
||||
stepVal := "14"
|
||||
exemplarVal := int64(123)
|
||||
query := &dataquery.TempoQuery{
|
||||
Query: &queryVal,
|
||||
Step: &stepVal,
|
||||
Exemplars: &exemplarVal,
|
||||
}
|
||||
start := int64(1625097600)
|
||||
end := int64(1625184000)
|
||||
|
||||
req, err := service.createMetricsQuery(context.Background(), dsInfo, query, start, end)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, req)
|
||||
assert.Equal(t, "http://tempo:3100/api/metrics/query_range?end=1625184000&exemplars=123&q=%7Battribute%3D%22value%22%7D&start=1625097600&step=14", req.URL.String())
|
||||
assert.Equal(t, "application/json", req.Header.Get("Accept"))
|
||||
}
|
||||
|
||||
func TestCreateMetricsQuery_OnlyQuery(t *testing.T) {
|
||||
logger := backend.NewLoggerWith("logger", "tsdb.tempo.test")
|
||||
service := &Service{
|
||||
logger: logger,
|
||||
}
|
||||
dsInfo := &DatasourceInfo{
|
||||
URL: "http://tempo:3100",
|
||||
}
|
||||
queryVal := "{attribute=\"value\"}"
|
||||
query := &dataquery.TempoQuery{
|
||||
Query: &queryVal,
|
||||
}
|
||||
|
||||
req, err := service.createMetricsQuery(context.Background(), dsInfo, query, 0, 0)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, req)
|
||||
assert.Equal(t, "http://tempo:3100/api/metrics/query_range?q=%7Battribute%3D%22value%22%7D", req.URL.String())
|
||||
assert.Equal(t, "application/json", req.Header.Get("Accept"))
|
||||
}
|
||||
|
||||
func TestCreateMetricsQuery_URLParseError(t *testing.T) {
|
||||
logger := backend.NewLoggerWith("logger", "tsdb.tempo.test")
|
||||
service := &Service{
|
||||
logger: logger,
|
||||
}
|
||||
dsInfo := &DatasourceInfo{
|
||||
URL: "http://[::1]:namedport",
|
||||
}
|
||||
queryVal := "{attribute=\"value\"}"
|
||||
query := &dataquery.TempoQuery{
|
||||
Query: &queryVal,
|
||||
}
|
||||
start := int64(1625097600)
|
||||
end := int64(1625184000)
|
||||
|
||||
req, err := service.createMetricsQuery(context.Background(), dsInfo, query, start, end)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, req)
|
||||
}
|
||||
|
||||
func TestRunTraceQlQuery_NilQuery_ReturnsError(t *testing.T) {
|
||||
service := &Service{logger: backend.NewLoggerWith("logger", "tsdb.tempo.test")}
|
||||
query := backend.DataQuery{JSON: []byte(`{}`)}
|
||||
|
||||
res, err := service.runTraceQlQuery(context.Background(), backend.PluginContext{}, query)
|
||||
|
||||
assert.Nil(t, res)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "query is required")
|
||||
}
|
||||
|
||||
func TestRunTraceQlQuery_EmptyQuery_ReturnsError(t *testing.T) {
|
||||
service := &Service{logger: backend.NewLoggerWith("logger", "tsdb.tempo.test")}
|
||||
query := backend.DataQuery{JSON: []byte(`{"query": ""}`)}
|
||||
|
||||
res, err := service.runTraceQlQuery(context.Background(), backend.PluginContext{}, query)
|
||||
|
||||
assert.Nil(t, res)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "query is required")
|
||||
}
|
||||
|
||||
func TestEmptyQueryString_ReturnsFalse(t *testing.T) {
|
||||
result := isMetricsQuery("")
|
||||
assert.False(t, result)
|
||||
}
|
||||
|
||||
func TestQueryWithoutMetricsFunction_ReturnsFalse(t *testing.T) {
|
||||
result := isMetricsQuery("{.some = \"random query\"} && {} >> {}")
|
||||
assert.False(t, result)
|
||||
}
|
||||
|
||||
func TestQueryWithRateFunction_ReturnsTrue(t *testing.T) {
|
||||
result := isMetricsQuery("{} | rate(foo)")
|
||||
assert.True(t, result)
|
||||
}
|
||||
|
||||
func TestQueryWithAvgOverTimeFunction_ReturnsTrue(t *testing.T) {
|
||||
result := isMetricsQuery("{} | avg_over_time(foo)")
|
||||
assert.True(t, result)
|
||||
}
|
||||
|
||||
func TestQueryWithSumOverTimeFunction_ReturnsTrue(t *testing.T) {
|
||||
result := isMetricsQuery("{} | sum_over_time(foo)")
|
||||
assert.True(t, result)
|
||||
}
|
||||
|
||||
func TestQueryWithCountOverTimeFunction_ReturnsTrue(t *testing.T) {
|
||||
result := isMetricsQuery("{} | count_over_time(foo)")
|
||||
assert.True(t, result)
|
||||
}
|
||||
|
||||
func TestQueryWithMaxOverTimeFunction_ReturnsTrue(t *testing.T) {
|
||||
result := isMetricsQuery("{} | max_over_time(foo)")
|
||||
assert.True(t, result)
|
||||
}
|
||||
|
||||
func TestQueryWithMinOverTimeFunction_ReturnsTrue(t *testing.T) {
|
||||
result := isMetricsQuery("{} | min_over_time(foo)")
|
||||
assert.True(t, result)
|
||||
}
|
||||
|
||||
func TestQueryWithQuantileOverTimeFunction_ReturnsTrue(t *testing.T) {
|
||||
result := isMetricsQuery("{} | quantile_over_time(foo)")
|
||||
assert.True(t, result)
|
||||
}
|
||||
|
||||
func TestQueryWithHistogramOverTimeFunction_ReturnsTrue(t *testing.T) {
|
||||
result := isMetricsQuery("{} | histogram_over_time(foo)")
|
||||
assert.True(t, result)
|
||||
}
|
||||
|
||||
func TestQueryWithCompareFunction_ReturnsTrue(t *testing.T) {
|
||||
result := isMetricsQuery("{} | compare(foo)")
|
||||
assert.True(t, result)
|
||||
}
|
||||
|
||||
func TestQueryWithMultipleFunctions_ReturnsTrue(t *testing.T) {
|
||||
result := isMetricsQuery("{} | rate(foo) | avg_over_time(bar)")
|
||||
assert.True(t, result)
|
||||
}
|
||||
|
||||
func TestQueryWithInvalidFunction_ReturnsFalse(t *testing.T) {
|
||||
result := isMetricsQuery("{} | invalid_function(foo)")
|
||||
assert.False(t, result)
|
||||
}
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
package stream_utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/grpc/metadata"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/config"
|
||||
)
|
||||
|
||||
const (
|
||||
TeamHttpHeaderKeyLower = "x-prom-label-policy"
|
||||
TeamHttpHeaderKeyCamel = "X-Prom-Label-Policy"
|
||||
)
|
||||
|
||||
// returns HTTP header key/value pairs for the outgoing Tempo streaming gRPC call.
|
||||
// It always includes datasource HTTP client option headers. When streamingForwardTeamHeadersTempo is enabled, it also merges
|
||||
// outgoing gRPC metadata: X-Prom-Label-Policy is set from the x-prom-label-policy metadata values, and every other
|
||||
// metadata entry is copied under its existing key. Team headers whose key collides case-insensitively with a
|
||||
// datasource-configured header are dropped — HTTP/gRPC header names are case-insensitive on the wire, so keeping both
|
||||
// would let Go's randomised map iteration deliver the wrong value (e.g. x-scope-orgid).
|
||||
func GetHeadersFromIncomingContext(ctx context.Context, logger log.Logger) (map[string]string, error) {
|
||||
plugin := backend.PluginConfigFromContext(ctx)
|
||||
headers, err := getClientOptionsHeaders(ctx, plugin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reserved := make(map[string]struct{}, len(headers))
|
||||
for k := range headers {
|
||||
reserved[strings.ToLower(k)] = struct{}{}
|
||||
}
|
||||
|
||||
// fetch team headers from outgoing context.
|
||||
teamHeaders := getTeamHeaders(ctx, logger, plugin)
|
||||
for k, v := range teamHeaders {
|
||||
if _, conflict := reserved[strings.ToLower(k)]; conflict {
|
||||
if plugin.DataSourceInstanceSettings != nil {
|
||||
logger.Debug("Skipping team header that conflicts with datasource header", "header", k, "datasource_uid", plugin.DataSourceInstanceSettings.UID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
headers[k] = v
|
||||
}
|
||||
return headers, nil
|
||||
}
|
||||
|
||||
// maps outgoing gRPC metadata to HTTP-style header strings (comma-joined values per key).
|
||||
// x-prom-label-policy is exposed as X-Prom-Label-Policy.
|
||||
func getTeamHeaders(ctx context.Context, logger log.Logger, plugin backend.PluginContext) map[string]string {
|
||||
cfg := config.GrafanaConfigFromContext(ctx)
|
||||
if cfg == nil || !cfg.FeatureToggles().IsEnabled("streamingForwardTeamHeadersTempo") {
|
||||
return nil
|
||||
}
|
||||
|
||||
md, ok := metadata.FromOutgoingContext(ctx)
|
||||
if !ok {
|
||||
// if no metadata was found in the outgoing context, try to get it from incoming context
|
||||
md, ok = metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
if plugin.DataSourceInstanceSettings != nil {
|
||||
logger.Debug("No outgoing gRPC metadata for team header forwarding", "datasource_uid", plugin.DataSourceInstanceSettings.UID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
headers := map[string]string{}
|
||||
for headerKey, headerVals := range md {
|
||||
joined := strings.Join(headerVals, ",")
|
||||
if headerKey == TeamHttpHeaderKeyLower {
|
||||
headers[TeamHttpHeaderKeyCamel] = joined
|
||||
continue
|
||||
}
|
||||
headers[headerKey] = joined
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
func getClientOptionsHeaders(ctx context.Context, plugin backend.PluginContext) (map[string]string, error) {
|
||||
headers := map[string]string{}
|
||||
opts, err := plugin.DataSourceInstanceSettings.HTTPClientOptions(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get HTTP client options: %w", err)
|
||||
}
|
||||
|
||||
for name, values := range opts.Header {
|
||||
joined := strings.Join(values, ",")
|
||||
headers[name] = joined
|
||||
}
|
||||
return headers, nil
|
||||
}
|
||||
|
|
@ -1,232 +0,0 @@
|
|||
package stream_utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc/metadata"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/config"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/experimental/featuretoggles"
|
||||
)
|
||||
|
||||
func testLogger() log.Logger {
|
||||
return backend.NewLoggerWith("stream_utils_test")
|
||||
}
|
||||
|
||||
func TestGetTeamHeaders_NoMetadata_ReturnsNil(t *testing.T) {
|
||||
pluginCtx := backend.PluginContext{
|
||||
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{JSONData: []byte(`{}`)},
|
||||
}
|
||||
ctx := backend.WithPluginContext(context.Background(), pluginCtx)
|
||||
ctx = config.WithGrafanaConfig(ctx, config.NewGrafanaCfg(map[string]string{
|
||||
featuretoggles.EnabledFeatures: "streamingForwardTeamHeadersTempo",
|
||||
}))
|
||||
|
||||
assert.Nil(t, getTeamHeaders(ctx, testLogger(), pluginCtx))
|
||||
}
|
||||
|
||||
func TestGetTeamHeaders_FeatureToggleOff_ReturnsNil(t *testing.T) {
|
||||
pluginCtx := backend.PluginContext{
|
||||
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{JSONData: []byte(`{}`)},
|
||||
}
|
||||
ctx := backend.WithPluginContext(context.Background(), pluginCtx)
|
||||
ctx = metadata.AppendToOutgoingContext(ctx,
|
||||
TeamHttpHeaderKeyLower, "policy-a", TeamHttpHeaderKeyLower, "policy-b",
|
||||
"x-custom-forward", "extra",
|
||||
)
|
||||
|
||||
assert.Nil(t, getTeamHeaders(ctx, testLogger(), pluginCtx))
|
||||
}
|
||||
|
||||
func TestGetTeamHeaders_MapsOutgoingMetadataToHeaderStrings(t *testing.T) {
|
||||
pluginCtx := backend.PluginContext{
|
||||
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{JSONData: []byte(`{}`)},
|
||||
}
|
||||
ctx := backend.WithPluginContext(context.Background(), pluginCtx)
|
||||
ctx = config.WithGrafanaConfig(ctx, config.NewGrafanaCfg(map[string]string{
|
||||
featuretoggles.EnabledFeatures: "streamingForwardTeamHeadersTempo",
|
||||
}))
|
||||
ctx = metadata.AppendToOutgoingContext(ctx,
|
||||
TeamHttpHeaderKeyLower, "policy-a,policy-b",
|
||||
"x-custom-forward", "extra",
|
||||
)
|
||||
|
||||
got := getTeamHeaders(ctx, testLogger(), pluginCtx)
|
||||
require.NotNil(t, got)
|
||||
assert.Equal(t, "policy-a,policy-b", got[TeamHttpHeaderKeyCamel])
|
||||
assert.Equal(t, "extra", got["x-custom-forward"])
|
||||
}
|
||||
|
||||
func TestGetTeamHeaders_FallsBackToIncomingMetadata(t *testing.T) {
|
||||
pluginCtx := backend.PluginContext{
|
||||
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{JSONData: []byte(`{}`)},
|
||||
}
|
||||
ctx := backend.WithPluginContext(context.Background(), pluginCtx)
|
||||
ctx = config.WithGrafanaConfig(ctx, config.NewGrafanaCfg(map[string]string{
|
||||
featuretoggles.EnabledFeatures: "streamingForwardTeamHeadersTempo",
|
||||
}))
|
||||
ctx = metadata.NewIncomingContext(ctx, metadata.Pairs(
|
||||
TeamHttpHeaderKeyLower, "policy-a",
|
||||
TeamHttpHeaderKeyLower, "policy-b",
|
||||
"x-custom-forward", "extra",
|
||||
))
|
||||
|
||||
got := getTeamHeaders(ctx, testLogger(), pluginCtx)
|
||||
require.NotNil(t, got)
|
||||
assert.Equal(t, "policy-a,policy-b", got[TeamHttpHeaderKeyCamel])
|
||||
assert.Equal(t, "extra", got["x-custom-forward"])
|
||||
}
|
||||
|
||||
func TestGetHeadersFromIncomingContext_WithoutFeatureFlag_OnlyClientHeaders(t *testing.T) {
|
||||
jsonData := []byte(`{
|
||||
"httpHeaderName1": "X-Client",
|
||||
"httpHeaderName2": "X-Shared"
|
||||
}`)
|
||||
pluginCtx := backend.PluginContext{
|
||||
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{
|
||||
JSONData: jsonData,
|
||||
DecryptedSecureJSONData: map[string]string{
|
||||
"httpHeaderValue1": "client-value",
|
||||
"httpHeaderValue2": "shared-value",
|
||||
},
|
||||
},
|
||||
}
|
||||
ctx := backend.WithPluginContext(context.Background(), pluginCtx)
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, TeamHttpHeaderKeyLower, "should-not-forward")
|
||||
|
||||
headers, err := GetHeadersFromIncomingContext(ctx, testLogger())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "client-value", headers["X-Client"])
|
||||
assert.Equal(t, "shared-value", headers["X-Shared"])
|
||||
_, ok := headers[TeamHttpHeaderKeyCamel]
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestGetHeadersFromIncomingContext_MergesOutgoingMetadata_WhenToggleOn(t *testing.T) {
|
||||
jsonData := []byte(`{
|
||||
"httpHeaderName1": "X-Client",
|
||||
"httpHeaderName2": "X-Client"
|
||||
}`)
|
||||
pluginCtx := backend.PluginContext{
|
||||
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{
|
||||
JSONData: jsonData,
|
||||
BasicAuthEnabled: true,
|
||||
DecryptedSecureJSONData: map[string]string{
|
||||
"httpHeaderValue1": "client-value-a",
|
||||
"httpHeaderValue2": "client-value-b",
|
||||
},
|
||||
},
|
||||
}
|
||||
ctx := backend.WithPluginContext(context.Background(), pluginCtx)
|
||||
ctx = config.WithGrafanaConfig(ctx, config.NewGrafanaCfg(map[string]string{
|
||||
featuretoggles.EnabledFeatures: "streamingForwardTeamHeadersTempo",
|
||||
}))
|
||||
ctx = metadata.AppendToOutgoingContext(ctx,
|
||||
TeamHttpHeaderKeyLower, "policy-a,policy-b",
|
||||
"x-custom-forward", "extra",
|
||||
)
|
||||
|
||||
headers, err := GetHeadersFromIncomingContext(ctx, testLogger())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "policy-a,policy-b", headers[TeamHttpHeaderKeyCamel])
|
||||
assert.Equal(t, "extra", headers["x-custom-forward"])
|
||||
assert.Equal(t, "client-value-a,client-value-b", headers["X-Client"])
|
||||
}
|
||||
|
||||
func TestGetHeadersFromIncomingContext_MergesIncomingMetadata_WhenToggleOn(t *testing.T) {
|
||||
jsonData := []byte(`{
|
||||
"httpHeaderName1": "X-Client",
|
||||
"httpHeaderName2": "X-Client"
|
||||
}`)
|
||||
pluginCtx := backend.PluginContext{
|
||||
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{
|
||||
JSONData: jsonData,
|
||||
BasicAuthEnabled: true,
|
||||
DecryptedSecureJSONData: map[string]string{
|
||||
"httpHeaderValue1": "client-value-a",
|
||||
"httpHeaderValue2": "client-value-b",
|
||||
},
|
||||
},
|
||||
}
|
||||
ctx := backend.WithPluginContext(context.Background(), pluginCtx)
|
||||
ctx = config.WithGrafanaConfig(ctx, config.NewGrafanaCfg(map[string]string{
|
||||
featuretoggles.EnabledFeatures: "streamingForwardTeamHeadersTempo",
|
||||
}))
|
||||
ctx = metadata.NewIncomingContext(ctx, metadata.Pairs(
|
||||
TeamHttpHeaderKeyLower, "policy-a",
|
||||
TeamHttpHeaderKeyLower, "policy-b",
|
||||
"x-custom-forward", "extra",
|
||||
))
|
||||
|
||||
headers, err := GetHeadersFromIncomingContext(ctx, testLogger())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "policy-a,policy-b", headers[TeamHttpHeaderKeyCamel])
|
||||
assert.Equal(t, "extra", headers["x-custom-forward"])
|
||||
assert.Equal(t, "client-value-a,client-value-b", headers["X-Client"])
|
||||
}
|
||||
|
||||
func TestGetHeadersFromIncomingContext_DatasourceHeaderWinsOverConflictingTeamHeader(t *testing.T) {
|
||||
jsonData := []byte(`{
|
||||
"httpHeaderName1": "X-Scope-Orgid"
|
||||
}`)
|
||||
pluginCtx := backend.PluginContext{
|
||||
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{
|
||||
JSONData: jsonData,
|
||||
DecryptedSecureJSONData: map[string]string{
|
||||
"httpHeaderValue1": "1",
|
||||
},
|
||||
},
|
||||
}
|
||||
ctx := backend.WithPluginContext(context.Background(), pluginCtx)
|
||||
ctx = config.WithGrafanaConfig(ctx, config.NewGrafanaCfg(map[string]string{
|
||||
featuretoggles.EnabledFeatures: "streamingForwardTeamHeadersTempo",
|
||||
}))
|
||||
ctx = metadata.NewIncomingContext(ctx, metadata.Pairs(
|
||||
"x-scope-orgid", "tenant-from-incoming",
|
||||
"x-custom-forward", "extra",
|
||||
))
|
||||
|
||||
headers, err := GetHeadersFromIncomingContext(ctx, testLogger())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Only the datasource-configured X-Scope-Orgid should be present; the lowercase
|
||||
// incoming variant must be dropped so the gRPC wire only carries one value.
|
||||
assert.Equal(t, "1", headers["X-Scope-Orgid"])
|
||||
_, lowerPresent := headers["x-scope-orgid"]
|
||||
assert.False(t, lowerPresent, "lowercase x-scope-orgid must not coexist with datasource header")
|
||||
assert.Equal(t, "extra", headers["x-custom-forward"])
|
||||
}
|
||||
|
||||
func TestGetClientOptionsHeaders_ParsesHeaders(t *testing.T) {
|
||||
pluginCtx := backend.PluginContext{
|
||||
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{
|
||||
JSONData: []byte(`{"httpHeaderName1": "X-Client", "httpHeaderName2": "X-Client"}`),
|
||||
DecryptedSecureJSONData: map[string]string{
|
||||
"httpHeaderValue1": "client-value-a",
|
||||
"httpHeaderValue2": "client-value-b",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
headers, err := getClientOptionsHeaders(context.Background(), pluginCtx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]string{"X-Client": "client-value-a,client-value-b"}, headers)
|
||||
}
|
||||
|
||||
func TestGetClientOptionsHeaders_InvalidJSON(t *testing.T) {
|
||||
pluginCtx := backend.PluginContext{
|
||||
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{
|
||||
JSONData: []byte("{"),
|
||||
},
|
||||
}
|
||||
|
||||
_, err := getClientOptionsHeaders(context.Background(), pluginCtx)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ import { Trans, t } from '@grafana/i18n';
|
|||
import { config, getTemplateSrv, PanelRenderer } from '@grafana/runtime';
|
||||
import { type TimeZone } from '@grafana/schema';
|
||||
import { type AdHocFilterItem, PanelChrome, useTheme2, PanelContextProvider } from '@grafana/ui';
|
||||
import { TEMPO_STREAMING_PROGRESS_REF_ID } from 'app/plugins/datasource/tempo/streaming';
|
||||
const TEMPO_STREAMING_PROGRESS_REF_ID = 'streaming-progress';
|
||||
import {
|
||||
hasDeprecatedParentRowIndex,
|
||||
migrateFromParentRowIndexToNestedFrames,
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import { getTraceToLogsOptions, type TraceToMetricsData, type TraceToProfilesDat
|
|||
import { getTemplateSrv } from '@grafana/runtime';
|
||||
import { type DataQuery } from '@grafana/schema';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { type TempoQuery } from '@grafana-plugins/tempo/types';
|
||||
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||
import { getTimeZone } from 'app/features/profile/state/selectors';
|
||||
import { useDispatch, useSelector } from 'app/types/store';
|
||||
|
|
@ -315,7 +314,7 @@ function useFocusSpanLink(options: {
|
|||
// If it's the same trace, only update panel state with setFocusedSpanId (no navigation).
|
||||
// If it's a different trace, use splitOpenFn to open a new explore panel
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const sameTrace = query?.queryType === 'traceql' && (query as TempoQuery).query === traceId;
|
||||
const sameTrace = query?.queryType === 'traceql' && (query as { query?: string }).query === traceId;
|
||||
|
||||
return mapInternalLinkToExplore({
|
||||
link,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { CoreApp, type TimeRange } from '@grafana/data';
|
||||
import { usePluginLinks } from '@grafana/runtime';
|
||||
import { RelatedProfilesTitle } from '@grafana-plugins/tempo/resultTransformer';
|
||||
|
||||
import { SpanLinkType } from '../../types/links';
|
||||
import { type TraceSpan } from '../../types/trace';
|
||||
|
||||
import { getSpanDetailLinkButtons, getProfileLinkButtonsContext } from './SpanDetailLinkButtons';
|
||||
import { getProfileLinkButtonsContext, getSpanDetailLinkButtons, RelatedProfilesTitle } from './SpanDetailLinkButtons';
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { type TraceToProfilesOptions } from '@grafana/o11y-ds-frontend';
|
|||
import { config, locationService, reportInteraction, usePluginLinks } from '@grafana/runtime';
|
||||
import { type DataSourceRef } from '@grafana/schema';
|
||||
import { Button, DataLinkButton, Dropdown, Menu, useStyles2 } from '@grafana/ui';
|
||||
import { RelatedProfilesTitle } from '@grafana-plugins/tempo/resultTransformer';
|
||||
export const RelatedProfilesTitle = 'Related profiles';
|
||||
|
||||
import { pyroscopeProfileIdTagKey } from '../../../createSpanLink';
|
||||
import { type SpanLinkDef, type SpanLinkFunc, SpanLinkType } from '../../types/links';
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ import {
|
|||
MutableDataFrame,
|
||||
toCSV,
|
||||
} from '@grafana/data';
|
||||
import { transformToOTLP } from '@grafana-plugins/tempo/resultTransformer';
|
||||
|
||||
import { transformToJaeger } from '../../../plugins/datasource/jaeger/responseTransform';
|
||||
|
||||
import { transformToOTLP } from './transformToOTLP';
|
||||
import { transformToZipkin } from './transformToZipkin';
|
||||
|
||||
/**
|
||||
|
|
|
|||
195
public/app/features/inspector/utils/transformToOTLP.ts
Normal file
195
public/app/features/inspector/utils/transformToOTLP.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
// Copied from https://github.com/grafana/grafana-tempo-datasource — the Tempo datasource was removed
|
||||
// from core and moved to an external repo, but the core trace download utility still needs to support
|
||||
// converting DataFrames to OTLP format for existing traces tagged with traceFormat: 'otlp'.
|
||||
import { type SpanStatus } from '@opentelemetry/api';
|
||||
import { collectorTypes } from '@opentelemetry/exporter-collector';
|
||||
|
||||
import {
|
||||
type MutableDataFrame,
|
||||
type TraceKeyValuePair,
|
||||
type TraceLog,
|
||||
type TraceSpanReference,
|
||||
type TraceSpanRow,
|
||||
} from '@grafana/data';
|
||||
|
||||
export function transformToOTLP(data: MutableDataFrame): {
|
||||
batches: collectorTypes.opentelemetryProto.trace.v1.ResourceSpans[];
|
||||
} {
|
||||
let result: { batches: collectorTypes.opentelemetryProto.trace.v1.ResourceSpans[] } = {
|
||||
batches: [],
|
||||
};
|
||||
|
||||
// Lookup object to see which batch contains spans for which services
|
||||
let services: { [key: string]: number } = {};
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const span = data.get(i);
|
||||
|
||||
// Group spans based on service
|
||||
if (services[span.serviceName] === undefined) {
|
||||
services[span.serviceName] = result.batches.length;
|
||||
result.batches.push({
|
||||
resource: {
|
||||
attributes: [],
|
||||
droppedAttributesCount: 0,
|
||||
},
|
||||
instrumentationLibrarySpans: [
|
||||
{
|
||||
spans: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
let batchIndex = services[span.serviceName];
|
||||
|
||||
// Populate resource attributes from service tags
|
||||
if (result.batches[batchIndex].resource!.attributes.length === 0) {
|
||||
result.batches[batchIndex].resource!.attributes = tagsToAttributes(span.serviceTags);
|
||||
}
|
||||
|
||||
// Populate instrumentation library if it exists
|
||||
if (!result.batches[batchIndex].instrumentationLibrarySpans[0].instrumentationLibrary) {
|
||||
if (span.instrumentationLibraryName) {
|
||||
result.batches[batchIndex].instrumentationLibrarySpans[0].instrumentationLibrary = {
|
||||
name: span.instrumentationLibraryName,
|
||||
version: span.instrumentationLibraryVersion ? span.instrumentationLibraryVersion : '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
result.batches[batchIndex].instrumentationLibrarySpans[0].spans.push({
|
||||
traceId: span.traceID.padStart(32, '0'),
|
||||
spanId: span.spanID,
|
||||
parentSpanId: span.parentSpanID || '',
|
||||
traceState: span.traceState || '',
|
||||
name: span.operationName,
|
||||
kind: getOTLPSpanKind(span.kind),
|
||||
startTimeUnixNano: span.startTime * 1000000,
|
||||
endTimeUnixNano: (span.startTime + span.duration) * 1000000,
|
||||
attributes: span.tags ? tagsToAttributes(span.tags) : [],
|
||||
droppedAttributesCount: 0,
|
||||
droppedEventsCount: 0,
|
||||
droppedLinksCount: 0,
|
||||
status: getOTLPStatus(span),
|
||||
events: getOTLPEvents(span.logs),
|
||||
links: getOTLPReferences(span.references),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getOTLPSpanKind(kind: string): collectorTypes.opentelemetryProto.trace.v1.Span.SpanKind | undefined {
|
||||
if (!kind) {
|
||||
return undefined;
|
||||
}
|
||||
const { SpanKind } = collectorTypes.opentelemetryProto.trace.v1.Span;
|
||||
switch (kind) {
|
||||
case 'server':
|
||||
return SpanKind.SPAN_KIND_SERVER;
|
||||
case 'client':
|
||||
return SpanKind.SPAN_KIND_CLIENT;
|
||||
case 'producer':
|
||||
return SpanKind.SPAN_KIND_PRODUCER;
|
||||
case 'consumer':
|
||||
return SpanKind.SPAN_KIND_CONSUMER;
|
||||
case 'internal':
|
||||
return SpanKind.SPAN_KIND_INTERNAL;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function tagsToAttributes(tags: TraceKeyValuePair[]): collectorTypes.opentelemetryProto.common.v1.KeyValue[] {
|
||||
return tags.reduce<collectorTypes.opentelemetryProto.common.v1.KeyValue[]>(
|
||||
(attributes, tag) => [...attributes, { key: tag.key, value: toAttributeValue(tag) }],
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
function toAttributeValue(tag: TraceKeyValuePair): collectorTypes.opentelemetryProto.common.v1.AnyValue {
|
||||
if (typeof tag.value === 'string') {
|
||||
return { stringValue: tag.value };
|
||||
} else if (typeof tag.value === 'boolean') {
|
||||
return { boolValue: tag.value };
|
||||
} else if (typeof tag.value === 'number') {
|
||||
if (tag.value % 1 === 0) {
|
||||
return { intValue: tag.value };
|
||||
} else {
|
||||
return { doubleValue: tag.value };
|
||||
}
|
||||
} else if (typeof tag.value === 'object') {
|
||||
if (Array.isArray(tag.value)) {
|
||||
const values: collectorTypes.opentelemetryProto.common.v1.AnyValue[] = [];
|
||||
for (const val of tag.value) {
|
||||
values.push(toAttributeValue(val));
|
||||
}
|
||||
return { arrayValue: { values } };
|
||||
}
|
||||
}
|
||||
return { stringValue: tag.value };
|
||||
}
|
||||
|
||||
function getOTLPStatus(span: TraceSpanRow): SpanStatus | undefined {
|
||||
let status = undefined;
|
||||
if (span.statusCode !== undefined) {
|
||||
status = {
|
||||
code: span.statusCode,
|
||||
message: span.statusMessage ? span.statusMessage : '',
|
||||
};
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
function getOTLPEvents(logs: TraceLog[]): collectorTypes.opentelemetryProto.trace.v1.Span.Event[] | undefined {
|
||||
if (!logs || !logs.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let events: collectorTypes.opentelemetryProto.trace.v1.Span.Event[] = [];
|
||||
for (const log of logs) {
|
||||
let event: collectorTypes.opentelemetryProto.trace.v1.Span.Event = {
|
||||
timeUnixNano: log.timestamp * 1000000,
|
||||
attributes: [],
|
||||
droppedAttributesCount: 0,
|
||||
name: log.name || '',
|
||||
};
|
||||
for (const field of log.fields) {
|
||||
event.attributes!.push({
|
||||
key: field.key,
|
||||
value: toAttributeValue(field),
|
||||
});
|
||||
}
|
||||
events.push(event);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
function getOTLPReferences(
|
||||
references: TraceSpanReference[]
|
||||
): collectorTypes.opentelemetryProto.trace.v1.Span.Link[] | undefined {
|
||||
if (!references || !references.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let links: collectorTypes.opentelemetryProto.trace.v1.Span.Link[] = [];
|
||||
for (const ref of references) {
|
||||
let link: collectorTypes.opentelemetryProto.trace.v1.Span.Link = {
|
||||
traceId: ref.traceID,
|
||||
spanId: ref.spanID,
|
||||
attributes: [],
|
||||
droppedAttributesCount: 0,
|
||||
};
|
||||
if (ref.tags?.length) {
|
||||
for (const tag of ref.tags) {
|
||||
link.attributes?.push({
|
||||
key: tag.key,
|
||||
value: toAttributeValue(tag),
|
||||
});
|
||||
}
|
||||
}
|
||||
links.push(link);
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@ import userEvent from '@testing-library/user-event';
|
|||
import type { Grammar } from 'prismjs';
|
||||
|
||||
import { CoreApp, createTheme, getDefaultTimeRange, LogsDedupStrategy, LogsSortOrder } from '@grafana/data';
|
||||
import { createTempoDatasource } from '@grafana-plugins/tempo/test/mocks';
|
||||
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from '../fieldSelector/logFields';
|
||||
import { createLogLine } from '../mocks/logRow';
|
||||
|
|
@ -15,6 +14,7 @@ import { type LogListFontSize } from './LogList';
|
|||
import { LogListContextProvider, LogListContext } from './LogListContext';
|
||||
import { LogListSearchContext } from './LogListSearchContext';
|
||||
import { defaultProps, defaultValue } from './__mocks__/LogListContext';
|
||||
import { createTempoDatasource } from './__mocks__/createTempoDatasource';
|
||||
import { type LogListModel } from './processing';
|
||||
import { LogLineVirtualization } from './virtualization';
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import {
|
|||
} from '@grafana/data';
|
||||
import { type DataSourceSrv, getDataSourceSrv, setPluginLinksHook, usePluginLinks } from '@grafana/runtime';
|
||||
import { createLokiDatasource } from 'app/plugins/datasource/loki/mocks/datasource';
|
||||
import { createTempoDatasource } from 'app/plugins/datasource/tempo/test/mocks';
|
||||
|
||||
import { DATAPLANE_LABEL_TYPES_NAME, DATAPLANE_LABELS_NAME } from '../../logsFrame';
|
||||
import * as logsUtils from '../../utils';
|
||||
|
|
@ -30,6 +29,7 @@ import { emptyContextData, LogDetailsContext, type LogDetailsContextData } from
|
|||
import { LogLineDetails, type Props } from './LogLineDetails';
|
||||
import { LogListContext, type LogListContextData } from './LogListContext';
|
||||
import { defaultValue } from './__mocks__/LogListContext';
|
||||
import { createTempoDatasource } from './__mocks__/createTempoDatasource';
|
||||
|
||||
jest.mock('@openfeature/react-sdk', () => ({
|
||||
useBooleanFlagValue: jest.fn().mockReturnValue(false),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { isObservable, lastValueFrom } from 'rxjs';
|
|||
|
||||
import {
|
||||
type DataFrame,
|
||||
type DataQuery,
|
||||
type DataQueryRequest,
|
||||
type DataSourceApi,
|
||||
type GrafanaTheme2,
|
||||
|
|
@ -14,7 +15,13 @@ import { getDataSourceSrv, reportInteraction } from '@grafana/runtime';
|
|||
import { Icon, Spinner, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
import { TraceView } from 'app/features/explore/TraceView/TraceView';
|
||||
import { transformDataFrames } from 'app/features/explore/TraceView/utils/transform';
|
||||
import { SearchTableType, type TempoQuery } from 'app/plugins/datasource/tempo/dataquery.gen';
|
||||
|
||||
interface TempoQuery extends DataQuery {
|
||||
query?: string;
|
||||
queryType?: string;
|
||||
tableType?: string;
|
||||
filters: unknown[];
|
||||
}
|
||||
|
||||
import { useLogListContext } from './LogListContext';
|
||||
import { getTraceIdFromTraceQlQuery, type EmbeddedInternalLink } from './links';
|
||||
|
|
@ -59,7 +66,7 @@ export const LogLineDetailsTrace = ({ timeRange, timeZone, traceRef }: Props) =>
|
|||
query: traceQuery,
|
||||
queryType: 'traceql',
|
||||
refId: `log-details-trace-${traceQuery}`,
|
||||
tableType: SearchTableType.Traces,
|
||||
tableType: 'traces',
|
||||
filters: [],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -14,8 +14,6 @@ import {
|
|||
toDataFrame,
|
||||
} from '@grafana/data';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { type TempoDatasource } from '@grafana-plugins/tempo/datasource';
|
||||
import { createTempoDatasource } from '@grafana-plugins/tempo/test/mocks';
|
||||
|
||||
import { disablePopoverMenu, enablePopoverMenu, isPopoverMenuDisabled } from '../../utils';
|
||||
import { LOG_LINE_BODY_FIELD_NAME, OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME } from '../fieldSelector/logFields';
|
||||
|
|
@ -23,6 +21,7 @@ import { createLogLine, createLogRow } from '../mocks/logRow';
|
|||
import { OTEL_PROBE_FIELD } from '../otel/formats';
|
||||
|
||||
import { LogList, type Props } from './LogList';
|
||||
import { type TempoDatasource, createTempoDatasource } from './__mocks__/createTempoDatasource';
|
||||
|
||||
const useBooleanFlagValueMock = jest.fn((_: string, defaultValue: boolean) => defaultValue);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
// Copied from https://github.com/grafana/grafana-tempo-datasource — the Tempo datasource was removed
|
||||
// from core and moved to an external repo.
|
||||
import { type DataSourceInstanceSettings, type DataSourceJsonData, PluginType } from '@grafana/data';
|
||||
|
||||
export interface TempoJsonData extends DataSourceJsonData {
|
||||
tracesToLogs?: unknown;
|
||||
serviceMap?: {
|
||||
datasourceUid?: string;
|
||||
};
|
||||
search?: {
|
||||
hide?: boolean;
|
||||
filters?: unknown[];
|
||||
};
|
||||
nodeGraph?: unknown;
|
||||
spanBar?: {
|
||||
tag: string;
|
||||
};
|
||||
tagLimit?: number;
|
||||
traceQuery?: {
|
||||
timeShiftEnabled?: boolean;
|
||||
spanStartTimeShift?: string;
|
||||
spanEndTimeShift?: string;
|
||||
};
|
||||
streamingEnabled?: {
|
||||
search?: boolean;
|
||||
};
|
||||
timeRangeForTags?: number;
|
||||
}
|
||||
|
||||
const defaultMeta = {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
type: PluginType.datasource,
|
||||
module: '',
|
||||
baseUrl: '',
|
||||
info: {
|
||||
author: { name: 'Test' },
|
||||
description: '',
|
||||
links: [],
|
||||
logos: { large: '', small: '' },
|
||||
screenshots: [],
|
||||
updated: '',
|
||||
version: '',
|
||||
},
|
||||
};
|
||||
|
||||
export type TempoDatasource = ReturnType<typeof createTempoDatasource>;
|
||||
|
||||
export function createTempoDatasource(
|
||||
_templateSrv?: unknown,
|
||||
settings: Partial<DataSourceInstanceSettings<TempoJsonData>> = {}
|
||||
) {
|
||||
const customSettings: DataSourceInstanceSettings<TempoJsonData> = {
|
||||
url: 'myloggingurl',
|
||||
uid: '',
|
||||
type: 'tempo',
|
||||
name: 'Tempo',
|
||||
meta: defaultMeta,
|
||||
readOnly: false,
|
||||
jsonData: {},
|
||||
access: 'direct',
|
||||
...settings,
|
||||
};
|
||||
|
||||
return {
|
||||
uid: customSettings.uid,
|
||||
name: customSettings.name,
|
||||
type: customSettings.type,
|
||||
meta: customSettings.meta,
|
||||
instanceSettings: customSettings,
|
||||
query: jest.fn().mockReturnValue({ subscribe: jest.fn() }),
|
||||
testDatasource: jest.fn().mockResolvedValue({ status: 'success', message: 'OK' }),
|
||||
getTagKeys: jest.fn().mockResolvedValue([]),
|
||||
getTagValues: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
# TS generate from cue by cuetsy
|
||||
**/*.gen.ts
|
||||
|
|
@ -1 +0,0 @@
|
|||
# Changelog
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
import { TextLink } from '@grafana/ui';
|
||||
|
||||
export default function CheatSheet() {
|
||||
reportInteraction('grafana_traces_cheatsheet_clicked', {
|
||||
datasourceType: 'tempo',
|
||||
grafana_version: config.buildInfo.version,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>Tempo Cheat Sheet</h2>
|
||||
<p>
|
||||
<TextLink href={'https://grafana.com/docs/tempo/latest/'} external={true}>
|
||||
Grafana Tempo
|
||||
</TextLink>{' '}
|
||||
is an open source, easy-to-use, and high-volume distributed tracing backend.
|
||||
</p>
|
||||
<p>
|
||||
Tempo implements{' '}
|
||||
<TextLink href={'https://grafana.com/docs/tempo/latest/traceql'} external={true}>
|
||||
TraceQL
|
||||
</TextLink>
|
||||
, a traces-first query language inspired by LogQL and PromQL. This query language allows users to precisely and
|
||||
easily select spans and jump directly to the spans fulfilling the specified conditions.
|
||||
</p>
|
||||
<p>
|
||||
You can compose TraceQL queries using either the Search tab (the TraceQL query builder) or the TraceQL tab (the
|
||||
TraceQL query editor). Both of these methods let you build queries and drill-down into result sets. (
|
||||
<TextLink href={'https://grafana.com/docs/grafana/latest/datasources/tempo/query-editor/'} external={true}>
|
||||
Learn more
|
||||
</TextLink>
|
||||
)
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,233 +0,0 @@
|
|||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import { CoreApp, type GrafanaTheme, type GrafanaTheme2, toUtc } from '@grafana/data';
|
||||
import { config, reportInteraction, type TemplateSrv } from '@grafana/runtime';
|
||||
import { type Themeable } from '@grafana/ui';
|
||||
|
||||
import QueryField from './QueryField';
|
||||
import { createTempoDatasource } from './test/mocks';
|
||||
import { type TempoQuery } from './types';
|
||||
|
||||
jest.mock('@grafana/assistant', () => ({
|
||||
QueryWithAssistantButton: () => <div data-testid="query-with-assistant-button" />,
|
||||
}));
|
||||
|
||||
jest.mock('./SearchTraceQLEditor/TraceQLSearch', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="traceql-search-editor" />,
|
||||
}));
|
||||
|
||||
jest.mock('./ServiceGraphSection', () => ({
|
||||
ServiceGraphSection: () => <div data-testid="service-graph-section" />,
|
||||
}));
|
||||
|
||||
jest.mock('./traceql/QueryEditor', () => ({
|
||||
QueryEditor: () => <div data-testid="traceql-editor" />,
|
||||
}));
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
reportInteraction: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@grafana/ui', () => {
|
||||
const actual = jest.requireActual('@grafana/ui');
|
||||
|
||||
return {
|
||||
...actual,
|
||||
Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => (
|
||||
<button onClick={onClick} type="button">
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
FileDropzone: ({ onLoad }: { onLoad: (result: string | null) => void }) => (
|
||||
<button onClick={() => onLoad('{"trace":"uploaded"}')} type="button">
|
||||
Mock file dropzone
|
||||
</button>
|
||||
),
|
||||
InlineField: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
InlineFieldRow: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
Modal: ({ children, isOpen }: { children: React.ReactNode; isOpen: boolean }) =>
|
||||
isOpen ? <div>{children}</div> : null,
|
||||
RadioButtonGroup: ({
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
options: Array<{ label?: string; value?: string }>;
|
||||
onChange: (value: string) => void;
|
||||
}) => (
|
||||
<div>
|
||||
{options.map((option) => (
|
||||
<button key={option.value} onClick={() => option.value && onChange(option.value)} type="button">
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
Stack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
withTheme2: (Component: React.ComponentType<Partial<Themeable>>) => (props: Record<string, unknown>) => (
|
||||
<Component
|
||||
{...props}
|
||||
theme={
|
||||
{
|
||||
spacing: (value: number) => `${value * 8}px`,
|
||||
} as unknown as GrafanaTheme2 & GrafanaTheme
|
||||
}
|
||||
/>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedReportInteraction = jest.mocked(reportInteraction);
|
||||
|
||||
describe('QueryField', () => {
|
||||
const range = {
|
||||
from: toUtc('2024-01-01T00:00:00Z'),
|
||||
to: toUtc('2024-01-01T01:00:00Z'),
|
||||
raw: {
|
||||
from: toUtc('2024-01-01T00:00:00Z'),
|
||||
to: toUtc('2024-01-01T01:00:00Z'),
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
config.featureToggles.queryWithAssistant = true;
|
||||
config.buildInfo.version = '11.0.0';
|
||||
});
|
||||
|
||||
function renderQueryField(
|
||||
overrides: Partial<React.ComponentProps<typeof QueryField>> = {},
|
||||
nativeHistograms = false
|
||||
) {
|
||||
const datasource = createTempoDatasource({} as unknown as TemplateSrv);
|
||||
jest.spyOn(datasource, 'getNativeHistograms').mockResolvedValue(nativeHistograms);
|
||||
|
||||
const props = {
|
||||
app: CoreApp.Explore,
|
||||
datasource,
|
||||
onBlur: jest.fn(),
|
||||
onChange: jest.fn(),
|
||||
onRunQuery: jest.fn(),
|
||||
query: { refId: 'A', queryType: 'traceql' } as TempoQuery,
|
||||
range,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return {
|
||||
...render(<QueryField {...props} />),
|
||||
datasource,
|
||||
props,
|
||||
};
|
||||
}
|
||||
|
||||
it('sets the default query type on mount when it is missing', async () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
const { datasource } = renderQueryField({
|
||||
onChange,
|
||||
query: { refId: 'A' } as TempoQuery,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(datasource.getNativeHistograms).toHaveBeenCalledWith(range));
|
||||
|
||||
expect(onChange).toHaveBeenNthCalledWith(1, { refId: 'A', queryType: 'traceql' });
|
||||
expect(onChange).toHaveBeenNthCalledWith(2, { refId: 'A', serviceMapUseNativeHistograms: false });
|
||||
});
|
||||
|
||||
it('runs the query when a service graph query migrates to native histograms', async () => {
|
||||
const onRunQuery = jest.fn();
|
||||
const query = { refId: 'A', queryType: 'serviceMap' } as TempoQuery;
|
||||
|
||||
renderQueryField(
|
||||
{
|
||||
onRunQuery,
|
||||
query,
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
await waitFor(() => expect(onRunQuery).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('clears results, updates the query type, and reports the interaction when switching query type', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
const onRunQuery = jest.fn();
|
||||
|
||||
renderQueryField({
|
||||
onChange,
|
||||
onRunQuery,
|
||||
query: { refId: 'A', queryType: 'traceql' } as TempoQuery,
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onChange).toHaveBeenCalledWith({ refId: 'A', queryType: 'traceql', serviceMapUseNativeHistograms: false })
|
||||
);
|
||||
onChange.mockClear();
|
||||
onRunQuery.mockClear();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Service Graph' }));
|
||||
|
||||
expect(onChange).toHaveBeenNthCalledWith(1, { refId: 'A', queryType: 'clear' });
|
||||
expect(onRunQuery).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenNthCalledWith(2, { refId: 'A', queryType: 'serviceMap' });
|
||||
expect(mockedReportInteraction).toHaveBeenCalledWith('grafana_traces_query_type_changed', {
|
||||
datasourceType: 'tempo',
|
||||
app: CoreApp.Explore,
|
||||
grafana_version: '11.0.0',
|
||||
newQueryType: 'serviceMap',
|
||||
previousQueryType: 'traceql',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the assistant button only in supported apps', () => {
|
||||
const { rerender } = renderQueryField({ app: CoreApp.Explore });
|
||||
|
||||
expect(screen.getByTestId('query-with-assistant-button')).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<QueryField
|
||||
app={CoreApp.UnifiedAlerting}
|
||||
datasource={createTempoDatasource({} as unknown as TemplateSrv)}
|
||||
onBlur={jest.fn()}
|
||||
onChange={jest.fn()}
|
||||
onRunQuery={jest.fn()}
|
||||
query={{ refId: 'A', queryType: 'traceql' } as TempoQuery}
|
||||
range={range}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('query-with-assistant-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show the assistant button when the feature toggle is disabled', () => {
|
||||
config.featureToggles.queryWithAssistant = false;
|
||||
|
||||
renderQueryField({ app: CoreApp.Explore });
|
||||
|
||||
expect(screen.queryByTestId('query-with-assistant-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uploads a trace, switches to upload mode, and runs the query', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
const onRunQuery = jest.fn();
|
||||
const query = { refId: 'A', queryType: 'traceql' } as TempoQuery;
|
||||
|
||||
const { datasource } = renderQueryField({
|
||||
onChange,
|
||||
onRunQuery,
|
||||
query,
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Import trace' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Mock file dropzone' }));
|
||||
|
||||
expect(datasource.uploadedJson).toBe('{"trace":"uploaded"}');
|
||||
expect(onChange).toHaveBeenLastCalledWith({ refId: 'A', queryType: 'upload' });
|
||||
expect(onRunQuery).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
import { css } from '@emotion/css';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { QueryWithAssistantButton } from '@grafana/assistant';
|
||||
import { CoreApp, type QueryEditorProps, type SelectableValue } from '@grafana/data';
|
||||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
import {
|
||||
Button,
|
||||
FileDropzone,
|
||||
Stack,
|
||||
InlineField,
|
||||
InlineFieldRow,
|
||||
Modal,
|
||||
RadioButtonGroup,
|
||||
type Themeable2,
|
||||
withTheme2,
|
||||
} from '@grafana/ui';
|
||||
|
||||
import TraceQLSearch from './SearchTraceQLEditor/TraceQLSearch';
|
||||
import { ServiceGraphSection } from './ServiceGraphSection';
|
||||
import { type TempoQueryType } from './dataquery.gen';
|
||||
import { type TempoDatasource } from './datasource';
|
||||
import { QueryEditor } from './traceql/QueryEditor';
|
||||
import { type TempoQuery } from './types';
|
||||
import { migrateFromSearchToTraceQLSearch } from './utils';
|
||||
|
||||
interface Props extends QueryEditorProps<TempoDatasource, TempoQuery>, Themeable2 {
|
||||
// should template variables be added to tag options. default true
|
||||
addVariablesToOptions?: boolean;
|
||||
}
|
||||
interface State {
|
||||
uploadModalOpen: boolean;
|
||||
}
|
||||
|
||||
// This needs to default to traceql for data sources like Splunk, where clicking on a
|
||||
// data link should open the traceql tab and run a search based on the configured query.
|
||||
const DEFAULT_QUERY_TYPE: TempoQueryType = 'traceql';
|
||||
|
||||
class TempoQueryFieldComponent extends PureComponent<Props, State> {
|
||||
private _isMounted = false;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
uploadModalOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Set the default query type when the component mounts.
|
||||
// Also do this if queryType is 'clear' (which is the case when the user changes the query type)
|
||||
// otherwise if the user changes the query type and refreshes the page, no query type will be selected
|
||||
// which is inconsistent with how the UI was originally when they selected the Tempo data source.
|
||||
async componentDidMount() {
|
||||
this._isMounted = true;
|
||||
|
||||
if (!this.props.query.queryType || this.props.query.queryType === 'clear') {
|
||||
this.props.onChange({
|
||||
...this.props.query,
|
||||
queryType: DEFAULT_QUERY_TYPE,
|
||||
});
|
||||
}
|
||||
// TODO: Remove this automatic check for native histograms once Tempo only supports native histograms https://github.com/grafana/grafana/issues/109708
|
||||
// indentify the service map can use native histograms
|
||||
const timeRange = this.props.range;
|
||||
const nativeHistograms = await this.props.datasource.getNativeHistograms(timeRange);
|
||||
|
||||
// Only update if component is still mounted
|
||||
if (!this._isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.onChange({
|
||||
...this.props.query,
|
||||
serviceMapUseNativeHistograms: nativeHistograms,
|
||||
});
|
||||
// Migrate to native histograms
|
||||
// this will ensure that on navigating to the query option service map from a url,
|
||||
// the service map will be rendered with the native histograms when
|
||||
// querytype is serviceMap
|
||||
// the serviceMapUseNativeHistograms is undefined
|
||||
// and nativeHistograms is true
|
||||
if (
|
||||
this.props.query.queryType === 'serviceMap' &&
|
||||
this.props.query.serviceMapUseNativeHistograms === undefined &&
|
||||
// switch from tempo with native histograms to tempo without native histograms
|
||||
this.props.query.serviceMapUseNativeHistograms !== nativeHistograms &&
|
||||
nativeHistograms
|
||||
) {
|
||||
this.props.onRunQuery();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._isMounted = false;
|
||||
}
|
||||
|
||||
onClearResults = () => {
|
||||
// Run clear query to clear results
|
||||
const { onChange, query, onRunQuery } = this.props;
|
||||
onChange({
|
||||
...query,
|
||||
queryType: 'clear',
|
||||
});
|
||||
onRunQuery();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { query, onChange, datasource, app } = this.props;
|
||||
const isAlerting = app === CoreApp.UnifiedAlerting;
|
||||
|
||||
const graphDatasourceUid = datasource.serviceMap?.datasourceUid;
|
||||
|
||||
let queryTypeOptions: Array<SelectableValue<TempoQueryType>> = [
|
||||
{ value: 'traceqlSearch', label: 'Search' },
|
||||
{ value: 'traceql', label: 'TraceQL' },
|
||||
{ value: 'serviceMap', label: 'Service Graph' },
|
||||
];
|
||||
|
||||
// Migrate user to new query type if they are using the old search query type
|
||||
if (
|
||||
query.spanName ||
|
||||
query.serviceName ||
|
||||
query.search ||
|
||||
query.maxDuration ||
|
||||
query.minDuration ||
|
||||
query.queryType === 'nativeSearch'
|
||||
) {
|
||||
onChange(migrateFromSearchToTraceQLSearch(query));
|
||||
}
|
||||
|
||||
// only show query with assistant button if:
|
||||
// feature toggle is enabled
|
||||
// app is Explore, Dashboard, or PanelEditor
|
||||
const showAssistant =
|
||||
config.featureToggles.queryWithAssistant &&
|
||||
(app === CoreApp.Explore || app === CoreApp.Dashboard || app === CoreApp.PanelEditor);
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title={'Upload trace'}
|
||||
isOpen={this.state.uploadModalOpen}
|
||||
onDismiss={() => this.setState({ uploadModalOpen: false })}
|
||||
>
|
||||
<div className={css({ padding: this.props.theme.spacing(2) })}>
|
||||
<FileDropzone
|
||||
options={{ multiple: false }}
|
||||
onLoad={(result) => {
|
||||
if (typeof result !== 'string' && result !== null) {
|
||||
throw Error(`Unexpected result type: ${typeof result}`);
|
||||
}
|
||||
this.props.datasource.uploadedJson = result;
|
||||
onChange({
|
||||
...query,
|
||||
queryType: 'upload',
|
||||
});
|
||||
this.setState({ uploadModalOpen: false });
|
||||
this.props.onRunQuery();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
{!isAlerting && showAssistant && (
|
||||
<InlineFieldRow className={css({ marginBottom: this.props.theme.spacing(1) })}>
|
||||
<QueryWithAssistantButton
|
||||
currentQuery={query}
|
||||
queries={[query]}
|
||||
dataSourceInstanceSettings={datasource.instanceSettings}
|
||||
datasourceApi={null}
|
||||
app={app}
|
||||
/>
|
||||
</InlineFieldRow>
|
||||
)}
|
||||
{!isAlerting && (
|
||||
<InlineFieldRow>
|
||||
<InlineField label="Query type" grow={true}>
|
||||
<Stack gap={1} alignItems="center" justifyContent="space-between">
|
||||
<RadioButtonGroup<TempoQueryType>
|
||||
options={queryTypeOptions}
|
||||
value={query.queryType}
|
||||
onChange={(v) => {
|
||||
reportInteraction('grafana_traces_query_type_changed', {
|
||||
datasourceType: 'tempo',
|
||||
app: app ?? '',
|
||||
grafana_version: config.buildInfo.version,
|
||||
newQueryType: v,
|
||||
previousQueryType: query.queryType ?? '',
|
||||
});
|
||||
|
||||
this.onClearResults();
|
||||
onChange({
|
||||
...query,
|
||||
queryType: v,
|
||||
});
|
||||
}}
|
||||
size="md"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
this.setState({ uploadModalOpen: true });
|
||||
}}
|
||||
>
|
||||
Import trace
|
||||
</Button>
|
||||
</Stack>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
)}
|
||||
{query.queryType === 'traceqlSearch' && (
|
||||
<TraceQLSearch
|
||||
datasource={this.props.datasource}
|
||||
query={query}
|
||||
onChange={onChange}
|
||||
onBlur={this.props.onBlur}
|
||||
app={app}
|
||||
onClearResults={this.onClearResults}
|
||||
addVariablesToOptions={this.props.addVariablesToOptions}
|
||||
range={this.props.range}
|
||||
/>
|
||||
)}
|
||||
{query.queryType === 'serviceMap' && (
|
||||
<ServiceGraphSection graphDatasourceUid={graphDatasourceUid} query={query} onChange={onChange} />
|
||||
)}
|
||||
{query.queryType === 'traceql' && (
|
||||
<QueryEditor
|
||||
datasource={this.props.datasource}
|
||||
query={query}
|
||||
onRunQuery={this.props.onRunQuery}
|
||||
onChange={onChange}
|
||||
app={app}
|
||||
onClearResults={this.onClearResults}
|
||||
range={this.props.range}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const TempoQueryField = withTheme2(TempoQueryFieldComponent);
|
||||
|
||||
export default TempoQueryField;
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
# Tempo Data Source - Native Plugin
|
||||
|
||||
Grafana ships with **built in** support for Tempo, an open source, easy-to-use, and high-scale distributed tracing backend.
|
||||
|
||||
Read more about it here:
|
||||
|
||||
[https://docs.grafana.org/datasources/tempo/](https://docs.grafana.org/datasources/tempo/)
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Alert, Button } from '@grafana/ui';
|
||||
|
||||
import { type TempoQuery } from '../dataquery.gen';
|
||||
|
||||
export function AggregateByAlert({
|
||||
query,
|
||||
onChange,
|
||||
}: {
|
||||
query: TempoQuery;
|
||||
onChange?: () => void;
|
||||
}): React.ReactNode | null {
|
||||
return query.groupBy ? (
|
||||
<Alert title="" severity="info">
|
||||
The aggregate by feature has been removed. We recommend using Traces Drilldown app instead.
|
||||
<Button onClick={onChange}>Remove aggregate by from this query</Button>
|
||||
</Alert>
|
||||
) : null;
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
import { css } from '@emotion/css';
|
||||
|
||||
import { Select, Stack, Input, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { type TraceqlFilter } from '../dataquery.gen';
|
||||
|
||||
import { operatorSelectableValue } from './utils';
|
||||
|
||||
interface Props {
|
||||
filter: TraceqlFilter;
|
||||
updateFilter: (f: TraceqlFilter) => void;
|
||||
isTagsLoading?: boolean;
|
||||
operators: string[];
|
||||
}
|
||||
|
||||
// Support template variables (e.g., `$dur`, `$v_1`) and durations (e.g., `300µs`, `1.2ms`)
|
||||
const validationRegex = /^(\$\w+)|(\d+(?:\.\d)?\d*(?:us|µs|ns|ms|s|m|h))$/;
|
||||
|
||||
const getStyles = () => ({
|
||||
noBoxShadow: css({
|
||||
boxShadow: 'none',
|
||||
'*:focus': {
|
||||
boxShadow: 'none',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const DurationInput = ({ filter, operators, updateFilter }: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
let invalid = false;
|
||||
if (typeof filter.value === 'string') {
|
||||
invalid = filter.value ? !validationRegex.test(filter.value.concat('')) : false;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<Select
|
||||
className={styles.noBoxShadow}
|
||||
inputId={`${filter.id}-operator`}
|
||||
options={operators.map(operatorSelectableValue)}
|
||||
value={filter.operator}
|
||||
onChange={(v) => {
|
||||
updateFilter({ ...filter, operator: v?.value });
|
||||
}}
|
||||
isClearable={false}
|
||||
aria-label={`select ${filter.id} operator`}
|
||||
allowCustomValue={true}
|
||||
width={8}
|
||||
/>
|
||||
<Input
|
||||
className={styles.noBoxShadow}
|
||||
value={filter.value}
|
||||
onChange={(v) => {
|
||||
updateFilter({ ...filter, value: v.currentTarget.value });
|
||||
}}
|
||||
placeholder="e.g. 100ms, 1.2s"
|
||||
aria-label={`select ${filter.id} value`}
|
||||
invalid={invalid}
|
||||
width={18}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default DurationInput;
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { InlineFieldRow, InlineField } from '@grafana/ui';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
tooltip?: string;
|
||||
children: React.ReactElement<Record<string, unknown>>;
|
||||
}
|
||||
const SearchField = ({ label, tooltip, children }: Props) => {
|
||||
return (
|
||||
<InlineFieldRow>
|
||||
<InlineField label={label} labelWidth={28} grow tooltip={tooltip}>
|
||||
{children}
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchField;
|
||||
|
|
@ -1,381 +0,0 @@
|
|||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { type LanguageProvider } from '@grafana/data';
|
||||
|
||||
import { type TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen';
|
||||
import { type TempoDatasource } from '../datasource';
|
||||
import type TempoLanguageProvider from '../language_provider';
|
||||
import { initTemplateSrv } from '../test/test_utils';
|
||||
import { keywordOperators, numberOperators, operators, stringOperators } from '../traceql/traceql';
|
||||
|
||||
import SearchField from './SearchField';
|
||||
|
||||
describe('SearchField', () => {
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
|
||||
beforeEach(() => {
|
||||
const expectedValues = {
|
||||
interpolationVar: 'interpolationText',
|
||||
interpolationText: 'interpolationText',
|
||||
interpolationVarWithPipe: 'interpolationTextOne|interpolationTextTwo',
|
||||
scopedInterpolationText: 'scopedInterpolationText',
|
||||
};
|
||||
initTemplateSrv([{ name: 'templateVariable1' }, { name: 'templateVariable2' }], expectedValues);
|
||||
|
||||
jest.useFakeTimers();
|
||||
// Need to use delay: null here to work with fakeTimers
|
||||
// see https://github.com/testing-library/user-event/issues/833
|
||||
user = userEvent.setup({ delay: null });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should not render tag if hideTag is true', async () => {
|
||||
const updateFilter = jest.fn((val) => {
|
||||
return val;
|
||||
});
|
||||
const filter: TraceqlFilter = { id: 'test1', valueType: 'string', tag: 'test-tag' };
|
||||
|
||||
const { container } = renderSearchField(updateFilter, filter, [], true);
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(container.querySelector(`input[aria-label="select test1 tag"]`)).not.toBeInTheDocument();
|
||||
expect(container.querySelector(`input[aria-label="select test1 operator"]`)).toBeInTheDocument();
|
||||
expect(container.querySelector(`input[aria-label="select test1 value"]`)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should update operator when new value is selected in operator input', async () => {
|
||||
const updateFilter = jest.fn((val) => {
|
||||
return val;
|
||||
});
|
||||
const filter: TraceqlFilter = { id: 'test1', operator: '=', valueType: 'string', tag: 'test-tag' };
|
||||
const { container } = renderSearchField(updateFilter, filter);
|
||||
|
||||
const select = container.querySelector(`input[aria-label="select test1 operator"]`);
|
||||
expect(select).not.toBeNull();
|
||||
expect(select).toBeInTheDocument();
|
||||
if (select) {
|
||||
await user.click(select);
|
||||
jest.advanceTimersByTime(1000);
|
||||
const largerThanOp = await screen.findByText('!=');
|
||||
await user.click(largerThanOp);
|
||||
|
||||
expect(updateFilter).toHaveBeenCalledWith({ ...filter, operator: '!=' });
|
||||
}
|
||||
});
|
||||
|
||||
it('should update value when new value is selected in value input', async () => {
|
||||
const updateFilter = jest.fn((val) => {
|
||||
return val;
|
||||
});
|
||||
const filter: TraceqlFilter = {
|
||||
id: 'test1',
|
||||
isCustomValue: false,
|
||||
valueType: 'string',
|
||||
tag: 'test-tag',
|
||||
};
|
||||
const { container } = renderSearchField(updateFilter, filter);
|
||||
|
||||
const select = container.querySelector(`input[aria-label="select test1 value"]`);
|
||||
expect(select).not.toBeNull();
|
||||
expect(select).toBeInTheDocument();
|
||||
if (select) {
|
||||
// Add first value
|
||||
await user.click(select);
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
const driverVal = await screen.findByText('driver');
|
||||
|
||||
await act(async () => {
|
||||
await user.click(driverVal);
|
||||
});
|
||||
expect(updateFilter).toHaveBeenCalledWith({ ...filter, value: ['driver'] });
|
||||
|
||||
// Add a second value
|
||||
await user.click(select);
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
const customerVal = await screen.findByText('customer');
|
||||
|
||||
await user.click(customerVal);
|
||||
expect(updateFilter).toHaveBeenCalledWith({ ...filter, value: ['driver', 'customer'] });
|
||||
|
||||
// Remove the first value
|
||||
const firstValRemove = await screen.findAllByLabelText('Remove');
|
||||
|
||||
await user.click(firstValRemove[0]);
|
||||
expect(updateFilter).toHaveBeenCalledWith({ ...filter, value: ['customer'] });
|
||||
}
|
||||
});
|
||||
|
||||
it('should update tag when new value is selected in tag input', async () => {
|
||||
const updateFilter = jest.fn((val) => {
|
||||
return val;
|
||||
});
|
||||
const filter: TraceqlFilter = {
|
||||
id: 'test1',
|
||||
valueType: 'string',
|
||||
};
|
||||
const { container } = renderSearchField(updateFilter, filter, ['tag1', 'tag22', 'tag33']);
|
||||
|
||||
const select = container.querySelector(`input[aria-label="select test1 tag"]`);
|
||||
expect(select).not.toBeNull();
|
||||
expect(select).toBeInTheDocument();
|
||||
if (select) {
|
||||
// Select tag22 as the tag
|
||||
await user.click(select);
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
const tag22 = await screen.findByText('tag22');
|
||||
await user.click(tag22);
|
||||
expect(updateFilter).toHaveBeenCalledWith({ ...filter, tag: 'tag22', value: [] });
|
||||
|
||||
// Select tag1 as the tag
|
||||
await user.click(select);
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
const tag1 = await screen.findByText('tag1');
|
||||
await user.click(tag1);
|
||||
expect(updateFilter).toHaveBeenCalledWith({ ...filter, tag: 'tag1', value: [] });
|
||||
|
||||
// Remove the tag
|
||||
const tagRemove = await screen.findByLabelText('Clear value');
|
||||
await user.click(tagRemove);
|
||||
expect(updateFilter).toHaveBeenCalledWith({ ...filter, value: [] });
|
||||
}
|
||||
});
|
||||
|
||||
it('should provide intrinsic as a selectable scope', async () => {
|
||||
const updateFilter = jest.fn((val) => {
|
||||
return val;
|
||||
});
|
||||
const filter: TraceqlFilter = { id: 'test1', valueType: 'string', tag: 'test-tag' };
|
||||
|
||||
const { container } = renderSearchField(updateFilter, filter, [], true);
|
||||
|
||||
const scopeSelect = container.querySelector(`input[aria-label="select test1 scope"]`);
|
||||
expect(scopeSelect).not.toBeNull();
|
||||
expect(scopeSelect).toBeInTheDocument();
|
||||
|
||||
if (scopeSelect) {
|
||||
await user.click(scopeSelect);
|
||||
jest.advanceTimersByTime(1000);
|
||||
expect(await screen.findByText('resource')).toBeInTheDocument();
|
||||
expect(await screen.findByText('span')).toBeInTheDocument();
|
||||
expect(await screen.findByText('unscoped')).toBeInTheDocument();
|
||||
expect(await screen.findByText('intrinsic')).toBeInTheDocument();
|
||||
expect(await screen.findByText('$templateVariable1')).toBeInTheDocument();
|
||||
expect(await screen.findByText('$templateVariable2')).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('should only show keyword operators if options tag type is keyword', async () => {
|
||||
const filter: TraceqlFilter = { id: 'test1', operator: '=', valueType: 'string', tag: 'test-tag' };
|
||||
const lp = {
|
||||
getOptionsV2: jest.fn().mockReturnValue([
|
||||
{
|
||||
value: 'ok',
|
||||
label: 'ok',
|
||||
type: 'keyword',
|
||||
},
|
||||
]),
|
||||
getIntrinsics: jest.fn().mockReturnValue(['duration']),
|
||||
getTags: jest.fn().mockReturnValue(['cluster']),
|
||||
} as unknown as TempoLanguageProvider;
|
||||
|
||||
const { container } = renderSearchField(jest.fn(), filter, [], false, lp);
|
||||
const select = container.querySelector(`input[aria-label="select test1 operator"]`);
|
||||
if (select) {
|
||||
await user.click(select);
|
||||
await waitFor(async () => {
|
||||
expect(screen.getByText('Equals')).toBeInTheDocument();
|
||||
expect(screen.getByText('Not equals')).toBeInTheDocument();
|
||||
operators
|
||||
.filter((op) => !keywordOperators.includes(op))
|
||||
.forEach((op) => {
|
||||
expect(screen.queryByText(op)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should only show string operators if options tag type is string', async () => {
|
||||
const filter: TraceqlFilter = { id: 'test1', operator: '=', valueType: 'string', tag: 'test-tag' };
|
||||
const { container } = renderSearchField(jest.fn(), filter);
|
||||
const select = container.querySelector(`input[aria-label="select test1 operator"]`);
|
||||
if (select) {
|
||||
await user.click(select);
|
||||
await waitFor(async () => {
|
||||
expect(screen.getByText('Equals')).toBeInTheDocument();
|
||||
expect(screen.getByText('Not equals')).toBeInTheDocument();
|
||||
expect(screen.getByText('Matches regex')).toBeInTheDocument();
|
||||
expect(screen.getByText('Does not match regex')).toBeInTheDocument();
|
||||
operators
|
||||
.filter((op) => !stringOperators.includes(op))
|
||||
.forEach((op) => {
|
||||
expect(screen.queryByText(op)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should only show number operators if options tag type is number', async () => {
|
||||
const filter: TraceqlFilter = { id: 'test1', operator: '=', valueType: 'string', tag: 'test-tag' };
|
||||
const lp = {
|
||||
getOptionsV2: jest.fn().mockReturnValue([
|
||||
{
|
||||
value: 200,
|
||||
label: 200,
|
||||
type: 'int',
|
||||
},
|
||||
]),
|
||||
getIntrinsics: jest.fn().mockReturnValue(['duration']),
|
||||
getTags: jest.fn().mockReturnValue(['cluster']),
|
||||
} as unknown as TempoLanguageProvider;
|
||||
|
||||
const { container } = renderSearchField(jest.fn(), filter, [], false, lp);
|
||||
const select = container.querySelector(`input[aria-label="select test1 operator"]`);
|
||||
if (select) {
|
||||
await user.click(select);
|
||||
await waitFor(async () => {
|
||||
expect(screen.getByText('Equals')).toBeInTheDocument();
|
||||
expect(screen.getByText('Not equals')).toBeInTheDocument();
|
||||
expect(screen.getByText('Greater')).toBeInTheDocument();
|
||||
expect(screen.getByText('Less')).toBeInTheDocument();
|
||||
expect(screen.getByText('Greater or Equal')).toBeInTheDocument();
|
||||
expect(screen.getByText('Less or Equal')).toBeInTheDocument();
|
||||
operators
|
||||
.filter((op) => !numberOperators.includes(op))
|
||||
.forEach((op) => {
|
||||
expect(screen.queryByText(op)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should create custom option with single value when filter value is not an array', async () => {
|
||||
const updateFilter = jest.fn((val) => {
|
||||
return val;
|
||||
});
|
||||
const filter: TraceqlFilter = {
|
||||
id: 'test1',
|
||||
valueType: 'string',
|
||||
tag: 'test-tag',
|
||||
value: 'existing-value',
|
||||
};
|
||||
|
||||
const { container } = renderSearchField(updateFilter, filter, [], false, undefined, false);
|
||||
|
||||
const select = container.querySelector(`input[aria-label="select test1 value"]`);
|
||||
expect(select).not.toBeNull();
|
||||
expect(select).toBeInTheDocument();
|
||||
|
||||
if (select) {
|
||||
await user.type(select, 'custom-value');
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
expect(updateFilter).toHaveBeenCalledWith({
|
||||
...filter,
|
||||
value: 'custom-value',
|
||||
valueType: 'string',
|
||||
isCustomValue: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should create custom option with array value when filter value is an array', async () => {
|
||||
const updateFilter = jest.fn((val) => {
|
||||
return val;
|
||||
});
|
||||
const filter: TraceqlFilter = {
|
||||
id: 'test1',
|
||||
valueType: 'string',
|
||||
tag: 'test-tag',
|
||||
value: ['existing-value1', 'existing-value2'],
|
||||
};
|
||||
|
||||
const { container } = renderSearchField(updateFilter, filter, [], false, undefined, true);
|
||||
|
||||
const select = container.querySelector(`input[aria-label="select test1 value"]`);
|
||||
expect(select).not.toBeNull();
|
||||
expect(select).toBeInTheDocument();
|
||||
|
||||
if (select) {
|
||||
await user.type(select, 'custom-value');
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
expect(updateFilter).toHaveBeenCalledWith({
|
||||
...filter,
|
||||
value: ['existing-value1', 'existing-value2', 'custom-value'],
|
||||
valueType: 'string',
|
||||
isCustomValue: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const renderSearchField = (
|
||||
updateFilter: (f: TraceqlFilter) => void,
|
||||
filter: TraceqlFilter,
|
||||
tags?: string[],
|
||||
hideTag?: boolean,
|
||||
lp?: LanguageProvider,
|
||||
isMulti?: boolean
|
||||
) => {
|
||||
const languageProvider =
|
||||
lp ||
|
||||
({
|
||||
getOptionsV2: jest.fn().mockReturnValue([
|
||||
{
|
||||
value: 'customer',
|
||||
label: 'customer',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
value: 'driver',
|
||||
label: 'driver',
|
||||
type: 'string',
|
||||
},
|
||||
]),
|
||||
getIntrinsics: jest.fn().mockReturnValue(['duration']),
|
||||
getTags: jest.fn().mockReturnValue(['cluster']),
|
||||
} as unknown as TempoLanguageProvider);
|
||||
|
||||
const datasource: TempoDatasource = {
|
||||
search: {
|
||||
filters: [
|
||||
{
|
||||
id: 'service-name',
|
||||
tag: 'service.name',
|
||||
operator: '=',
|
||||
scope: TraceqlSearchScope.Resource,
|
||||
},
|
||||
{ id: 'span-name', type: 'static', tag: 'name', operator: '=', scope: TraceqlSearchScope.Span },
|
||||
],
|
||||
},
|
||||
languageProvider,
|
||||
} as TempoDatasource;
|
||||
|
||||
return render(
|
||||
<SearchField
|
||||
datasource={datasource}
|
||||
updateFilter={updateFilter}
|
||||
filter={filter}
|
||||
setError={() => {}}
|
||||
tags={tags || []}
|
||||
hideTag={hideTag}
|
||||
query={'{}'}
|
||||
addVariablesToOptions={true}
|
||||
isMulti={isMulti}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,299 +0,0 @@
|
|||
import { css } from '@emotion/css';
|
||||
import { uniq } from 'lodash';
|
||||
import { useMemo, useState } from 'react';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
|
||||
import { type SelectableValue, type TimeRange } from '@grafana/data';
|
||||
import { TemporaryAlert } from '@grafana/o11y-ds-frontend';
|
||||
import { type FetchError, getTemplateSrv, isFetchError } from '@grafana/runtime';
|
||||
import { Select, Stack, useStyles2, type InputActionMeta } from '@grafana/ui';
|
||||
|
||||
import { type TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen';
|
||||
import { type TempoDatasource } from '../datasource';
|
||||
import { OPTIONS_LIMIT } from '../language_provider';
|
||||
import { operators as allOperators, stringOperators, numberOperators, keywordOperators } from '../traceql/traceql';
|
||||
|
||||
import { filterScopedTag, operatorSelectableValue } from './utils';
|
||||
|
||||
interface Props {
|
||||
filter: TraceqlFilter;
|
||||
datasource: TempoDatasource;
|
||||
updateFilter: (f: TraceqlFilter) => void;
|
||||
deleteFilter?: (f: TraceqlFilter) => void;
|
||||
setError: (error: FetchError | null) => void;
|
||||
isTagsLoading?: boolean;
|
||||
tags: string[];
|
||||
hideScope?: boolean;
|
||||
hideTag?: boolean;
|
||||
hideValue?: boolean;
|
||||
query: string;
|
||||
isMulti?: boolean;
|
||||
allowCustomValue?: boolean;
|
||||
addVariablesToOptions?: boolean;
|
||||
range?: TimeRange;
|
||||
timeRangeForTags?: number;
|
||||
}
|
||||
const SearchField = ({
|
||||
filter,
|
||||
datasource,
|
||||
updateFilter,
|
||||
isTagsLoading,
|
||||
tags,
|
||||
setError,
|
||||
hideScope,
|
||||
hideTag,
|
||||
hideValue,
|
||||
query,
|
||||
addVariablesToOptions,
|
||||
isMulti = true,
|
||||
allowCustomValue = true,
|
||||
range,
|
||||
timeRangeForTags,
|
||||
}: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const [alertText, setAlertText] = useState<string>();
|
||||
const scopedTag = useMemo(
|
||||
() => filterScopedTag(filter, datasource.languageProvider),
|
||||
[datasource.languageProvider, filter]
|
||||
);
|
||||
const [tagQuery, setTagQuery] = useState<string>('');
|
||||
const [tagValuesQuery, setTagValuesQuery] = useState<string>('');
|
||||
|
||||
const updateOptions = async () => {
|
||||
try {
|
||||
const result = filter.tag
|
||||
? await datasource.languageProvider.getOptionsV2({
|
||||
tag: scopedTag,
|
||||
query,
|
||||
timeRangeForTags,
|
||||
range,
|
||||
})
|
||||
: [];
|
||||
setAlertText(undefined);
|
||||
setError(null);
|
||||
return result;
|
||||
} catch (error) {
|
||||
// Display message if Tempo is connected but search 404's
|
||||
if (isFetchError(error) && error?.status === 404) {
|
||||
setError(error);
|
||||
} else if (error instanceof Error) {
|
||||
setAlertText(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const { loading: isLoadingValues, value: options } = useAsync(updateOptions, [
|
||||
scopedTag,
|
||||
datasource.languageProvider,
|
||||
setError,
|
||||
query,
|
||||
range,
|
||||
timeRangeForTags,
|
||||
]);
|
||||
|
||||
// Add selected option if it doesn't exist in the current list of options
|
||||
if (filter.value && !Array.isArray(filter.value) && options && !options.find((o) => o.value === filter.value)) {
|
||||
options.push({ label: filter.value.toString(), value: filter.value.toString(), type: filter.valueType });
|
||||
}
|
||||
|
||||
const scopeOptions = Object.values(TraceqlSearchScope)
|
||||
.filter((s) => {
|
||||
// only add scope if it has tags
|
||||
return datasource.languageProvider.getTags(s).length > 0;
|
||||
})
|
||||
.map((t) => ({ label: t, value: t }));
|
||||
|
||||
// If all values have type string or int/float use a focused list of operators instead of all operators
|
||||
const optionsOfFirstType = options?.filter((o) => o.type === options[0]?.type);
|
||||
const uniqueOptionType = options?.length === optionsOfFirstType?.length ? options?.[0]?.type : undefined;
|
||||
let operatorList = allOperators;
|
||||
switch (uniqueOptionType) {
|
||||
case 'keyword':
|
||||
operatorList = keywordOperators;
|
||||
break;
|
||||
case 'string':
|
||||
operatorList = stringOperators;
|
||||
break;
|
||||
case 'int':
|
||||
case 'float':
|
||||
operatorList = numberOperators;
|
||||
}
|
||||
const operatorOptions = operatorList.map(operatorSelectableValue);
|
||||
|
||||
const formatTagOptions = (tags: string[], filterTag: string | undefined) => {
|
||||
return (filterTag !== undefined ? uniq([filterTag, ...tags]) : tags).map((t) => ({ label: t, value: t }));
|
||||
};
|
||||
|
||||
const tagOptions = useMemo(() => {
|
||||
if (tagQuery.length === 0) {
|
||||
return formatTagOptions(tags.slice(0, OPTIONS_LIMIT), filter.tag);
|
||||
}
|
||||
|
||||
const queryLowerCase = tagQuery.toLowerCase();
|
||||
const filterdOptions = tags.filter((tag) => tag.toLowerCase().includes(queryLowerCase)).slice(0, OPTIONS_LIMIT);
|
||||
return formatTagOptions(filterdOptions, filter.tag);
|
||||
}, [filter.tag, tagQuery, tags]);
|
||||
|
||||
const tagValueOptions = useMemo(() => {
|
||||
if (!options) {
|
||||
return;
|
||||
}
|
||||
|
||||
let currentOptions = options;
|
||||
|
||||
// Add custom value if it exists and isn't already in options
|
||||
if (filter.isCustomValue && filter.value) {
|
||||
const customValue = Array.isArray(filter.value) ? filter.value : [filter.value];
|
||||
|
||||
const newCustomOptions = customValue
|
||||
.filter((val) => !options.some((opt) => opt.value === val))
|
||||
.map((val) => ({ label: val, value: val, type: filter.valueType }));
|
||||
|
||||
if (newCustomOptions.length > 0) {
|
||||
currentOptions = [...options, ...newCustomOptions];
|
||||
}
|
||||
}
|
||||
|
||||
if (tagValuesQuery.length === 0) {
|
||||
return currentOptions.slice(0, OPTIONS_LIMIT);
|
||||
}
|
||||
|
||||
const queryLowerCase = tagValuesQuery.toLowerCase();
|
||||
return currentOptions
|
||||
.filter((tag) => {
|
||||
if (tag.value && tag.value.length > 0) {
|
||||
return tag.value.toLowerCase().includes(queryLowerCase);
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.slice(0, OPTIONS_LIMIT);
|
||||
}, [tagValuesQuery, options, filter.isCustomValue, filter.value, filter.valueType]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack gap={0} width="auto">
|
||||
{!hideScope && (
|
||||
<Select
|
||||
width="auto"
|
||||
className={styles.dropdown}
|
||||
inputId={`${filter.id}-scope`}
|
||||
options={addVariablesToOptions ? withTemplateVariableOptions(scopeOptions) : scopeOptions}
|
||||
value={filter.scope}
|
||||
onChange={(v) => updateFilter({ ...filter, scope: v?.value, tag: undefined, value: [] })}
|
||||
placeholder="Select scope"
|
||||
aria-label={`select ${filter.id} scope`}
|
||||
/>
|
||||
)}
|
||||
{!hideTag && (
|
||||
<Select
|
||||
width="auto"
|
||||
className={styles.dropdown}
|
||||
inputId={`${filter.id}-tag`}
|
||||
isLoading={isTagsLoading}
|
||||
// Add the current tag to the list if it doesn't exist in the tags prop, otherwise the field will be empty even though the state has a value
|
||||
options={addVariablesToOptions ? withTemplateVariableOptions(tagOptions) : tagOptions}
|
||||
onInputChange={(value: string, { action }: InputActionMeta) => {
|
||||
if (action === 'input-change') {
|
||||
setTagQuery(value);
|
||||
}
|
||||
}}
|
||||
onCloseMenu={() => setTagQuery('')}
|
||||
onChange={(v) => updateFilter({ ...filter, tag: v?.value, value: [] })}
|
||||
value={filter.tag}
|
||||
key={filter.tag}
|
||||
placeholder="Select tag"
|
||||
isClearable
|
||||
aria-label={`select ${filter.id} tag`}
|
||||
allowCustomValue
|
||||
virtualized
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
className={styles.dropdown}
|
||||
inputId={`${filter.id}-operator`}
|
||||
options={addVariablesToOptions ? withTemplateVariableOptions(operatorOptions) : operatorOptions}
|
||||
value={filter.operator}
|
||||
onChange={(v) => updateFilter({ ...filter, operator: v?.value })}
|
||||
isClearable={false}
|
||||
aria-label={`select ${filter.id} operator`}
|
||||
allowCustomValue={true}
|
||||
width={8}
|
||||
/>
|
||||
{!hideValue && (
|
||||
<Select
|
||||
/**
|
||||
* Trace cardinality means we need to use the virtualized variant of the Select component.
|
||||
* For example the number of span names being returned can easily reach 10s of thousands,
|
||||
* which is enough to cause a user's web browser to seize up
|
||||
*/
|
||||
width="auto"
|
||||
virtualized
|
||||
className={styles.dropdown}
|
||||
inputId={`${filter.id}-value`}
|
||||
isLoading={isLoadingValues}
|
||||
options={addVariablesToOptions ? withTemplateVariableOptions(tagValueOptions) : tagValueOptions}
|
||||
value={filter.value}
|
||||
onInputChange={(value: string, { action }: InputActionMeta) => {
|
||||
if (action === 'input-change') {
|
||||
setTagValuesQuery(value);
|
||||
}
|
||||
}}
|
||||
onCloseMenu={() => setTagValuesQuery('')}
|
||||
onChange={(val) => {
|
||||
if (Array.isArray(val)) {
|
||||
updateFilter({
|
||||
...filter,
|
||||
value: val.map((v) => v.value),
|
||||
valueType: val[0]?.type || uniqueOptionType,
|
||||
isCustomValue: false,
|
||||
});
|
||||
} else {
|
||||
updateFilter({
|
||||
...filter,
|
||||
value: val?.value,
|
||||
valueType: val?.type || uniqueOptionType,
|
||||
isCustomValue: false,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onCreateOption={(val) => {
|
||||
updateFilter({
|
||||
...filter,
|
||||
value: Array.isArray(filter.value) ? filter.value?.concat(val) : val,
|
||||
valueType: uniqueOptionType,
|
||||
isCustomValue: true,
|
||||
});
|
||||
}}
|
||||
placeholder="Select value"
|
||||
isClearable={true}
|
||||
aria-label={`select ${filter.id} value`}
|
||||
allowCustomValue={allowCustomValue}
|
||||
isMulti={isMulti}
|
||||
allowCreateWhileLoading
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
{alertText && <TemporaryAlert severity="error" text={alertText} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchField;
|
||||
|
||||
/**
|
||||
* Add to a list of options the current template variables.
|
||||
*
|
||||
* @param options a list of options
|
||||
* @returns the list of given options plus the template variables
|
||||
*/
|
||||
const withTemplateVariableOptions = (options: SelectableValue[] | undefined) => {
|
||||
const templateVariables = getTemplateSrv().getVariables();
|
||||
return [...(options || []), ...templateVariables.map((v) => ({ label: `$${v.name}`, value: `$${v.name}` }))];
|
||||
};
|
||||
|
||||
const getStyles = () => ({
|
||||
dropdown: css({
|
||||
boxShadow: 'none',
|
||||
}),
|
||||
});
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { type TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen';
|
||||
import { type TempoDatasource } from '../datasource';
|
||||
import TempoLanguageProvider from '../language_provider';
|
||||
import { initTemplateSrv } from '../test/test_utils';
|
||||
import { type Scope } from '../types';
|
||||
|
||||
import TagsInput from './TagsInput';
|
||||
import { v2Tags } from './mocks';
|
||||
|
||||
describe('TagsInput', () => {
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
|
||||
beforeEach(() => {
|
||||
const expectedValues = {
|
||||
interpolationVar: 'interpolationText',
|
||||
interpolationText: 'interpolationText',
|
||||
interpolationVarWithPipe: 'interpolationTextOne|interpolationTextTwo',
|
||||
scopedInterpolationText: 'scopedInterpolationText',
|
||||
};
|
||||
initTemplateSrv([{ name: 'templateVariable1' }, { name: 'templateVariable2' }], expectedValues);
|
||||
|
||||
jest.useFakeTimers();
|
||||
// Need to use delay: null here to work with fakeTimers
|
||||
// see https://github.com/testing-library/user-event/issues/833
|
||||
user = userEvent.setup({ delay: null });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('should render correct tags', () => {
|
||||
it('for API v2 tags with scope of resource', async () => {
|
||||
renderTagsInput(v2Tags, TraceqlSearchScope.Resource);
|
||||
|
||||
const tag = screen.getByText('Select tag');
|
||||
expect(tag).toBeInTheDocument();
|
||||
await user.click(tag);
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('cluster')).toBeInTheDocument();
|
||||
expect(screen.getByText('container')).toBeInTheDocument();
|
||||
expect(screen.getByText('$templateVariable1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('for API v2 tags with scope of span', async () => {
|
||||
renderTagsInput(v2Tags, TraceqlSearchScope.Span);
|
||||
|
||||
const tag = screen.getByText('Select tag');
|
||||
expect(tag).toBeInTheDocument();
|
||||
await user.click(tag);
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('db')).toBeInTheDocument();
|
||||
expect(screen.getByText('$templateVariable1')).toBeInTheDocument();
|
||||
expect(screen.getByText('$templateVariable2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('for API v2 tags with scope of unscoped', async () => {
|
||||
renderTagsInput(v2Tags, TraceqlSearchScope.Unscoped);
|
||||
|
||||
const tag = screen.getByText('Select tag');
|
||||
expect(tag).toBeInTheDocument();
|
||||
await user.click(tag);
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('cluster')).toBeInTheDocument();
|
||||
expect(screen.getByText('container')).toBeInTheDocument();
|
||||
expect(screen.getByText('db')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const renderTagsInput = (tagsV2?: Scope[], scope?: TraceqlSearchScope) => {
|
||||
const datasource: TempoDatasource = {
|
||||
search: {
|
||||
filters: [],
|
||||
},
|
||||
} as unknown as TempoDatasource;
|
||||
|
||||
const lp = new TempoLanguageProvider(datasource);
|
||||
if (tagsV2) {
|
||||
lp.setV2Tags(tagsV2);
|
||||
}
|
||||
datasource.languageProvider = lp;
|
||||
|
||||
const filter: TraceqlFilter = {
|
||||
id: 'id',
|
||||
valueType: 'string',
|
||||
scope,
|
||||
};
|
||||
|
||||
render(
|
||||
<TagsInput
|
||||
datasource={datasource}
|
||||
updateFilter={jest.fn}
|
||||
deleteFilter={jest.fn}
|
||||
filters={[filter]}
|
||||
setError={() => {}}
|
||||
staticTags={[]}
|
||||
isTagsLoading={false}
|
||||
generateQueryWithoutFilter={() => ''}
|
||||
addVariablesToOptions={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
import { css } from '@emotion/css';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
import { type GrafanaTheme2, type TimeRange, generateUUID } from '@grafana/data';
|
||||
import { AccessoryButton } from '@grafana/plugin-ui';
|
||||
import { type FetchError } from '@grafana/runtime';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { type TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen';
|
||||
import { type TempoDatasource } from '../datasource';
|
||||
|
||||
import SearchField from './SearchField';
|
||||
import { getFilteredTags } from './utils';
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
vertical: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(0.25),
|
||||
}),
|
||||
horizontal: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: theme.spacing(1),
|
||||
}),
|
||||
addTag: css({
|
||||
marginLeft: theme.spacing(1),
|
||||
}),
|
||||
});
|
||||
|
||||
interface Props {
|
||||
updateFilter: (f: TraceqlFilter) => void;
|
||||
deleteFilter: (f: TraceqlFilter) => void;
|
||||
generateQueryWithoutFilter: (f?: TraceqlFilter) => string;
|
||||
filters: TraceqlFilter[];
|
||||
datasource: TempoDatasource;
|
||||
setError: (error: FetchError | null) => void;
|
||||
staticTags: Array<string | undefined>;
|
||||
isTagsLoading: boolean;
|
||||
hideValues?: boolean;
|
||||
requireTagAndValue?: boolean;
|
||||
addVariablesToOptions?: boolean;
|
||||
range?: TimeRange;
|
||||
timeRangeForTags?: number;
|
||||
}
|
||||
const TagsInput = ({
|
||||
updateFilter,
|
||||
deleteFilter,
|
||||
filters,
|
||||
datasource,
|
||||
setError,
|
||||
staticTags,
|
||||
isTagsLoading,
|
||||
hideValues,
|
||||
requireTagAndValue,
|
||||
generateQueryWithoutFilter,
|
||||
addVariablesToOptions,
|
||||
range,
|
||||
timeRangeForTags,
|
||||
}: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const handleOnAdd = useCallback(
|
||||
() => updateFilter({ id: generateId(), operator: '=', scope: TraceqlSearchScope.Span }),
|
||||
[updateFilter]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!filters?.length) {
|
||||
handleOnAdd();
|
||||
}
|
||||
}, [filters, handleOnAdd]);
|
||||
|
||||
const getTags = (f: TraceqlFilter) => {
|
||||
const tags = datasource.languageProvider.getTags(f.scope);
|
||||
return getFilteredTags(tags, staticTags);
|
||||
};
|
||||
|
||||
const validInput = (f: TraceqlFilter) => {
|
||||
// If value is removed from the filter, it can be set as an empty array
|
||||
return requireTagAndValue ? f.tag && f.value && f.value.length > 0 : f.tag;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.vertical}>
|
||||
{filters?.map((f, i) => (
|
||||
<div className={styles.horizontal} key={f.id}>
|
||||
<SearchField
|
||||
filter={f}
|
||||
datasource={datasource}
|
||||
setError={setError}
|
||||
updateFilter={updateFilter}
|
||||
tags={getTags(f)}
|
||||
isTagsLoading={isTagsLoading}
|
||||
hideValue={hideValues}
|
||||
query={generateQueryWithoutFilter(f)}
|
||||
addVariablesToOptions={addVariablesToOptions}
|
||||
range={range}
|
||||
timeRangeForTags={timeRangeForTags}
|
||||
/>
|
||||
{(validInput(f) || filters.length > 1) && (
|
||||
<AccessoryButton
|
||||
aria-label={`Remove tag with ID ${f.id}`}
|
||||
variant={'secondary'}
|
||||
icon={'times'}
|
||||
onClick={() => deleteFilter?.(f)}
|
||||
tooltip={'Remove tag'}
|
||||
/>
|
||||
)}
|
||||
{validInput(f) && i === filters.length - 1 && (
|
||||
<span className={styles.addTag}>
|
||||
<AccessoryButton
|
||||
aria-label="Add tag"
|
||||
variant={'secondary'}
|
||||
icon={'plus'}
|
||||
onClick={handleOnAdd}
|
||||
tooltip={'Add tag'}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagsInput;
|
||||
|
||||
export const generateId = () => generateUUID().slice(0, 8);
|
||||
|
|
@ -1,263 +0,0 @@
|
|||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { TraceqlSearchScope } from '../dataquery.gen';
|
||||
import { type TempoDatasource } from '../datasource';
|
||||
import TempoLanguageProvider from '../language_provider';
|
||||
import { initTemplateSrv } from '../test/test_utils';
|
||||
import { type TempoQuery } from '../types';
|
||||
|
||||
import TraceQLSearch from './TraceQLSearch';
|
||||
|
||||
const getOptionsV2 = jest.fn().mockImplementation(() => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve([
|
||||
{
|
||||
value: 'customer',
|
||||
label: 'customer',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
value: 'driver',
|
||||
label: 'driver',
|
||||
type: 'string',
|
||||
},
|
||||
]);
|
||||
}, 1000);
|
||||
});
|
||||
});
|
||||
|
||||
const getTags = jest.fn().mockImplementation(() => {
|
||||
return ['foo', 'bar'];
|
||||
});
|
||||
|
||||
jest.mock('../language_provider', () => {
|
||||
return jest.fn().mockImplementation(() => {
|
||||
return { getOptionsV2, getTags };
|
||||
});
|
||||
});
|
||||
|
||||
describe('TraceQLSearch', () => {
|
||||
const expectedValues = {
|
||||
interpolationVar: 'interpolationText',
|
||||
interpolationText: 'interpolationText',
|
||||
interpolationVarWithPipe: 'interpolationTextOne|interpolationTextTwo',
|
||||
scopedInterpolationText: 'scopedInterpolationText',
|
||||
};
|
||||
initTemplateSrv([{ name: 'templateVariable1' }, { name: 'templateVariable2' }], expectedValues);
|
||||
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
|
||||
const datasource: TempoDatasource = {
|
||||
search: {
|
||||
filters: [
|
||||
{
|
||||
id: 'service-name',
|
||||
tag: 'service.name',
|
||||
operator: '=',
|
||||
scope: TraceqlSearchScope.Resource,
|
||||
},
|
||||
],
|
||||
},
|
||||
} as TempoDatasource;
|
||||
datasource.isStreamingSearchEnabled = () => false;
|
||||
datasource.isStreamingMetricsEnabled = () => false;
|
||||
const lp = new TempoLanguageProvider(datasource);
|
||||
lp.getIntrinsics = () => ['duration'];
|
||||
lp.generateQueryFromFilters = () => '{}';
|
||||
datasource.languageProvider = lp;
|
||||
let query: TempoQuery = {
|
||||
refId: 'A',
|
||||
queryType: 'traceqlSearch',
|
||||
key: 'Q-595a9bbc-2a25-49a7-9249-a52a0a475d83-0',
|
||||
query: '',
|
||||
filters: [{ id: 'min-duration', operator: '>', valueType: 'duration', tag: 'duration' }],
|
||||
};
|
||||
const onChange = (q: TempoQuery) => {
|
||||
query = q;
|
||||
};
|
||||
const onClearResults = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
// Need to use delay: null here to work with fakeTimers
|
||||
// see https://github.com/testing-library/user-event/issues/833
|
||||
user = userEvent.setup({ delay: null });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should only show add/remove tag when necessary', async () => {
|
||||
const TraceQLSearchWithProps = () => {
|
||||
const [query, setQuery] = useState<TempoQuery>({
|
||||
refId: 'A',
|
||||
queryType: 'traceqlSearch',
|
||||
key: 'Q-595a9bbc-2a25-49a7-9249-a52a0a475d83-0',
|
||||
filters: [],
|
||||
});
|
||||
return (
|
||||
<TraceQLSearch
|
||||
datasource={datasource}
|
||||
query={query}
|
||||
onChange={(q: TempoQuery) => setQuery(q)}
|
||||
onClearResults={onClearResults}
|
||||
/>
|
||||
);
|
||||
};
|
||||
render(<TraceQLSearchWithProps />);
|
||||
|
||||
await act(async () => {
|
||||
expect(screen.queryAllByLabelText('Add tag').length).toBe(0); // not filled in the default tag, so no need to add another one
|
||||
expect(screen.queryAllByLabelText('Remove tag').length).toBe(0); // mot filled in the default tag, so no values to remove
|
||||
expect(screen.getAllByText('Select tag').length).toBe(1);
|
||||
});
|
||||
|
||||
await user.click(screen.getByText('Select tag'));
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
await user.click(screen.getByText('foo'));
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
await user.click(screen.getAllByText('Select value')[2]);
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
await user.click(screen.getByText('driver'));
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
await act(async () => {
|
||||
expect(screen.getAllByLabelText('Add tag').length).toBe(1);
|
||||
expect(screen.getAllByLabelText(/Remove tag/).length).toBe(1);
|
||||
});
|
||||
|
||||
await user.click(screen.getByLabelText('Add tag'));
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(screen.queryAllByLabelText('Add tag').length).toBe(0); // not filled in the new tag, so no need to add another one
|
||||
expect(screen.getAllByLabelText(/Remove tag/).length).toBe(2); // one for each tag
|
||||
|
||||
await user.click(screen.getAllByLabelText(/Remove tag/)[1]);
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(screen.queryAllByLabelText('Add tag').length).toBe(1); // filled in the default tag, so can add another one
|
||||
expect(screen.queryAllByLabelText(/Remove tag/).length).toBe(1); // filled in the default tag, so can remove values
|
||||
|
||||
await user.click(screen.getAllByLabelText(/Remove tag/)[0]);
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
expect(screen.queryAllByLabelText('Add tag').length).toBe(0); // not filled in the default tag, so no need to add another one
|
||||
expect(screen.queryAllByLabelText(/Remove tag/).length).toBe(0); // mot filled in the default tag, so no values to remove
|
||||
});
|
||||
});
|
||||
|
||||
it('should update operator when new value is selected in operator input', async () => {
|
||||
const { container } = render(
|
||||
<TraceQLSearch datasource={datasource} query={query} onChange={onChange} onClearResults={onClearResults} />
|
||||
);
|
||||
|
||||
const minDurationOperator = container.querySelector(`input[aria-label="select min-duration operator"]`);
|
||||
expect(minDurationOperator).not.toBeNull();
|
||||
expect(minDurationOperator).toBeInTheDocument();
|
||||
|
||||
if (minDurationOperator) {
|
||||
await user.click(minDurationOperator);
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
const regexOp = await screen.findByText('>=');
|
||||
await user.click(regexOp);
|
||||
const minDurationFilter = query.filters.find((f) => f.id === 'min-duration');
|
||||
expect(minDurationFilter).not.toBeNull();
|
||||
expect(minDurationFilter?.operator).toBe('>=');
|
||||
}
|
||||
});
|
||||
|
||||
it('should add new filter when new value is selected in the service name section', async () => {
|
||||
const { container } = render(
|
||||
<TraceQLSearch datasource={datasource} query={query} onChange={onChange} onClearResults={onClearResults} />
|
||||
);
|
||||
const serviceNameValue = container.querySelector(`input[aria-label="select service-name value"]`);
|
||||
expect(serviceNameValue).not.toBeNull();
|
||||
expect(serviceNameValue).toBeInTheDocument();
|
||||
|
||||
expect(query.filters.find((f) => f.id === 'service-name')).not.toBeDefined();
|
||||
|
||||
if (serviceNameValue) {
|
||||
await user.click(serviceNameValue);
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
const customerValue = await screen.findByText('customer');
|
||||
await user.click(customerValue);
|
||||
const nameFilter = query.filters.find((f) => f.id === 'service-name');
|
||||
expect(nameFilter).not.toBeNull();
|
||||
expect(nameFilter?.operator).toBe('=');
|
||||
expect(nameFilter?.value).toStrictEqual(['customer']);
|
||||
expect(nameFilter?.tag).toBe('service.name');
|
||||
expect(nameFilter?.scope).toBe(TraceqlSearchScope.Resource);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not render static filter when no tag is configured', async () => {
|
||||
const datasource: TempoDatasource = {
|
||||
search: {
|
||||
filters: [
|
||||
{
|
||||
id: 'service-name',
|
||||
operator: '=',
|
||||
scope: TraceqlSearchScope.Resource,
|
||||
},
|
||||
],
|
||||
},
|
||||
} as TempoDatasource;
|
||||
datasource.isStreamingSearchEnabled = () => false;
|
||||
datasource.isStreamingMetricsEnabled = () => false;
|
||||
|
||||
const lp = new TempoLanguageProvider(datasource);
|
||||
lp.getIntrinsics = () => ['duration'];
|
||||
lp.generateQueryFromFilters = () => '{}';
|
||||
datasource.languageProvider = lp;
|
||||
await act(async () => {
|
||||
const { container } = render(
|
||||
<TraceQLSearch datasource={datasource} query={query} onChange={onChange} onClearResults={onClearResults} />
|
||||
);
|
||||
const serviceNameValue = container.querySelector(`input[aria-label="select service-name value"]`);
|
||||
expect(serviceNameValue).toBeNull();
|
||||
expect(serviceNameValue).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not render group by alert when query does not contain group by', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<TraceQLSearch datasource={datasource} query={query} onChange={onChange} onClearResults={onClearResults} />
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: 'Remove aggregate by from this query' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render group by alert when query contains group by', async () => {
|
||||
const onChange = jest.fn();
|
||||
await waitFor(async () => {
|
||||
render(
|
||||
<TraceQLSearch
|
||||
datasource={datasource}
|
||||
query={{ ...query, groupBy: [] }}
|
||||
onChange={onChange}
|
||||
onClearResults={onClearResults}
|
||||
/>
|
||||
);
|
||||
const button = screen.queryByRole('button', { name: 'Remove aggregate by from this query' });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,335 +0,0 @@
|
|||
import { css } from '@emotion/css';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { type CoreApp, type GrafanaTheme2, type TimeRange } from '@grafana/data';
|
||||
import { TemporaryAlert } from '@grafana/o11y-ds-frontend';
|
||||
import { config, type FetchError, getTemplateSrv, reportInteraction } from '@grafana/runtime';
|
||||
import { Alert, Button, Stack, Select, useStyles2, TextLink } from '@grafana/ui';
|
||||
|
||||
import { RawQuery } from '../_importedDependencies/datasources/prometheus/RawQuery';
|
||||
import { type TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen';
|
||||
import { type TempoDatasource } from '../datasource';
|
||||
import { TempoQueryBuilderOptions } from '../traceql/TempoQueryBuilderOptions';
|
||||
import { traceqlGrammar } from '../traceql/traceql';
|
||||
import { type TempoQuery } from '../types';
|
||||
|
||||
import { AggregateByAlert } from './AggregateByAlert';
|
||||
import DurationInput from './DurationInput';
|
||||
import InlineSearchField from './InlineSearchField';
|
||||
import SearchField from './SearchField';
|
||||
import TagsInput from './TagsInput';
|
||||
import { filterScopedTag, filterTitle, interpolateFilters, replaceAt } from './utils';
|
||||
|
||||
interface Props {
|
||||
datasource: TempoDatasource;
|
||||
query: TempoQuery;
|
||||
onChange: (value: TempoQuery) => void;
|
||||
onBlur?: () => void;
|
||||
onClearResults: () => void;
|
||||
app?: CoreApp;
|
||||
addVariablesToOptions?: boolean;
|
||||
range?: TimeRange;
|
||||
}
|
||||
|
||||
const hardCodedFilterIds = ['min-duration', 'max-duration', 'status'];
|
||||
|
||||
const TraceQLSearch = ({
|
||||
datasource,
|
||||
query,
|
||||
onChange,
|
||||
onClearResults,
|
||||
app,
|
||||
addVariablesToOptions = true,
|
||||
range,
|
||||
}: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const [alertText, setAlertText] = useState<string>();
|
||||
const [error, setError] = useState<Error | FetchError | null>(null);
|
||||
|
||||
const [isTagsLoading, setIsTagsLoading] = useState(true);
|
||||
const [traceQlQuery, setTraceQlQuery] = useState<string>('');
|
||||
|
||||
const templateSrv = getTemplateSrv();
|
||||
|
||||
const updateFilter = useCallback(
|
||||
(s: TraceqlFilter) => {
|
||||
const copy = { ...query };
|
||||
copy.filters ||= [];
|
||||
const indexOfFilter = copy.filters.findIndex((f) => f.id === s.id);
|
||||
if (indexOfFilter >= 0) {
|
||||
// update in place if the filter already exists, for consistency and to avoid UI bugs
|
||||
copy.filters = replaceAt(copy.filters, indexOfFilter, s);
|
||||
} else {
|
||||
copy.filters.push(s);
|
||||
}
|
||||
onChange(copy);
|
||||
},
|
||||
[onChange, query]
|
||||
);
|
||||
|
||||
const deleteFilter = (s: TraceqlFilter) => {
|
||||
onChange({ ...query, filters: query.filters.filter((f) => f.id !== s.id) });
|
||||
};
|
||||
|
||||
const templateVariables = getTemplateSrv().getVariables();
|
||||
useEffect(() => {
|
||||
setTraceQlQuery(
|
||||
datasource.languageProvider.generateQueryFromFilters({ traceqlFilters: interpolateFilters(query.filters || []) })
|
||||
);
|
||||
}, [datasource.languageProvider, query, templateVariables]);
|
||||
|
||||
const findFilter = useCallback((id: string) => query.filters?.find((f) => f.id === id), [query.filters]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTags = async () => {
|
||||
try {
|
||||
await datasource.languageProvider.start(range, datasource.timeRangeForTags);
|
||||
setIsTagsLoading(false);
|
||||
setAlertText(undefined);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
setAlertText(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
fetchTags();
|
||||
}, [datasource, setAlertText, range, datasource.timeRangeForTags]);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize state with configured static filters that already have a value from the config
|
||||
datasource.search?.filters
|
||||
?.filter((f) => f.value)
|
||||
.forEach((f) => {
|
||||
if (!findFilter(f.id)) {
|
||||
updateFilter(f);
|
||||
}
|
||||
});
|
||||
}, [datasource.search?.filters, findFilter, updateFilter]);
|
||||
|
||||
// filter out tags that already exist in the static fields
|
||||
const staticTags = datasource.search?.filters?.map((f) => f.tag) || [];
|
||||
staticTags.push('duration');
|
||||
staticTags.push('traceDuration');
|
||||
staticTags.push('span:duration');
|
||||
staticTags.push('trace:duration');
|
||||
staticTags.push('status');
|
||||
staticTags.push('span:status');
|
||||
|
||||
// Dynamic filters are all filters that don't match the ID of a filter in the datasource configuration
|
||||
// The duration and status fields are a special case since its selector is hard-coded
|
||||
const dynamicFilters = (query.filters || []).filter(
|
||||
(f) =>
|
||||
!hardCodedFilterIds.includes(f.id) &&
|
||||
(datasource.search?.filters?.findIndex((sf) => sf.id === f.id) || 0) === -1 &&
|
||||
f.id !== 'duration-type'
|
||||
);
|
||||
|
||||
// We use this function to generate queries without a specfic filter.
|
||||
// This is useful because we're sending the query to Tempo so it can return the attributes and values filtered down.
|
||||
// However, if we send the full query then we won't see more values for the filter we're trying to edit.
|
||||
// For example, if we already have a service.name value selected and try to add another one, we won't see the other
|
||||
// values if we send the full query since Tempo will only return the service.name that's already selected.
|
||||
const generateQueryWithoutFilter = (filter?: TraceqlFilter) => {
|
||||
if (!filter) {
|
||||
return traceQlQuery;
|
||||
}
|
||||
const filtersAfterRemoval = query.filters?.filter((f) => f.id !== filter.id) || [];
|
||||
return datasource.languageProvider.generateQueryFromFilters({
|
||||
traceqlFilters: interpolateFilters(filtersAfterRemoval || []),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.container}>
|
||||
<div>
|
||||
{datasource.search?.filters?.map(
|
||||
(f) =>
|
||||
f.tag && (
|
||||
<InlineSearchField
|
||||
key={f.id}
|
||||
label={filterTitle(f, datasource.languageProvider)}
|
||||
tooltip={`Filter your search by ${filterScopedTag(
|
||||
f,
|
||||
datasource.languageProvider
|
||||
)}. To modify the default filters shown for search visit the Tempo datasource configuration page.`}
|
||||
>
|
||||
<SearchField
|
||||
filter={findFilter(f.id) || f}
|
||||
datasource={datasource}
|
||||
setError={setError}
|
||||
updateFilter={updateFilter}
|
||||
tags={[]}
|
||||
hideScope={true}
|
||||
hideTag={true}
|
||||
query={generateQueryWithoutFilter(findFilter(f.id))}
|
||||
addVariablesToOptions={addVariablesToOptions}
|
||||
range={range}
|
||||
timeRangeForTags={datasource.timeRangeForTags}
|
||||
/>
|
||||
</InlineSearchField>
|
||||
)
|
||||
)}
|
||||
<InlineSearchField label={'Status'}>
|
||||
<SearchField
|
||||
filter={
|
||||
findFilter('status') || {
|
||||
id: 'status',
|
||||
tag: 'status',
|
||||
scope: TraceqlSearchScope.Intrinsic,
|
||||
operator: '=',
|
||||
}
|
||||
}
|
||||
datasource={datasource}
|
||||
setError={setError}
|
||||
updateFilter={updateFilter}
|
||||
tags={[]}
|
||||
hideScope={true}
|
||||
hideTag={true}
|
||||
query={generateQueryWithoutFilter(findFilter('status'))}
|
||||
isMulti={false}
|
||||
allowCustomValue={false}
|
||||
addVariablesToOptions={addVariablesToOptions}
|
||||
range={range}
|
||||
timeRangeForTags={datasource.timeRangeForTags}
|
||||
/>
|
||||
</InlineSearchField>
|
||||
<InlineSearchField
|
||||
label={'Duration'}
|
||||
tooltip="The trace or span duration, i.e. end - start time of the trace/span. Accepted units are ns, ms, s, m, h"
|
||||
>
|
||||
<Stack gap={0}>
|
||||
<Select
|
||||
width="auto"
|
||||
options={[
|
||||
{ label: 'span', value: 'span' },
|
||||
{ label: 'trace', value: 'trace' },
|
||||
]}
|
||||
value={findFilter('duration-type')?.value ?? 'span'}
|
||||
onChange={(v) => {
|
||||
const filter = findFilter('duration-type') || {
|
||||
id: 'duration-type',
|
||||
value: 'span',
|
||||
};
|
||||
updateFilter({ ...filter, value: v?.value });
|
||||
}}
|
||||
aria-label={'duration type'}
|
||||
/>
|
||||
<DurationInput
|
||||
filter={
|
||||
findFilter('min-duration') || {
|
||||
id: 'min-duration',
|
||||
tag: 'duration',
|
||||
operator: '>',
|
||||
valueType: 'duration',
|
||||
}
|
||||
}
|
||||
operators={['>', '>=']}
|
||||
updateFilter={updateFilter}
|
||||
/>
|
||||
<DurationInput
|
||||
filter={
|
||||
findFilter('max-duration') || {
|
||||
id: 'max-duration',
|
||||
tag: 'duration',
|
||||
operator: '<',
|
||||
valueType: 'duration',
|
||||
}
|
||||
}
|
||||
operators={['<', '<=']}
|
||||
updateFilter={updateFilter}
|
||||
/>
|
||||
</Stack>
|
||||
</InlineSearchField>
|
||||
<InlineSearchField label={'Tags'}>
|
||||
<TagsInput
|
||||
filters={dynamicFilters}
|
||||
datasource={datasource}
|
||||
setError={setError}
|
||||
updateFilter={updateFilter}
|
||||
deleteFilter={deleteFilter}
|
||||
staticTags={staticTags}
|
||||
isTagsLoading={isTagsLoading}
|
||||
generateQueryWithoutFilter={generateQueryWithoutFilter}
|
||||
requireTagAndValue={true}
|
||||
addVariablesToOptions={addVariablesToOptions}
|
||||
range={range}
|
||||
timeRangeForTags={datasource.timeRangeForTags}
|
||||
/>
|
||||
</InlineSearchField>
|
||||
<AggregateByAlert
|
||||
query={query}
|
||||
onChange={() => {
|
||||
delete query.groupBy;
|
||||
onChange({
|
||||
...query,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.rawQueryContainer}>
|
||||
<RawQuery query={templateSrv.replace(traceQlQuery)} lang={{ grammar: traceqlGrammar, name: 'traceql' }} />
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
reportInteraction('grafana_traces_copy_to_traceql_clicked', {
|
||||
app: app ?? '',
|
||||
grafana_version: config.buildInfo.version,
|
||||
location: 'search_tab',
|
||||
});
|
||||
|
||||
onClearResults();
|
||||
const traceQlQuery = datasource.languageProvider.generateQueryFromFilters({
|
||||
traceqlFilters: query.filters || [],
|
||||
});
|
||||
onChange({
|
||||
...query,
|
||||
query: traceQlQuery,
|
||||
queryType: 'traceql',
|
||||
});
|
||||
}}
|
||||
>
|
||||
Edit in TraceQL
|
||||
</Button>
|
||||
</div>
|
||||
<TempoQueryBuilderOptions
|
||||
onChange={onChange}
|
||||
query={query}
|
||||
searchStreaming={datasource.isStreamingSearchEnabled() ?? false}
|
||||
metricsStreaming={datasource.isStreamingMetricsEnabled() ?? false}
|
||||
app={app}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<Alert title="Unable to connect to Tempo search" severity="info" className={styles.alert}>
|
||||
Please ensure that Tempo is configured with search enabled. If you would like to hide this tab, you can
|
||||
configure it in the <TextLink href={`/datasources/edit/${datasource.uid}`}>datasource settings</TextLink>.
|
||||
</Alert>
|
||||
) : null}
|
||||
{alertText && <TemporaryAlert severity={'error'} text={alertText} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TraceQLSearch;
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
alert: css({
|
||||
maxWidth: '75ch',
|
||||
marginTop: theme.spacing(2),
|
||||
}),
|
||||
container: css({
|
||||
display: 'flex',
|
||||
gap: '4px',
|
||||
flexWrap: 'wrap',
|
||||
flexDirection: 'column',
|
||||
}),
|
||||
rawQueryContainer: css({
|
||||
alignItems: 'center',
|
||||
backgroundColor: theme.colors.background.secondary,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
padding: theme.spacing(1),
|
||||
}),
|
||||
});
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import { uniq } from 'lodash';
|
||||
|
||||
import { intrinsics } from '../traceql/traceql';
|
||||
|
||||
export const testIntrinsics = uniq(['duration', 'kind', 'name', 'status'].concat(intrinsics));
|
||||
|
||||
export const v1Tags = ['bar', 'foo'];
|
||||
|
||||
export const v2Tags = [
|
||||
{
|
||||
name: 'resource',
|
||||
tags: ['cluster', 'container'],
|
||||
},
|
||||
{
|
||||
name: 'span',
|
||||
tags: ['db'],
|
||||
},
|
||||
{
|
||||
name: 'intrinsic',
|
||||
tags: testIntrinsics,
|
||||
},
|
||||
];
|
||||
|
||||
export const emptyTags = [];
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
import { uniq } from 'lodash';
|
||||
|
||||
import { type TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen';
|
||||
import { type TempoDatasource } from '../datasource';
|
||||
import TempoLanguageProvider from '../language_provider';
|
||||
import { intrinsics } from '../traceql/traceql';
|
||||
|
||||
import { emptyTags, testIntrinsics, v1Tags, v2Tags } from './mocks';
|
||||
import {
|
||||
filterToQuerySection,
|
||||
getAllTags,
|
||||
getFilteredTags,
|
||||
getIntrinsicTags,
|
||||
getTagsByScope,
|
||||
getUnscopedTags,
|
||||
} from './utils';
|
||||
|
||||
const datasource: TempoDatasource = {
|
||||
search: {
|
||||
filters: [],
|
||||
},
|
||||
} as unknown as TempoDatasource;
|
||||
const lp = new TempoLanguageProvider(datasource);
|
||||
|
||||
describe('gets correct tags', () => {
|
||||
const datasource: TempoDatasource = {
|
||||
search: {
|
||||
filters: [],
|
||||
},
|
||||
} as unknown as TempoDatasource;
|
||||
const lp = new TempoLanguageProvider(datasource);
|
||||
|
||||
it('for filtered tags when no tags supplied', () => {
|
||||
const tags = getFilteredTags(emptyTags, []);
|
||||
expect(tags).toEqual([]);
|
||||
});
|
||||
|
||||
it('for filtered tags when API v1 tags supplied', () => {
|
||||
const tags = getFilteredTags(v1Tags, []);
|
||||
expect(tags).toEqual(['bar', 'foo']);
|
||||
});
|
||||
|
||||
it('for filtered tags when API v1 tags supplied with tags to filter out', () => {
|
||||
const tags = getFilteredTags(v1Tags, ['foo']);
|
||||
expect(tags).toEqual(['bar']);
|
||||
});
|
||||
|
||||
it('for filtered tags when API v2 tags supplied', () => {
|
||||
const tags = getFilteredTags(uniq(getUnscopedTags(v2Tags)), []);
|
||||
expect(tags).toEqual(['cluster', 'container', 'db']);
|
||||
});
|
||||
|
||||
it('for filtered tags when API v2 tags supplied with tags to filter out', () => {
|
||||
const tags = getFilteredTags(getUnscopedTags(v2Tags), ['cluster']);
|
||||
expect(tags).toEqual(['container', 'db']);
|
||||
});
|
||||
|
||||
it('for filtered tags when API v2 tags set', () => {
|
||||
lp.setV2Tags(v2Tags);
|
||||
const tags = getFilteredTags(uniq(getUnscopedTags(v2Tags)), []);
|
||||
expect(tags).toEqual(['cluster', 'container', 'db']);
|
||||
});
|
||||
|
||||
it('for unscoped tags', () => {
|
||||
const tags = getUnscopedTags(v2Tags);
|
||||
expect(tags).toEqual(['cluster', 'container', 'db']);
|
||||
});
|
||||
|
||||
it('for all tags', () => {
|
||||
const tags = getAllTags(v2Tags);
|
||||
expect(tags).toEqual(uniq(['cluster', 'container', 'db', 'duration', 'kind', 'name', 'status'].concat(intrinsics)));
|
||||
});
|
||||
|
||||
it('for tags by resource scope', () => {
|
||||
const tags = getTagsByScope(v2Tags, TraceqlSearchScope.Resource);
|
||||
expect(tags).toEqual(['cluster', 'container']);
|
||||
});
|
||||
|
||||
it('for tags by span scope', () => {
|
||||
const tags = getTagsByScope(v2Tags, TraceqlSearchScope.Span);
|
||||
expect(tags).toEqual(['db']);
|
||||
});
|
||||
|
||||
it('for intrinsic tags', () => {
|
||||
const tags = getIntrinsicTags(v2Tags);
|
||||
expect(tags).toEqual(testIntrinsics);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterToQuerySection returns the correct query section for a filter', () => {
|
||||
it('filter with single value', () => {
|
||||
const filter: TraceqlFilter = { id: 'abc', tag: 'foo', operator: '=', value: 'bar' };
|
||||
const result = filterToQuerySection(filter, [], lp);
|
||||
expect(result).toBe('.foo=bar');
|
||||
});
|
||||
|
||||
it('filter with regex operator', () => {
|
||||
const filter: TraceqlFilter = { id: 'abc', tag: 'foo', operator: '=~', value: 'bar.*', valueType: 'string' };
|
||||
const result = filterToQuerySection(filter, [], lp);
|
||||
expect(result).toBe('.foo=~"bar.*"');
|
||||
});
|
||||
|
||||
it('filter with scope', () => {
|
||||
const filter: TraceqlFilter = {
|
||||
id: 'abc',
|
||||
tag: 'foo',
|
||||
operator: '=',
|
||||
value: 'bar',
|
||||
scope: TraceqlSearchScope.Resource,
|
||||
};
|
||||
const result = filterToQuerySection(filter, [], lp);
|
||||
expect(result).toBe('resource.foo=bar');
|
||||
});
|
||||
|
||||
it('filter with intrinsic tag', () => {
|
||||
const filter: TraceqlFilter = { id: 'abc', tag: 'duration', operator: '=', value: '100ms' };
|
||||
const result = filterToQuerySection(filter, [], lp);
|
||||
expect(result).toBe('duration=100ms');
|
||||
});
|
||||
|
||||
it('filter with multiple non-string values and scope', () => {
|
||||
const filter: TraceqlFilter = {
|
||||
id: 'abc',
|
||||
tag: 'foo',
|
||||
operator: '=',
|
||||
value: ['bar', 'baz'],
|
||||
scope: TraceqlSearchScope.Span,
|
||||
};
|
||||
const result = filterToQuerySection(filter, [], lp);
|
||||
expect(result).toBe('(span.foo=bar || span.foo=baz)');
|
||||
});
|
||||
|
||||
it('filter with multiple string values and scope', () => {
|
||||
const filter: TraceqlFilter = {
|
||||
id: 'abc',
|
||||
tag: 'foo',
|
||||
operator: '=',
|
||||
value: ['bar', 'baz'],
|
||||
scope: TraceqlSearchScope.Span,
|
||||
valueType: 'string',
|
||||
};
|
||||
const result = filterToQuerySection(filter, [], lp);
|
||||
expect(result).toBe('(span.foo="bar" || span.foo="baz")');
|
||||
});
|
||||
|
||||
it('filter with multiple string values with regex', () => {
|
||||
const filter: TraceqlFilter = {
|
||||
id: 'abc',
|
||||
tag: 'foo',
|
||||
operator: '=~',
|
||||
value: ['bar', 'baz'],
|
||||
scope: TraceqlSearchScope.Span,
|
||||
valueType: 'string',
|
||||
};
|
||||
const result = filterToQuerySection(filter, [], lp);
|
||||
expect(result).toBe('span.foo=~"bar|baz"');
|
||||
});
|
||||
|
||||
it('filter with multiple values and != operator', () => {
|
||||
const filter: TraceqlFilter = {
|
||||
id: 'abc',
|
||||
tag: 'foo',
|
||||
operator: '!=',
|
||||
value: ['bar', 'baz'],
|
||||
scope: TraceqlSearchScope.Span,
|
||||
};
|
||||
const result = filterToQuerySection(filter, [], lp);
|
||||
expect(result).toBe('(span.foo!=bar && span.foo!=baz)');
|
||||
});
|
||||
|
||||
it('filter with multiple string values and != operator', () => {
|
||||
const filter: TraceqlFilter = {
|
||||
id: 'abc',
|
||||
tag: 'foo',
|
||||
operator: '!=',
|
||||
value: ['bar', 'baz'],
|
||||
scope: TraceqlSearchScope.Span,
|
||||
valueType: 'string',
|
||||
};
|
||||
const result = filterToQuerySection(filter, [], lp);
|
||||
expect(result).toBe('(span.foo!="bar" && span.foo!="baz")');
|
||||
});
|
||||
|
||||
it('filter with multiple string values and !~ operator', () => {
|
||||
const filter: TraceqlFilter = {
|
||||
id: 'abc',
|
||||
tag: 'foo',
|
||||
operator: '!~',
|
||||
value: ['bar', 'baz'],
|
||||
scope: TraceqlSearchScope.Span,
|
||||
valueType: 'string',
|
||||
};
|
||||
const result = filterToQuerySection(filter, [], lp);
|
||||
expect(result).toBe('span.foo!~"bar|baz"');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
import { startCase, uniq } from 'lodash';
|
||||
|
||||
import { type ScopedVars, type SelectableValue } from '@grafana/data';
|
||||
import { getTemplateSrv } from '@grafana/runtime';
|
||||
import { VariableFormatID } from '@grafana/schema';
|
||||
|
||||
import { type TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen';
|
||||
import { getEscapedRegexValues, getEscapedValues } from '../datasource';
|
||||
import type TempoLanguageProvider from '../language_provider';
|
||||
import { intrinsics } from '../traceql/traceql';
|
||||
import { type Scope } from '../types';
|
||||
|
||||
export const interpolateFilters = (filters: TraceqlFilter[], scopedVars?: ScopedVars) => {
|
||||
const interpolatedFilters = filters.map((filter) => {
|
||||
const updatedFilter = {
|
||||
...filter,
|
||||
tag: getTemplateSrv().replace(filter.tag ?? '', scopedVars ?? {}),
|
||||
};
|
||||
|
||||
if (filter.value) {
|
||||
updatedFilter.value =
|
||||
typeof filter.value === 'string'
|
||||
? getTemplateSrv().replace(filter.value ?? '', scopedVars ?? {}, VariableFormatID.Pipe)
|
||||
: filter.value.map((v) => getTemplateSrv().replace(v ?? '', scopedVars ?? {}, VariableFormatID.Pipe));
|
||||
}
|
||||
|
||||
return updatedFilter;
|
||||
});
|
||||
|
||||
return interpolatedFilters;
|
||||
};
|
||||
|
||||
const isRegExpOperator = (operator: string) => operator === '=~' || operator === '!~';
|
||||
|
||||
const valueHelper = (f: TraceqlFilter) => {
|
||||
let value = f.value;
|
||||
|
||||
if (Array.isArray(value) && !f.isCustomValue) {
|
||||
value = getEscapedValues(value);
|
||||
|
||||
if (isRegExpOperator(f.operator!)) {
|
||||
value = getEscapedRegexValues(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(value) && value.length > 1) {
|
||||
return `"${value.join('|')}"`;
|
||||
}
|
||||
if (f.valueType === 'string') {
|
||||
return `"${value}"`;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const scopeHelper = (f: TraceqlFilter, lp: TempoLanguageProvider) => {
|
||||
// Intrinsic fields don't have a scope
|
||||
if (lp.getIntrinsics().find((t) => t === f.tag)) {
|
||||
return '';
|
||||
}
|
||||
return (
|
||||
(f.scope === TraceqlSearchScope.Event ||
|
||||
f.scope === TraceqlSearchScope.Instrumentation ||
|
||||
f.scope === TraceqlSearchScope.Link ||
|
||||
f.scope === TraceqlSearchScope.Resource ||
|
||||
f.scope === TraceqlSearchScope.Span
|
||||
? f.scope?.toLowerCase()
|
||||
: '') + '.'
|
||||
);
|
||||
};
|
||||
|
||||
const tagHelper = (f: TraceqlFilter, filters: TraceqlFilter[]) => {
|
||||
if (f.tag === 'duration') {
|
||||
const durationType = filters.find((f) => f.id === 'duration-type');
|
||||
if (durationType) {
|
||||
return durationType.value === 'trace' ? 'traceDuration' : 'duration';
|
||||
}
|
||||
return f.tag;
|
||||
}
|
||||
return f.tag;
|
||||
};
|
||||
|
||||
export const filterToQuerySection = (f: TraceqlFilter, filters: TraceqlFilter[], lp: TempoLanguageProvider) => {
|
||||
if (Array.isArray(f.value) && f.value.length > 1 && !isRegExpOperator(f.operator!)) {
|
||||
// For negative operators (!=), use && instead of ||
|
||||
const joinOperator = f.operator === '!=' ? ' && ' : ' || ';
|
||||
return `(${f.value.map((v) => `${scopeHelper(f, lp)}${tagHelper(f, filters)}${f.operator}${valueHelper({ ...f, value: v })}`).join(joinOperator)})`;
|
||||
}
|
||||
|
||||
return `${scopeHelper(f, lp)}${tagHelper(f, filters)}${f.operator}${valueHelper(f)}`;
|
||||
};
|
||||
|
||||
export const filterScopedTag = (f: TraceqlFilter, lp: TempoLanguageProvider) => {
|
||||
return scopeHelper(f, lp) + f.tag;
|
||||
};
|
||||
|
||||
export const filterTitle = (f: TraceqlFilter, lp: TempoLanguageProvider) => {
|
||||
// Special case for the intrinsic "name" since a label called "Name" isn't explicit
|
||||
if (f.tag === 'name') {
|
||||
return 'Span Name';
|
||||
}
|
||||
// Special case for the resource service name
|
||||
if (f.tag === 'service.name' && f.scope === TraceqlSearchScope.Resource) {
|
||||
return 'Service Name';
|
||||
}
|
||||
return startCase(filterScopedTag(f, lp));
|
||||
};
|
||||
|
||||
export const getFilteredTags = (tags: string[], staticTags: Array<string | undefined>) => {
|
||||
return [...tags].filter((t) => !staticTags.includes(t));
|
||||
};
|
||||
|
||||
export const getUnscopedTags = (scopes: Scope[]) => {
|
||||
return uniq(
|
||||
scopes
|
||||
.map((scope: Scope) =>
|
||||
scope.name && scope.name !== TraceqlSearchScope.Intrinsic && scope.tags ? scope.tags : []
|
||||
)
|
||||
.flat()
|
||||
);
|
||||
};
|
||||
|
||||
export const getIntrinsicTags = (scopes: Scope[]) => {
|
||||
let tags = scopes
|
||||
.map((scope: Scope) => (scope.name && scope.name === TraceqlSearchScope.Intrinsic && scope.tags ? scope.tags : []))
|
||||
.flat();
|
||||
|
||||
// Add the default intrinsic tags to the list of tags.
|
||||
// This is needed because the /api/v2/search/tags API
|
||||
// may not always return all the default intrinsic tags
|
||||
// but generally has the most up to date list.
|
||||
tags = uniq(tags.concat(intrinsics));
|
||||
return tags;
|
||||
};
|
||||
|
||||
export const getAllTags = (scopes: Scope[]) => {
|
||||
return uniq(scopes.map((scope: Scope) => (scope.tags ? scope.tags : [])).flat());
|
||||
};
|
||||
|
||||
export const getTagsByScope = (scopes: Scope[], scope: TraceqlSearchScope | string) => {
|
||||
return uniq(scopes.map((s: Scope) => (s.name && s.name === scope && s.tags ? s.tags : [])).flat());
|
||||
};
|
||||
|
||||
export function replaceAt<T>(array: T[], index: number, value: T) {
|
||||
const ret = array.slice(0);
|
||||
ret[index] = value;
|
||||
return ret;
|
||||
}
|
||||
|
||||
export const operatorSelectableValue = (op: string) => {
|
||||
const result: SelectableValue = { label: op, value: op };
|
||||
switch (op) {
|
||||
case '=':
|
||||
result.description = 'Equals';
|
||||
break;
|
||||
case '!=':
|
||||
result.description = 'Not equals';
|
||||
break;
|
||||
case '>':
|
||||
result.description = 'Greater';
|
||||
break;
|
||||
case '>=':
|
||||
result.description = 'Greater or Equal';
|
||||
break;
|
||||
case '<':
|
||||
result.description = 'Less';
|
||||
break;
|
||||
case '<=':
|
||||
result.description = 'Less or Equal';
|
||||
break;
|
||||
case '=~':
|
||||
result.description = 'Matches regex';
|
||||
break;
|
||||
case '!~':
|
||||
result.description = 'Does not match regex';
|
||||
break;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
import { css } from '@emotion/css';
|
||||
import { useEffect, useState } from 'react';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
|
||||
import { type GrafanaTheme2 } from '@grafana/data';
|
||||
import { Alert, InlineField, InlineFieldRow, TextLink, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { AdHocFilter } from './_importedDependencies/components/AdHocFilter/AdHocFilter';
|
||||
import { type AdHocVariableFilter } from './_importedDependencies/components/AdHocFilter/types';
|
||||
import { type PrometheusDatasource } from './_importedDependencies/datasources/prometheus/types';
|
||||
import { type TempoQuery } from './types';
|
||||
import { getDS } from './utils';
|
||||
|
||||
export function ServiceGraphSection({
|
||||
graphDatasourceUid,
|
||||
query,
|
||||
onChange,
|
||||
}: {
|
||||
graphDatasourceUid?: string;
|
||||
query: TempoQuery;
|
||||
onChange: (value: TempoQuery) => void;
|
||||
}) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const dsState = useAsync(() => getDS(graphDatasourceUid), [graphDatasourceUid]);
|
||||
|
||||
// Check if service graph metrics are being collected. If not, displays a warning
|
||||
const [hasKeys, setHasKeys] = useState<boolean | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
async function fn(ds: PrometheusDatasource) {
|
||||
const keys = await ds.getTagKeys({
|
||||
filters: [
|
||||
{
|
||||
key: '__name__',
|
||||
operator: '=~',
|
||||
value:
|
||||
'traces_service_graph_request_server_seconds_sum|traces_service_graph_request_total|traces_service_graph_request_failed_total',
|
||||
condition: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
setHasKeys(Boolean(keys.length));
|
||||
}
|
||||
if (!dsState.loading && dsState.value) {
|
||||
fn(dsState.value as PrometheusDatasource);
|
||||
}
|
||||
}, [dsState]);
|
||||
|
||||
if (dsState.loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ds = dsState.value;
|
||||
|
||||
if (!graphDatasourceUid) {
|
||||
return getWarning(
|
||||
'No service graph datasource selected',
|
||||
'Please set up a service graph datasource in the datasource settings',
|
||||
styles
|
||||
);
|
||||
}
|
||||
|
||||
if (graphDatasourceUid && !ds) {
|
||||
return getWarning(
|
||||
'No service graph data found',
|
||||
'Service graph datasource is configured but the data source no longer exists. Please configure existing data source to use the service graph functionality',
|
||||
styles
|
||||
);
|
||||
}
|
||||
|
||||
const filters = queryToFilter(
|
||||
(Array.isArray(query.serviceMapQuery) ? query.serviceMapQuery[0] : query.serviceMapQuery) || ''
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<InlineFieldRow>
|
||||
<InlineField label="Filter" labelWidth={14} grow>
|
||||
<AdHocFilter
|
||||
datasource={{ uid: graphDatasourceUid }}
|
||||
filters={filters}
|
||||
baseFilters={[
|
||||
{
|
||||
key: '__name__',
|
||||
operator: '=~',
|
||||
value: 'traces_service_graph_request_total|traces_spanmetrics_calls_total',
|
||||
condition: '',
|
||||
},
|
||||
]}
|
||||
addFilter={(filter: AdHocVariableFilter) => {
|
||||
onChange({
|
||||
...query,
|
||||
serviceMapQuery: filtersToQuery([...filters, filter]),
|
||||
});
|
||||
}}
|
||||
removeFilter={(index: number) => {
|
||||
const newFilters = [...filters];
|
||||
newFilters.splice(index, 1);
|
||||
onChange({ ...query, serviceMapQuery: filtersToQuery(newFilters) });
|
||||
}}
|
||||
changeFilter={(index: number, filter: AdHocVariableFilter) => {
|
||||
const newFilters = [...filters];
|
||||
newFilters.splice(index, 1, filter);
|
||||
onChange({ ...query, serviceMapQuery: filtersToQuery(newFilters) });
|
||||
}}
|
||||
/>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
{hasKeys === false
|
||||
? getWarning(
|
||||
'No service graph data found',
|
||||
'Please ensure that service graph metrics are set up correctly',
|
||||
styles
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getWarning(title: string, description: string, styles: { alert: string }) {
|
||||
return (
|
||||
<Alert title={title} severity="info" className={styles.alert}>
|
||||
{description} according to the{' '}
|
||||
<TextLink external href="https://grafana.com/docs/grafana/latest/datasources/tempo/service-graph/">
|
||||
Tempo documentation
|
||||
</TextLink>
|
||||
.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
function queryToFilter(query: string): AdHocVariableFilter[] {
|
||||
let match;
|
||||
let filters: AdHocVariableFilter[] = [];
|
||||
const re = /([\w_]+)(=|!=|<|>|=~|!~)"(.*?)"/g;
|
||||
while ((match = re.exec(query)) !== null) {
|
||||
filters.push({
|
||||
key: match[1],
|
||||
operator: match[2],
|
||||
value: match[3],
|
||||
condition: '',
|
||||
});
|
||||
}
|
||||
return filters;
|
||||
}
|
||||
|
||||
function filtersToQuery(filters: AdHocVariableFilter[]): string {
|
||||
return `{${filters.map((f) => `${f.key}${f.operator}"${f.value}"`).join(',')}}`;
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
alert: css({
|
||||
maxWidth: '75ch',
|
||||
marginTop: theme.spacing(2),
|
||||
}),
|
||||
});
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { type TemplateSrv } from '@grafana/runtime';
|
||||
|
||||
import {
|
||||
type TempoVariableQuery,
|
||||
TempoVariableQueryEditor,
|
||||
type TempoVariableQueryEditorProps,
|
||||
TempoVariableQueryType,
|
||||
} from './VariableQueryEditor';
|
||||
import { selectOptionInTest } from './_importedDependencies/test/helpers/selectOptionInTest';
|
||||
import { createTempoDatasource } from './test/mocks';
|
||||
|
||||
const refId = 'TempoDatasourceVariableQueryEditor-VariableQuery';
|
||||
|
||||
describe('TempoVariableQueryEditor', () => {
|
||||
let props: TempoVariableQueryEditorProps;
|
||||
let onChange: (value: TempoVariableQuery) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
datasource: createTempoDatasource({} as unknown as TemplateSrv),
|
||||
query: { type: 0, refId: 'test' },
|
||||
onChange: (_: TempoVariableQuery) => {},
|
||||
};
|
||||
|
||||
onChange = jest.fn();
|
||||
});
|
||||
|
||||
test('Allows to create a Label names variable', async () => {
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
render(<TempoVariableQueryEditor {...props} onChange={onChange} />);
|
||||
|
||||
await selectOptionInTest(screen.getByLabelText('Query type'), 'Label names');
|
||||
await userEvent.click(document.body);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
type: TempoVariableQueryType.LabelNames,
|
||||
label: '',
|
||||
refId,
|
||||
});
|
||||
});
|
||||
|
||||
test('Allows to create a Label values variable', async () => {
|
||||
jest.spyOn(props.datasource, 'labelNamesQuery').mockResolvedValue([
|
||||
{
|
||||
text: 'moon',
|
||||
},
|
||||
{
|
||||
text: 'luna',
|
||||
},
|
||||
]);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
render(<TempoVariableQueryEditor {...props} onChange={onChange} />);
|
||||
|
||||
await selectOptionInTest(screen.getByLabelText('Query type'), 'Label values');
|
||||
await userEvent.click(document.body);
|
||||
|
||||
// The Label field is rendered only after the query type has been selected.
|
||||
// We wait for it to be displayed to avoid flakyness.
|
||||
await waitFor(() => expect(screen.getByLabelText('Label')).toBeInTheDocument());
|
||||
|
||||
await selectOptionInTest(screen.getByLabelText('Label'), 'luna');
|
||||
await userEvent.click(document.body);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
type: TempoVariableQueryType.LabelValues,
|
||||
label: 'luna',
|
||||
refId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { type DataQuery, type SelectableValue, type TimeRange } from '@grafana/data';
|
||||
import { InlineField, InlineFieldRow, type InputActionMeta, Select } from '@grafana/ui';
|
||||
|
||||
import { type TempoDatasource } from './datasource';
|
||||
import { OPTIONS_LIMIT } from './language_provider';
|
||||
|
||||
export enum TempoVariableQueryType {
|
||||
LabelNames,
|
||||
LabelValues,
|
||||
}
|
||||
|
||||
export interface TempoVariableQuery extends DataQuery {
|
||||
type: TempoVariableQueryType;
|
||||
label?: string;
|
||||
stream?: string;
|
||||
}
|
||||
|
||||
const variableOptions = [
|
||||
{ label: 'Label names', value: TempoVariableQueryType.LabelNames },
|
||||
{ label: 'Label values', value: TempoVariableQueryType.LabelValues },
|
||||
];
|
||||
|
||||
const refId = 'TempoDatasourceVariableQueryEditor-VariableQuery';
|
||||
|
||||
export type TempoVariableQueryEditorProps = {
|
||||
onChange: (value: TempoVariableQuery) => void;
|
||||
query: TempoVariableQuery;
|
||||
datasource: TempoDatasource;
|
||||
range?: TimeRange;
|
||||
};
|
||||
|
||||
export const TempoVariableQueryEditor = ({ onChange, query, datasource, range }: TempoVariableQueryEditorProps) => {
|
||||
const [label, setLabel] = useState(query.label || '');
|
||||
const [type, setType] = useState<number | undefined>(query.type);
|
||||
const [labelOptions, setLabelOptions] = useState<Array<SelectableValue<string>>>([]);
|
||||
const [labelQuery, setLabelQuery] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (type === TempoVariableQueryType.LabelValues) {
|
||||
setIsLoading(true);
|
||||
datasource
|
||||
.labelNamesQuery(range)
|
||||
.then((labelNames: Array<{ text: string }>) => {
|
||||
setLabelOptions(labelNames.map(({ text }) => ({ label: text, value: text })));
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
}, [datasource, query, type, range]);
|
||||
|
||||
const options = useMemo(() => {
|
||||
if (labelQuery.length === 0) {
|
||||
return labelOptions.slice(0, OPTIONS_LIMIT);
|
||||
}
|
||||
|
||||
const queryLowerCase = labelQuery.toLowerCase();
|
||||
return labelOptions
|
||||
.filter((tag) => {
|
||||
if (tag.value && tag.value.length > 0) {
|
||||
return tag.value.toLowerCase().includes(queryLowerCase);
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.slice(0, OPTIONS_LIMIT);
|
||||
}, [labelQuery, labelOptions]);
|
||||
|
||||
const onQueryTypeChange = (newType: SelectableValue<TempoVariableQueryType>) => {
|
||||
setType(newType.value);
|
||||
if (newType.value !== undefined) {
|
||||
onChange({
|
||||
type: newType.value,
|
||||
label,
|
||||
refId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onLabelChange = (newLabel: SelectableValue<string>) => {
|
||||
const newLabelValue = newLabel.value || '';
|
||||
setLabel(newLabelValue);
|
||||
if (type !== undefined) {
|
||||
onChange({
|
||||
type,
|
||||
label: newLabelValue,
|
||||
refId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
if (type !== undefined) {
|
||||
onChange({ type, label, refId });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<InlineFieldRow>
|
||||
<InlineField label="Query type" labelWidth={20}>
|
||||
<Select
|
||||
aria-label="Query type"
|
||||
onChange={onQueryTypeChange}
|
||||
onBlur={handleBlur}
|
||||
value={type}
|
||||
options={variableOptions}
|
||||
width={32}
|
||||
/>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
|
||||
{type === TempoVariableQueryType.LabelValues && (
|
||||
<InlineFieldRow>
|
||||
<InlineField label="Label" labelWidth={20}>
|
||||
<Select
|
||||
aria-label="Label"
|
||||
onChange={onLabelChange}
|
||||
onBlur={handleBlur}
|
||||
onInputChange={(value: string, { action }: InputActionMeta) => {
|
||||
if (action === 'input-change') {
|
||||
setLabelQuery(value);
|
||||
}
|
||||
}}
|
||||
onCloseMenu={() => setLabelQuery('')}
|
||||
value={{ label, value: label }}
|
||||
options={options}
|
||||
width={32}
|
||||
allowCustomValue
|
||||
virtualized
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
This directory contains dependencies that we duplicated from Grafana core while working on the decoupling of Tempo from such core.
|
||||
The long-term goal is to move these files away from here by replacing them with packages.
|
||||
As such, they are only temporary and meant to be used internally to this package, please avoid using them for example as dependencies (imports) in other data source plugins.
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
import { Fragment, PureComponent, type ReactNode } from 'react';
|
||||
|
||||
import { type AdHocVariableFilter, type DataSourceRef, type SelectableValue } from '@grafana/data';
|
||||
import { Segment } from '@grafana/ui';
|
||||
|
||||
import { AdHocFilterBuilder } from './AdHocFilterBuilder';
|
||||
import { REMOVE_FILTER_KEY } from './AdHocFilterKey';
|
||||
import { AdHocFilterRenderer } from './AdHocFilterRenderer';
|
||||
import { ConditionSegment } from './ConditionSegment';
|
||||
|
||||
interface Props {
|
||||
datasource: DataSourceRef | null;
|
||||
filters: AdHocVariableFilter[];
|
||||
baseFilters?: AdHocVariableFilter[];
|
||||
addFilter: (filter: AdHocVariableFilter) => void;
|
||||
removeFilter: (index: number) => void;
|
||||
changeFilter: (index: number, newFilter: AdHocVariableFilter) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple filtering component that automatically uses datasource APIs to get available labels and its values, for
|
||||
* dynamic visual filtering without need for much setup. Instead of having single onChange prop this reports all the
|
||||
* change events with separate props so it is usable with AdHocPicker.
|
||||
*
|
||||
* Note: There isn't API on datasource to suggest the operators here so that is hardcoded to use prometheus style
|
||||
* operators. Also filters are assumed to be joined with `AND` operator, which is also hardcoded.
|
||||
*/
|
||||
export class AdHocFilter extends PureComponent<Props> {
|
||||
onChange = (index: number, prop: string) => (key: SelectableValue<string | null>) => {
|
||||
const { filters } = this.props;
|
||||
const { value } = key;
|
||||
|
||||
if (key.value === REMOVE_FILTER_KEY) {
|
||||
return this.props.removeFilter(index);
|
||||
}
|
||||
|
||||
return this.props.changeFilter(index, {
|
||||
...filters[index],
|
||||
[prop]: value,
|
||||
});
|
||||
};
|
||||
|
||||
appendFilterToVariable = (filter: AdHocVariableFilter) => {
|
||||
this.props.addFilter(filter);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { filters, disabled } = this.props;
|
||||
|
||||
return (
|
||||
<div className="gf-form-inline">
|
||||
{this.renderFilters(filters, disabled)}
|
||||
|
||||
{!disabled && (
|
||||
<AdHocFilterBuilder
|
||||
datasource={this.props.datasource!}
|
||||
appendBefore={filters.length > 0 ? <ConditionSegment label="AND" /> : null}
|
||||
onCompleted={this.appendFilterToVariable}
|
||||
allFilters={this.getAllFilters()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
getAllFilters() {
|
||||
if (this.props.baseFilters) {
|
||||
return this.props.baseFilters.concat(this.props.filters);
|
||||
}
|
||||
|
||||
return this.props.filters;
|
||||
}
|
||||
|
||||
renderFilters(filters: AdHocVariableFilter[], disabled?: boolean) {
|
||||
if (filters.length === 0 && disabled) {
|
||||
return <Segment disabled={disabled} value="No filters" options={[]} onChange={() => {}} />;
|
||||
}
|
||||
|
||||
return filters.reduce((segments: ReactNode[], filter, index) => {
|
||||
if (segments.length > 0) {
|
||||
segments.push(<ConditionSegment label="AND" key={`condition-${index}`} />);
|
||||
}
|
||||
segments.push(this.renderFilterSegments(filter, index, disabled));
|
||||
return segments;
|
||||
}, []);
|
||||
}
|
||||
|
||||
renderFilterSegments(filter: AdHocVariableFilter, index: number, disabled?: boolean) {
|
||||
return (
|
||||
<Fragment key={`filter-${index}`}>
|
||||
<AdHocFilterRenderer
|
||||
disabled={disabled}
|
||||
datasource={this.props.datasource!}
|
||||
filter={filter}
|
||||
onKeyChange={this.onChange(index, 'key')}
|
||||
onOperatorChange={this.onChange(index, 'operator')}
|
||||
onValueChange={this.onChange(index, 'value')}
|
||||
allFilters={this.getAllFilters()}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
import i18n from 'i18next';
|
||||
import { useCallback, useState } from 'react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { type AdHocVariableFilter, type DataSourceRef, type SelectableValue } from '@grafana/data';
|
||||
|
||||
import { AdHocFilterKey, REMOVE_FILTER_KEY } from './AdHocFilterKey';
|
||||
import { AdHocFilterRenderer } from './AdHocFilterRenderer';
|
||||
|
||||
interface Props {
|
||||
datasource: DataSourceRef;
|
||||
onCompleted: (filter: AdHocVariableFilter) => void;
|
||||
appendBefore?: React.ReactNode;
|
||||
allFilters: AdHocVariableFilter[];
|
||||
}
|
||||
|
||||
// Reassign t() so i18next-parser doesn't warn on dynamic key, and we can have 'failOnWarnings' enabled
|
||||
const tFunc = i18n.t;
|
||||
|
||||
const t = (id: string, defaultMessage: string, values?: Record<string, unknown>) => {
|
||||
return tFunc(id, defaultMessage, values);
|
||||
};
|
||||
|
||||
export const AdHocFilterBuilder = ({ datasource, appendBefore, onCompleted, allFilters }: Props) => {
|
||||
const [key, setKey] = useState<string | null>(null);
|
||||
const [operator, setOperator] = useState<string>('=');
|
||||
|
||||
const onKeyChanged = useCallback(
|
||||
(item: SelectableValue<string | null>) => {
|
||||
if (item.value !== REMOVE_FILTER_KEY) {
|
||||
setKey(item.value ?? '');
|
||||
return;
|
||||
}
|
||||
setKey(null);
|
||||
},
|
||||
[setKey]
|
||||
);
|
||||
|
||||
const onOperatorChanged = useCallback(
|
||||
(item: SelectableValue<string>) => setOperator(item.value ?? ''),
|
||||
[setOperator]
|
||||
);
|
||||
|
||||
const onValueChanged = useCallback(
|
||||
(item: SelectableValue<string>) => {
|
||||
onCompleted({
|
||||
value: item.value ?? '',
|
||||
operator: operator,
|
||||
key: key!,
|
||||
});
|
||||
setKey(null);
|
||||
setOperator('=');
|
||||
},
|
||||
[onCompleted, operator, key]
|
||||
);
|
||||
|
||||
if (key === null) {
|
||||
return <AdHocFilterKey datasource={datasource} filterKey={key} onChange={onKeyChanged} allFilters={allFilters} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment key="filter-builder">
|
||||
{appendBefore}
|
||||
<AdHocFilterRenderer
|
||||
datasource={datasource}
|
||||
filter={{ key, value: '', operator }}
|
||||
placeHolder={t('variable.adhoc.placeholder', 'Select value')}
|
||||
onKeyChange={onKeyChanged}
|
||||
onOperatorChange={onOperatorChanged}
|
||||
onValueChange={onValueChanged}
|
||||
allFilters={allFilters}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
import { type ReactElement } from 'react';
|
||||
|
||||
import { type AdHocVariableFilter, type DataSourceRef, type SelectableValue } from '@grafana/data';
|
||||
import { getDataSourceSrv } from '@grafana/runtime';
|
||||
import { Icon, SegmentAsync } from '@grafana/ui';
|
||||
|
||||
interface Props {
|
||||
datasource: DataSourceRef;
|
||||
filterKey: string | null;
|
||||
onChange: (item: SelectableValue<string | null>) => void;
|
||||
allFilters: AdHocVariableFilter[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const MIN_WIDTH = 90;
|
||||
export const AdHocFilterKey = ({ datasource, onChange, disabled, filterKey, allFilters }: Props) => {
|
||||
const loadKeys = () => fetchFilterKeys(datasource, filterKey, allFilters);
|
||||
const loadKeysWithRemove = () => fetchFilterKeysWithRemove(datasource, filterKey, allFilters);
|
||||
|
||||
if (filterKey === null) {
|
||||
return (
|
||||
<div className="gf-form" data-testid="AdHocFilterKey-add-key-wrapper">
|
||||
<SegmentAsync
|
||||
disabled={disabled}
|
||||
className="query-segment-key"
|
||||
Component={plusSegment}
|
||||
value={filterKey}
|
||||
onChange={onChange}
|
||||
loadOptions={loadKeys}
|
||||
inputMinWidth={MIN_WIDTH}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="gf-form" data-testid="AdHocFilterKey-key-wrapper">
|
||||
<SegmentAsync
|
||||
disabled={disabled}
|
||||
className="query-segment-key"
|
||||
value={filterKey}
|
||||
onChange={onChange}
|
||||
loadOptions={loadKeysWithRemove}
|
||||
inputMinWidth={MIN_WIDTH}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const REMOVE_FILTER_KEY = '-- remove filter --';
|
||||
const REMOVE_VALUE = { label: REMOVE_FILTER_KEY, value: REMOVE_FILTER_KEY };
|
||||
|
||||
const plusSegment: ReactElement = (
|
||||
<span className="gf-form-label query-part" aria-label="Add Filter">
|
||||
<Icon name="plus" />
|
||||
</span>
|
||||
);
|
||||
|
||||
const fetchFilterKeys = async (
|
||||
datasource: DataSourceRef,
|
||||
currentKey: string | null,
|
||||
allFilters: AdHocVariableFilter[]
|
||||
): Promise<Array<SelectableValue<string>>> => {
|
||||
const ds = await getDataSourceSrv().get(datasource);
|
||||
|
||||
if (!ds || !ds.getTagKeys) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const otherFilters = allFilters.filter((f) => f.key !== currentKey);
|
||||
const response = await ds.getTagKeys({ filters: otherFilters });
|
||||
const metrics = Array.isArray(response) ? response : response.data;
|
||||
return metrics.map((m) => ({ label: m.text, value: m.text }));
|
||||
};
|
||||
|
||||
const fetchFilterKeysWithRemove = async (
|
||||
datasource: DataSourceRef,
|
||||
currentKey: string | null,
|
||||
allFilters: AdHocVariableFilter[]
|
||||
): Promise<Array<SelectableValue<string>>> => {
|
||||
const keys = await fetchFilterKeys(datasource, currentKey, allFilters);
|
||||
return [REMOVE_VALUE, ...keys];
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue