From 59e4c257bba00968741ffc235e2dbe9d4c5b90ac Mon Sep 17 00:00:00 2001 From: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Mon, 28 Aug 2023 15:02:12 +0100 Subject: [PATCH] Tempo: Metrics summary (#73201) * Metrics summary * Update query * Remove colors * Update states * Add group by into its own component * Add group by to search and traceql tabs * Add spacing for group by * Update span kind values * Update span status code values * Update query based on target + group by * Cleanup * Only add targetQuery if not empty * Add kind=server to table * Update groupBy query logic * Add feature toggle * Use feature toggle * Self review * Update target query * Make gen-cue * Tweak query * Update states * useRef for onChange * Fix for steaming in search tab * Add loading state tests * metricsSummary tests * Datasource tests * Review updates * Update aria-label * Update test * Simplify response state * More manual testing and feedback from sync call * Prettier and fix test * Remove group by component from traceql tab * Cleanup, tests, error messages * Add feature tracking --- devenv/docker/blocks/tempo/tempo.yaml | 4 +- .../feature-toggles/index.md | 1 + .../src/types/featureToggles.gen.ts | 1 + .../dataquery/x/TempoDataQuery_types.gen.ts | 6 + pkg/services/featuremgmt/registry.go | 7 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + .../kinds/dataquery/types_dataquery_gen.go | 10 +- .../SearchTraceQLEditor/GroupByField.test.tsx | 88 +++++ .../SearchTraceQLEditor/GroupByField.tsx | 134 ++++++++ .../SearchTraceQLEditor/SearchField.test.tsx | 22 ++ .../tempo/SearchTraceQLEditor/SearchField.tsx | 4 +- .../TraceQLSearch.test.tsx | 61 ++-- .../SearchTraceQLEditor/TraceQLSearch.tsx | 6 +- .../plugins/datasource/tempo/dataquery.cue | 4 +- .../plugins/datasource/tempo/dataquery.gen.ts | 6 + .../datasource/tempo/datasource.test.ts | 24 +- .../plugins/datasource/tempo/datasource.ts | 150 +++++++-- .../tempo/language_provider.test.ts | 32 ++ .../datasource/tempo/language_provider.ts | 12 + .../datasource/tempo/metricsSummary.test.ts | 312 ++++++++++++++++++ .../datasource/tempo/metricsSummary.ts | 272 +++++++++++++++ .../datasource/tempo/resultTransformer.ts | 3 + 23 files changed, 1099 insertions(+), 65 deletions(-) create mode 100644 public/app/plugins/datasource/tempo/SearchTraceQLEditor/GroupByField.test.tsx create mode 100644 public/app/plugins/datasource/tempo/SearchTraceQLEditor/GroupByField.tsx create mode 100644 public/app/plugins/datasource/tempo/metricsSummary.test.ts create mode 100644 public/app/plugins/datasource/tempo/metricsSummary.ts diff --git a/devenv/docker/blocks/tempo/tempo.yaml b/devenv/docker/blocks/tempo/tempo.yaml index 25edc8b4e2f..6e94ff11a8d 100644 --- a/devenv/docker/blocks/tempo/tempo.yaml +++ b/devenv/docker/blocks/tempo/tempo.yaml @@ -24,6 +24,8 @@ compactor: compacted_block_retention: 10m metrics_generator: + traces_storage: + path: /tmp/tempo/generator/traces registry: external_labels: source: tempo @@ -49,4 +51,4 @@ storage: path: /tmp/tempo/blocks overrides: - metrics_generator_processors: [service-graphs, span-metrics] + metrics_generator_processors: [local-blocks, service-graphs, span-metrics] diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index f3278274dd4..077ec28fa3d 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -122,6 +122,7 @@ Experimental features might be changed or removed without prior notice. | `toggleLabelsInLogsUI` | Enable toggleable filters in log details view | | `mlExpressions` | Enable support for Machine Learning in server-side expressions | | `traceQLStreaming` | Enables response streaming of TraceQL queries of the Tempo data source | +| `metricsSummary` | Enables metrics summary queries in the Tempo data source | | `grafanaAPIServer` | Enable Kubernetes API Server for Grafana resources | | `featureToggleAdminPage` | Enable admin page for managing feature toggles from the Grafana front-end | | `awsAsyncQueryCaching` | Enable caching for async queries for Redshift and Athena. Requires that the `useCachingService` feature toggle is enabled and the datasource has caching and async query support enabled | diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index c49c7b629c2..1d38d3e08c0 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -108,6 +108,7 @@ export interface FeatureToggles { toggleLabelsInLogsUI?: boolean; mlExpressions?: boolean; traceQLStreaming?: boolean; + metricsSummary?: boolean; grafanaAPIServer?: boolean; featureToggleAdminPage?: boolean; awsAsyncQueryCaching?: boolean; diff --git a/packages/grafana-schema/src/raw/composable/tempo/dataquery/x/TempoDataQuery_types.gen.ts b/packages/grafana-schema/src/raw/composable/tempo/dataquery/x/TempoDataQuery_types.gen.ts index 3aa9d56be0e..7a776a66dd0 100644 --- a/packages/grafana-schema/src/raw/composable/tempo/dataquery/x/TempoDataQuery_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/tempo/dataquery/x/TempoDataQuery_types.gen.ts @@ -15,6 +15,10 @@ export const pluginVersion = "10.2.0-pre"; export interface TempoQuery extends common.DataQuery { filters: Array; + /** + * Filters that are used to query the metrics summary + */ + groupBy?: Array; /** * Defines the maximum number of traces that are returned from Tempo */ @@ -55,6 +59,7 @@ export interface TempoQuery extends common.DataQuery { export const defaultTempoQuery: Partial = { filters: [], + groupBy: [], }; /** @@ -76,6 +81,7 @@ export enum SearchStreamingState { * static fields are pre-set in the UI, dynamic fields are added by the user */ export enum TraceqlSearchScope { + Intrinsic = 'intrinsic', Resource = 'resource', Span = 'span', Unscoped = 'unscoped', diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index e2bede4c104..fd8b11cc602 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -620,6 +620,13 @@ var ( FrontendOnly: true, Owner: grafanaObservabilityTracesAndProfilingSquad, }, + { + Name: "metricsSummary", + Description: "Enables metrics summary queries in the Tempo data source", + Stage: FeatureStageExperimental, + FrontendOnly: true, + Owner: grafanaObservabilityTracesAndProfilingSquad, + }, { Name: "grafanaAPIServer", Description: "Enable Kubernetes API Server for Grafana resources", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index bb640bd6f5b..cb128dd5f9a 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -89,6 +89,7 @@ transformationsRedesign,GA,@grafana/observability-metrics,false,false,false,true toggleLabelsInLogsUI,experimental,@grafana/observability-logs,false,false,false,true mlExpressions,experimental,@grafana/alerting-squad,false,false,false,false traceQLStreaming,experimental,@grafana/observability-traces-and-profiling,false,false,false,true +metricsSummary,experimental,@grafana/observability-traces-and-profiling,false,false,false,true grafanaAPIServer,experimental,@grafana/grafana-app-platform-squad,false,false,false,false featureToggleAdminPage,experimental,@grafana/grafana-operator-experience-squad,false,false,true,false awsAsyncQueryCaching,experimental,@grafana/aws-datasources,false,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index a15098df1f4..2b1a1440750 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -367,6 +367,10 @@ const ( // Enables response streaming of TraceQL queries of the Tempo data source FlagTraceQLStreaming = "traceQLStreaming" + // FlagMetricsSummary + // Enables metrics summary queries in the Tempo data source + FlagMetricsSummary = "metricsSummary" + // FlagGrafanaAPIServer // Enable Kubernetes API Server for Grafana resources FlagGrafanaAPIServer = "grafanaAPIServer" diff --git a/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go b/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go index 9a1e4d8c9cc..768b4c1c620 100644 --- a/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go +++ b/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go @@ -31,9 +31,10 @@ const ( // Defines values for TraceqlSearchScope. const ( - TraceqlSearchScopeResource TraceqlSearchScope = "resource" - TraceqlSearchScopeSpan TraceqlSearchScope = "span" - TraceqlSearchScopeUnscoped TraceqlSearchScope = "unscoped" + TraceqlSearchScopeIntrinsic TraceqlSearchScope = "intrinsic" + TraceqlSearchScopeResource TraceqlSearchScope = "resource" + TraceqlSearchScopeSpan TraceqlSearchScope = "span" + TraceqlSearchScopeUnscoped TraceqlSearchScope = "unscoped" ) // These are the common properties available to all queries in all datasources. @@ -81,6 +82,9 @@ type TempoQuery struct { Datasource *any `json:"datasource,omitempty"` Filters []TraceqlFilter `json:"filters"` + // Filters that are used to query the metrics summary + GroupBy []TraceqlFilter `json:"groupBy,omitempty"` + // Hide true if query is disabled (ie should not be returned to the dashboard) // Note this does not always imply that the query should not be executed since // the results from a hidden query may be used as the input to other queries (SSE etc) diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/GroupByField.test.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/GroupByField.test.tsx new file mode 100644 index 00000000000..9c010e7057e --- /dev/null +++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/GroupByField.test.tsx @@ -0,0 +1,88 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { TraceqlSearchScope } from '../dataquery.gen'; +import { TempoDatasource } from '../datasource'; +import TempoLanguageProvider from '../language_provider'; +import { TempoQuery } from '../types'; + +import { GroupByField } from './GroupByField'; + +describe('GroupByField', () => { + let user: ReturnType; + + const datasource: TempoDatasource = { + search: { + filters: [], + }, + } as unknown as TempoDatasource; + + const lp = new TempoLanguageProvider(datasource); + datasource.languageProvider = lp; + + let query: TempoQuery = { + refId: 'A', + queryType: 'traceqlSearch', + query: '', + filters: [], + groupBy: [{ id: 'group-by-id', scope: TraceqlSearchScope.Span, tag: 'component' }], + }; + + const onChange = (q: TempoQuery) => { + query = q; + }; + + jest.spyOn(lp, 'getMetricsSummaryTags').mockReturnValue(['component', 'http.method', 'http.status_code']); + + 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 update scope when new value is selected in scope input', async () => { + const { container } = render( + + ); + + const scopeSelect = container.querySelector(`input[aria-label="Select scope for filter 1"]`); + expect(scopeSelect).not.toBeNull(); + expect(scopeSelect).toBeInTheDocument(); + + if (scopeSelect) { + await user.click(scopeSelect); + jest.advanceTimersByTime(1000); + const resourceScope = await screen.findByText('resource'); + await user.click(resourceScope); + const groupByFilter = query.groupBy?.find((f) => f.id === 'group-by-id'); + expect(groupByFilter).not.toBeNull(); + expect(groupByFilter?.scope).toBe('resource'); + } + }); + + it('should update tag when new value is selected in tag input', async () => { + const { container } = render( + + ); + + const tagSelect = container.querySelector(`input[aria-label="Select tag for filter 1"]`); + expect(tagSelect).not.toBeNull(); + expect(tagSelect).toBeInTheDocument(); + + if (tagSelect) { + await user.click(tagSelect); + jest.advanceTimersByTime(1000); + const tag = await screen.findByText('http.method'); + await user.click(tag); + const groupByFilter = query.groupBy?.find((f) => f.id === 'group-by-id'); + expect(groupByFilter).not.toBeNull(); + expect(groupByFilter?.tag).toBe('http.method'); + } + }); +}); diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/GroupByField.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/GroupByField.tsx new file mode 100644 index 00000000000..32776acd9c1 --- /dev/null +++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/GroupByField.tsx @@ -0,0 +1,134 @@ +import { css } from '@emotion/css'; +import React, { useEffect } from 'react'; +import { v4 as uuidv4 } from 'uuid'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { AccessoryButton } from '@grafana/experimental'; +import { HorizontalGroup, Select, useStyles2 } from '@grafana/ui'; + +import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen'; +import { TempoDatasource } from '../datasource'; +import { TempoQuery } from '../types'; + +import InlineSearchField from './InlineSearchField'; +import { replaceAt } from './utils'; + +interface Props { + datasource: TempoDatasource; + onChange: (value: TempoQuery) => void; + query: Partial & TempoQuery; + isTagsLoading: boolean; +} + +export const GroupByField = (props: Props) => { + const { datasource, onChange, query, isTagsLoading } = props; + const styles = useStyles2(getStyles); + const generateId = () => uuidv4().slice(0, 8); + + useEffect(() => { + if (!query.groupBy || query.groupBy.length === 0) { + onChange({ + ...query, + groupBy: [ + { + id: generateId(), + scope: TraceqlSearchScope.Span, + }, + ], + }); + } + }, [onChange, query]); + + const getTags = (f: TraceqlFilter) => { + return datasource!.languageProvider.getMetricsSummaryTags(f.scope); + }; + + const addFilter = () => { + updateFilter({ + id: generateId(), + scope: TraceqlSearchScope.Span, + }); + }; + + const removeFilter = (filter: TraceqlFilter) => { + onChange({ ...query, groupBy: query.groupBy?.filter((f) => f.id !== filter.id) }); + }; + + const updateFilter = (filter: TraceqlFilter) => { + const copy = { ...query }; + copy.groupBy ||= []; + const indexOfFilter = copy.groupBy.findIndex((f) => f.id === filter.id); + if (indexOfFilter >= 0) { + copy.groupBy = replaceAt(copy.groupBy, indexOfFilter, filter); + } else { + copy.groupBy.push(filter); + } + onChange(copy); + }; + + const scopeOptions = Object.values(TraceqlSearchScope).map((t) => ({ label: t, value: t })); + + return ( + + <> + {query.groupBy?.map((f, i) => ( +
+ + ({ + label: t, + value: t, + }))} + value={f.tag || ''} + onChange={(v) => { + updateFilter({ ...f, tag: v?.value }); + }} + placeholder="Select tag" + aria-label={`Select tag for filter ${i + 1}`} + isLoading={isTagsLoading} + isClearable + /> + removeFilter(f)} + tooltip="Remove tag" + aria-label={`Remove tag for filter ${i + 1}`} + /> + + {i === (query.groupBy?.length ?? 0) - 1 && ( + + addFilter()} + tooltip="Add tag" + aria-label="Add tag" + /> + + )} + +
+ ))} + +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + addFilter: css` + margin-left: ${theme.spacing(2)}; + `, +}); diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.test.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.test.tsx index b55f6f586e4..b46ed26216d 100644 --- a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.test.tsx +++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.test.tsx @@ -152,6 +152,28 @@ describe('SearchField', () => { expect(updateFilter).toHaveBeenCalledWith({ ...filter, value: undefined }); } }); + + it('should not 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(screen.queryByText('intrinsic')).not.toBeInTheDocument(); + } + }); }); const renderSearchField = ( diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.tsx index 0295ea7590e..9be6a1e2ba2 100644 --- a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.tsx +++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.tsx @@ -95,7 +95,9 @@ const SearchField = ({ setPrevValue(filter.value); }, [filter.value]); - const scopeOptions = Object.values(TraceqlSearchScope).map((t) => ({ label: t, value: t })); + const scopeOptions = Object.values(TraceqlSearchScope) + .filter((s) => s !== TraceqlSearchScope.Intrinsic) + .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); diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.test.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.test.tsx index 07a11aa88cb..b863513922b 100644 --- a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.test.tsx +++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.test.tsx @@ -1,8 +1,10 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { initTemplateSrv } from 'test/helpers/initTemplateSrv'; +import { config } from '@grafana/runtime'; + import { TraceqlSearchScope } from '../dataquery.gen'; import { TempoDatasource } from '../datasource'; import TempoLanguageProvider from '../language_provider'; @@ -97,25 +99,44 @@ describe('TraceQLSearch', () => { } }); - // it('should add new filter when new value is selected in the service name section', async () => { - // const { container } = render(); - // const serviceNameValue = container.querySelector(`input[aria-label="select service-name value"]`); - // expect(serviceNameValue).not.toBeNull(); - // expect(serviceNameValue).toBeInTheDocument(); + it('should add new filter when new value is selected in the service name section', async () => { + const { container } = render(); + 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(); + expect(query.filters.find((f) => f.id === 'service-name')).not.toBeDefined(); - // if (serviceNameValue) { - // await user.click(serviceNameValue); - // 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); - // } - // }); + if (serviceNameValue) { + await user.click(serviceNameValue); + 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 group by when feature toggle is not enabled', async () => { + await waitFor(() => { + render(); + const groupBy = screen.queryByText('Group By Metrics'); + expect(groupBy).toBeNull(); + expect(groupBy).not.toBeInTheDocument(); + }); + }); + + it('should render group by when feature toggle enabled', async () => { + config.featureToggles.metricsSummary = true; + await waitFor(() => { + render(); + const groupBy = screen.queryByText('Group By Metrics'); + expect(groupBy).not.toBeNull(); + expect(groupBy).toBeInTheDocument(); + }); + }); }); diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.tsx index 0e5e993ffc4..5be54f1ba7b 100644 --- a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.tsx +++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.tsx @@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { EditorRow } from '@grafana/experimental'; -import { FetchError, getTemplateSrv } from '@grafana/runtime'; +import { config, FetchError, getTemplateSrv } from '@grafana/runtime'; import { Alert, HorizontalGroup, useStyles2 } from '@grafana/ui'; import { createErrorNotification } from '../../../../core/copy/appNotification'; @@ -17,6 +17,7 @@ import { traceqlGrammar } from '../traceql/traceql'; import { TempoQuery } from '../types'; import DurationInput from './DurationInput'; +import { GroupByField } from './GroupByField'; import InlineSearchField from './InlineSearchField'; import SearchField from './SearchField'; import TagsInput from './TagsInput'; @@ -166,6 +167,9 @@ const TraceQLSearch = ({ datasource, query, onChange }: Props) => { query={traceQlQuery} /> + {config.featureToggles.metricsSummary && ( + + )} diff --git a/public/app/plugins/datasource/tempo/dataquery.cue b/public/app/plugins/datasource/tempo/dataquery.cue index a54bb10e116..6c6d9021165 100644 --- a/public/app/plugins/datasource/tempo/dataquery.cue +++ b/public/app/plugins/datasource/tempo/dataquery.cue @@ -45,6 +45,8 @@ composableKinds: DataQuery: { // Defines the maximum number of traces that are returned from Tempo limit?: int64 filters: [...#TraceqlFilter] + // Filters that are used to query the metrics summary + groupBy?: [...#TraceqlFilter] } @cuetsy(kind="interface") @grafana(TSVeneer="type") // search = Loki search, nativeSearch = Tempo search for backwards compatibility @@ -54,7 +56,7 @@ composableKinds: DataQuery: { #SearchStreamingState: "pending" | "streaming" | "done" | "error" @cuetsy(kind="enum") // static fields are pre-set in the UI, dynamic fields are added by the user - #TraceqlSearchScope: "unscoped" | "resource" | "span" @cuetsy(kind="enum") + #TraceqlSearchScope: "intrinsic" | "unscoped" | "resource" | "span" @cuetsy(kind="enum") #TraceqlFilter: { // Uniquely identify the filter, will not be used in the query generation id: string diff --git a/public/app/plugins/datasource/tempo/dataquery.gen.ts b/public/app/plugins/datasource/tempo/dataquery.gen.ts index 7bfac5a1758..53126ecf11d 100644 --- a/public/app/plugins/datasource/tempo/dataquery.gen.ts +++ b/public/app/plugins/datasource/tempo/dataquery.gen.ts @@ -12,6 +12,10 @@ import * as common from '@grafana/schema'; export interface TempoQuery extends common.DataQuery { filters: Array; + /** + * Filters that are used to query the metrics summary + */ + groupBy?: Array; /** * Defines the maximum number of traces that are returned from Tempo */ @@ -52,6 +56,7 @@ export interface TempoQuery extends common.DataQuery { export const defaultTempoQuery: Partial = { filters: [], + groupBy: [], }; /** @@ -73,6 +78,7 @@ export enum SearchStreamingState { * static fields are pre-set in the UI, dynamic fields are added by the user */ export enum TraceqlSearchScope { + Intrinsic = 'intrinsic', Resource = 'resource', Span = 'span', Unscoped = 'unscoped', diff --git a/public/app/plugins/datasource/tempo/datasource.test.ts b/public/app/plugins/datasource/tempo/datasource.test.ts index d6eafb5d4e0..59b434cf951 100644 --- a/public/app/plugins/datasource/tempo/datasource.test.ts +++ b/public/app/plugins/datasource/tempo/datasource.test.ts @@ -14,15 +14,11 @@ import { PluginType, CoreApp, } from '@grafana/data'; -import { - BackendDataSourceResponse, - FetchResponse, - setBackendSrv, - setDataSourceSrv, - TemplateSrv, -} from '@grafana/runtime'; +import { BackendDataSourceResponse, FetchResponse, setBackendSrv, setDataSourceSrv } from '@grafana/runtime'; import { BarGaugeDisplayMode, TableCellDisplayMode } from '@grafana/schema'; +import { TemplateSrv } from 'app/features/templating/template_srv'; +import { TraceqlSearchScope } from './dataquery.gen'; import { DEFAULT_LIMIT, TempoDatasource, @@ -267,6 +263,18 @@ describe('Tempo data source', () => { }); }); + it('should format metrics summary query correctly', () => { + const ds = new TempoDatasource(defaultSettings, {} as TemplateSrv); + const queryGroupBy = [ + { id: '1', scope: TraceqlSearchScope.Unscoped, tag: 'component' }, + { id: '2', scope: TraceqlSearchScope.Span, tag: 'name' }, + { id: '3', scope: TraceqlSearchScope.Resource, tag: 'service.name' }, + { id: '4', scope: TraceqlSearchScope.Intrinsic, tag: 'kind' }, + ]; + const groupBy = ds.formatGroupBy(queryGroupBy); + expect(groupBy).toEqual('.component, span.name, resource.service.name, kind'); + }); + it('should include a default limit', () => { const ds = new TempoDatasource(defaultSettings); const tempoQuery: TempoQuery = { @@ -800,7 +808,7 @@ function setupBackendSrv(frame: DataFrame) { } as any); } -const defaultSettings: DataSourceInstanceSettings = { +export const defaultSettings: DataSourceInstanceSettings = { id: 0, uid: 'gdev-tempo', type: 'tracing', diff --git a/public/app/plugins/datasource/tempo/datasource.ts b/public/app/plugins/datasource/tempo/datasource.ts index 2f8ec011cd3..9eb12e4332b 100644 --- a/public/app/plugins/datasource/tempo/datasource.ts +++ b/public/app/plugins/datasource/tempo/datasource.ts @@ -52,6 +52,7 @@ import { defaultTableFilter, } from './graphTransform'; import TempoLanguageProvider from './language_provider'; +import { createTableFrameFromMetricsSummaryQuery, emptyResponse, MetricsSummary } from './metricsSummary'; import { transformTrace, transformTraceList, @@ -202,6 +203,7 @@ export class TempoDatasource extends DataSourceWithBackend this.hasGroupBy(t)); + if (groupBy) { + subQueries.push(this.handleMetricsSummary(groupBy, generateQueryFromFilters(groupBy.filters), options)); + } + } - // We want to support template variables also in Search for consistency with other data sources - const queryValue = this.templateSrv.replace(queryValueFromFilters, options.scopedVars); + const traceqlSearchTargets = config.featureToggles.metricsSummary + ? targets.traceqlSearch.filter((t) => !this.hasGroupBy(t)) + : targets.traceqlSearch; + if (traceqlSearchTargets.length > 0) { + const queryValueFromFilters = generateQueryFromFilters(traceqlSearchTargets[0].filters); - reportInteraction('grafana_traces_traceql_search_queried', { - datasourceType: 'tempo', - app: options.app ?? '', - grafana_version: config.buildInfo.version, - query: queryValue ?? '', - streaming: config.featureToggles.traceQLStreaming, - }); + // We want to support template variables also in Search for consistency with other data sources + const queryValue = this.templateSrv.replace(queryValueFromFilters, options.scopedVars); - if (config.featureToggles.traceQLStreaming) { - subQueries.push(this.handleStreamingSearch(options, targets.traceqlSearch, queryValue)); - } else { - subQueries.push( - this._request('/api/search', { - q: queryValue, - limit: options.targets[0].limit ?? DEFAULT_LIMIT, - start: options.range.from.unix(), - end: options.range.to.unix(), - }).pipe( - map((response) => { - return { - data: createTableFrameFromTraceQlQuery(response.data.traces, this.instanceSettings), - }; - }), - catchError((err) => { - return of({ error: { message: getErrorMessage(err.data.message) }, data: [] }); - }) - ) - ); + reportInteraction('grafana_traces_traceql_search_queried', { + datasourceType: 'tempo', + app: options.app ?? '', + grafana_version: config.buildInfo.version, + query: queryValue ?? '', + streaming: config.featureToggles.traceQLStreaming, + }); + + if (config.featureToggles.traceQLStreaming) { + subQueries.push(this.handleStreamingSearch(options, traceqlSearchTargets, queryValue)); + } else { + subQueries.push( + this._request('/api/search', { + q: queryValue, + limit: options.targets[0].limit ?? DEFAULT_LIMIT, + start: options.range.from.unix(), + end: options.range.to.unix(), + }).pipe( + map((response) => { + return { + data: createTableFrameFromTraceQlQuery(response.data.traces, this.instanceSettings), + }; + }), + catchError((err) => { + return of({ error: { message: getErrorMessage(err.data.message) }, data: [] }); + }) + ) + ); + } } } catch (error) { return of({ error: { message: error instanceof Error ? error.message : 'Unknown error occurred' }, data: [] }); @@ -382,6 +397,81 @@ export class TempoDatasource extends DataSourceWithBackend) => { + reportInteraction('grafana_traces_metrics_summary_queried', { + datasourceType: 'tempo', + app: options.app ?? '', + grafana_version: config.buildInfo.version, + filterCount: target.groupBy?.length ?? 0, + }); + + if (query === '{}') { + return of({ + error: { + message: + 'Please ensure you do not have an empty query. This is so filters are applied and the metrics summary is not generated from all spans.', + }, + data: emptyResponse, + }); + } + + const groupBy = target.groupBy ? this.formatGroupBy(target.groupBy) : ''; + return this._request('/api/metrics/summary', { + q: query, + groupBy, + start: options.range.from.unix(), + end: options.range.to.unix(), + }).pipe( + map((response) => { + if (!response.data.summaries) { + return { + error: { + message: getErrorMessage( + `No summary data for '${groupBy}'. Note: the metrics summary API only considers spans of kind = server. You can check if the attributes exist by running a TraceQL query like { attr_key = attr_value && kind = server }` + ), + }, + data: emptyResponse, + }; + } + // Check if any of the results have series data as older versions of Tempo placed the series data in a different structure + const hasSeries = response.data.summaries.some((summary: MetricsSummary) => summary.series.length > 0); + if (!hasSeries) { + return { + error: { + message: getErrorMessage(`No series data. Ensure you are using an up to date version of Tempo`), + }, + data: emptyResponse, + }; + } + return { + data: createTableFrameFromMetricsSummaryQuery(response.data.summaries, query, this.instanceSettings), + }; + }), + catchError((error) => { + return of({ + error: { message: getErrorMessage(error.data.message) }, + data: emptyResponse, + }); + }) + ); + }; + + formatGroupBy = (groupBy: TraceqlFilter[]) => { + return groupBy + ?.filter((f) => f.tag) + .map((f) => { + if (f.scope === TraceqlSearchScope.Unscoped) { + return `.${f.tag}`; + } + return f.scope !== TraceqlSearchScope.Intrinsic ? `${f.scope}.${f.tag}` : f.tag; + }) + .join(', '); + }; + + hasGroupBy = (query: TempoQuery) => { + return query.groupBy?.find((gb) => gb.tag); + }; + /** * Handles the simplest of the queries where we have just a trace id and return trace data for it. * @param options diff --git a/public/app/plugins/datasource/tempo/language_provider.test.ts b/public/app/plugins/datasource/tempo/language_provider.test.ts index f6f76edfdc3..3826f8ab9fe 100644 --- a/public/app/plugins/datasource/tempo/language_provider.test.ts +++ b/public/app/plugins/datasource/tempo/language_provider.test.ts @@ -5,6 +5,38 @@ import TempoLanguageProvider from './language_provider'; import { Scope } from './types'; describe('Language_provider', () => { + describe('should get correct metrics summary tags', () => { + it('for API v1 tags', async () => { + const lp = setup(v1Tags); + const tags = lp.getMetricsSummaryTags(); + expect(tags).toEqual(['bar', 'foo']); + }); + + it('for API v2 intrinsic tags', async () => { + const lp = setup(undefined, v2Tags); + const tags = lp.getMetricsSummaryTags(TraceqlSearchScope.Intrinsic); + expect(tags).toEqual(['duration', 'kind', 'name', 'status']); + }); + + it('for API v2 resource tags', async () => { + const lp = setup(undefined, v2Tags); + const tags = lp.getMetricsSummaryTags(TraceqlSearchScope.Resource); + expect(tags).toEqual(['cluster', 'container']); + }); + + it('for API v2 span tags', async () => { + const lp = setup(undefined, v2Tags); + const tags = lp.getMetricsSummaryTags(TraceqlSearchScope.Span); + expect(tags).toEqual(['db']); + }); + + it('for API v2 unscoped tags', async () => { + const lp = setup(undefined, v2Tags); + const tags = lp.getMetricsSummaryTags(TraceqlSearchScope.Unscoped); + expect(tags).toEqual(['cluster', 'container', 'db']); + }); + }); + describe('should get correct tags', () => { it('for API v1 tags', async () => { const lp = setup(v1Tags); diff --git a/public/app/plugins/datasource/tempo/language_provider.ts b/public/app/plugins/datasource/tempo/language_provider.ts index a56c7b4b2cc..23e53fd6b21 100644 --- a/public/app/plugins/datasource/tempo/language_provider.ts +++ b/public/app/plugins/datasource/tempo/language_provider.ts @@ -71,6 +71,18 @@ export default class TempoLanguageProvider extends LanguageProvider { return []; }; + getMetricsSummaryTags = (scope?: TraceqlSearchScope) => { + if (this.tagsV2 && scope) { + if (scope === TraceqlSearchScope.Unscoped) { + return getUnscopedTags(this.tagsV2); + } + return getTagsByScope(this.tagsV2, scope); + } else if (this.tagsV1) { + return this.tagsV1; + } + return []; + }; + getTraceqlAutocompleteTags = (scope?: string) => { if (this.tagsV2) { if (!scope) { diff --git a/public/app/plugins/datasource/tempo/metricsSummary.test.ts b/public/app/plugins/datasource/tempo/metricsSummary.test.ts new file mode 100644 index 00000000000..890c7ced661 --- /dev/null +++ b/public/app/plugins/datasource/tempo/metricsSummary.test.ts @@ -0,0 +1,312 @@ +import { defaultSettings } from './datasource.test'; +import { + createTableFrameFromMetricsSummaryQuery, + emptyResponse, + getConfigQuery, + transformToMetricsData, +} from './metricsSummary'; + +describe('MetricsSummary', () => { + describe('createTableFrameFromMetricsSummaryQuery', () => { + it('should return emptyResponse when state is LoadingState.Error', () => { + const result = createTableFrameFromMetricsSummaryQuery([], '', defaultSettings); + expect(result).toEqual(emptyResponse); + }); + + it('should return correctly when state is LoadingState.Done', () => { + const data = [ + { + spanCount: '10', + errorSpanCount: '1', + p50: '1', + p90: '2', + p95: '3', + p99: '4', + series: [ + { + key: 'span.http.status_code', + value: { + type: 3, + n: 208, + }, + }, + { + key: 'temperature', + value: { + type: 4, + f: 38.1, + }, + }, + ], + }, + ]; + const result = createTableFrameFromMetricsSummaryQuery( + data, + '{name="HTTP POST - post"} | by(resource.service.name)', + defaultSettings + ); + expect(result).toMatchInlineSnapshot(` + [ + { + "fields": [ + { + "config": { + "displayNameFromDS": "span.http.status_code", + "links": [ + { + "internal": { + "datasourceName": "tempo", + "datasourceUid": "gdev-tempo", + "query": { + "query": "{name="HTTP POST - post" && span.http.status_code=\${__data.fields["span.http.status_code"]} && temperature=\${__data.fields["temperature"]} && kind=server} | by(resource.service.name)", + "queryType": "traceql", + }, + }, + "title": "Query in explore", + "url": "", + }, + ], + }, + "name": "span.http.status_code", + "type": "string", + "values": [ + 208, + ], + }, + { + "config": { + "displayNameFromDS": "temperature", + "links": [ + { + "internal": { + "datasourceName": "tempo", + "datasourceUid": "gdev-tempo", + "query": { + "query": "{name="HTTP POST - post" && span.http.status_code=\${__data.fields["span.http.status_code"]} && temperature=\${__data.fields["temperature"]} && kind=server} | by(resource.service.name)", + "queryType": "traceql", + }, + }, + "title": "Query in explore", + "url": "", + }, + ], + }, + "name": "temperature", + "type": "string", + "values": [ + 38.1, + ], + }, + { + "config": { + "custom": { + "width": 150, + }, + "displayNameFromDS": "Kind", + }, + "name": "kind", + "type": "string", + "values": [ + "server", + ], + }, + { + "config": { + "custom": { + "width": 150, + }, + "displayNameFromDS": "Span count", + }, + "name": "spanCount", + "type": "string", + "values": [ + "10", + ], + }, + { + "config": { + "custom": { + "width": 150, + }, + "displayNameFromDS": "Error", + "unit": "percent", + }, + "name": "errorPercentage", + "type": "string", + "values": [ + "10", + ], + }, + { + "config": { + "custom": { + "width": 150, + }, + "displayNameFromDS": "p50", + "unit": "ns", + }, + "name": "p50", + "type": "number", + "values": [ + "1", + ], + }, + { + "config": { + "custom": { + "width": 150, + }, + "displayNameFromDS": "p90", + "unit": "ns", + }, + "name": "p90", + "type": "number", + "values": [ + "2", + ], + }, + { + "config": { + "custom": { + "width": 150, + }, + "displayNameFromDS": "p95", + "unit": "ns", + }, + "name": "p95", + "type": "number", + "values": [ + "3", + ], + }, + { + "config": { + "custom": { + "width": 150, + }, + "displayNameFromDS": "p99", + "unit": "ns", + }, + "name": "p99", + "type": "number", + "values": [ + "4", + ], + }, + ], + "length": 1, + "meta": { + "preferredVisualisationType": "table", + }, + "name": "Metrics Summary", + "refId": "metrics-summary", + }, + ] + `); + }); + + it('transformToMetricsData should return correctly', () => { + const data = { + spanCount: '10', + errorSpanCount: '1', + p50: '1', + p90: '2', + p95: '3', + p99: '4', + series, + }; + const result = transformToMetricsData(data); + expect(result).toMatchInlineSnapshot(` + { + "contains_sink": "true", + "errorPercentage": "10", + "kind": "server", + "p50": "1", + "p90": "2", + "p95": "3", + "p99": "4", + "room": "kitchen", + "span.http.status_code": 208, + "spanCount": "10", + "spanKind": "server", + "spanStatus": "ok", + "temperature": 38.1, + "window_open": "8h", + } + `); + }); + + it('getConfigQuery should return correctly for empty target query', () => { + const result = getConfigQuery(series, '{}'); + expect(result).toEqual( + '{span.http.status_code=${__data.fields["span.http.status_code"]} && temperature=${__data.fields["temperature"]} && room="${__data.fields["room"]}" && contains_sink="${__data.fields["contains_sink"]}" && window_open="${__data.fields["window_open"]}" && spanStatus=${__data.fields["spanStatus"]} && spanKind=${__data.fields["spanKind"]} && kind=server}' + ); + }); + + it('getConfigQuery should return correctly for target query', () => { + const result = getConfigQuery(series, '{name="HTTP POST - post"} | by(resource.service.name)'); + expect(result).toEqual( + '{name="HTTP POST - post" && span.http.status_code=${__data.fields["span.http.status_code"]} && temperature=${__data.fields["temperature"]} && room="${__data.fields["room"]}" && contains_sink="${__data.fields["contains_sink"]}" && window_open="${__data.fields["window_open"]}" && spanStatus=${__data.fields["spanStatus"]} && spanKind=${__data.fields["spanKind"]} && kind=server} | by(resource.service.name)' + ); + }); + + it('getConfigQuery should return correctly for target query without brackets', () => { + const result = getConfigQuery(series, 'by(resource.service.name)'); + expect(result).toEqual( + '{span.http.status_code=${__data.fields["span.http.status_code"]} && temperature=${__data.fields["temperature"]} && room="${__data.fields["room"]}" && contains_sink="${__data.fields["contains_sink"]}" && window_open="${__data.fields["window_open"]}" && spanStatus=${__data.fields["spanStatus"]} && spanKind=${__data.fields["spanKind"]} && kind=server} | by(resource.service.name)' + ); + }); + }); +}); + +const series = [ + { + key: 'span.http.status_code', + value: { + type: 3, + n: 208, + }, + }, + { + key: 'temperature', + value: { + type: 4, + f: 38.1, + }, + }, + { + key: 'room', + value: { + type: 5, + s: 'kitchen', + }, + }, + { + key: 'contains_sink', + value: { + type: 6, + b: 'true', + }, + }, + { + key: 'window_open', + value: { + type: 7, + d: '8h', + }, + }, + { + key: 'spanStatus', + value: { + type: 8, + status: 1, + }, + }, + { + key: 'spanKind', + value: { + type: 9, + kind: 3, + }, + }, +]; diff --git a/public/app/plugins/datasource/tempo/metricsSummary.ts b/public/app/plugins/datasource/tempo/metricsSummary.ts new file mode 100644 index 00000000000..6a65fc685ea --- /dev/null +++ b/public/app/plugins/datasource/tempo/metricsSummary.ts @@ -0,0 +1,272 @@ +import { + createDataFrame, + DataSourceInstanceSettings, + FieldDTO, + FieldType, + MutableDataFrame, + sortDataFrame, +} from '@grafana/data'; + +export type MetricsSummary = { + spanCount: string; + errorSpanCount?: string; + p50: string; + p90: string; + p95: string; + p99: string; + series: Series[]; +}; + +type Series = { + key: string; + value: { + type: number; + n?: number; + f?: number; + s?: string; + b?: string; + d?: string; + status?: number; + kind?: number; + }; +}; + +type MetricsData = { + spanCount: string; + errorPercentage: string; + p50: string; + p90: string; + p95: string; + p99: string; + [key: string]: string | number; +}; + +export function createTableFrameFromMetricsSummaryQuery( + data: MetricsSummary[], + targetQuery: string, + instanceSettings: DataSourceInstanceSettings +) { + let frame; + + if (!data.length) { + return emptyResponse; + } + + const dynamicMetrics: Record = {}; + data.forEach((res: MetricsSummary) => { + const configQuery = getConfigQuery(res.series, targetQuery); + res.series.forEach((series: Series) => { + dynamicMetrics[series.key] = { + name: `${series.key}`, + type: FieldType.string, + config: getConfig(series, configQuery, instanceSettings), + }; + }); + }); + + frame = createDataFrame({ + name: 'Metrics Summary', + refId: 'metrics-summary', + fields: [ + ...Object.values(dynamicMetrics).sort((a, b) => a.name.localeCompare(b.name)), + { + name: 'kind', + type: FieldType.string, + config: { displayNameFromDS: 'Kind', custom: { width: 150 } }, + }, + { + name: 'spanCount', + type: FieldType.string, + config: { displayNameFromDS: 'Span count', custom: { width: 150 } }, + }, + { + name: 'errorPercentage', + type: FieldType.string, + config: { displayNameFromDS: 'Error', unit: 'percent', custom: { width: 150 } }, + }, + getPercentileRow('p50'), + getPercentileRow('p90'), + getPercentileRow('p95'), + getPercentileRow('p99'), + ], + meta: { + preferredVisualisationType: 'table', + }, + }); + + const metricsData = data.map(transformToMetricsData); + frame.length = metricsData.length; + for (const trace of metricsData) { + for (const field of frame.fields) { + field.values.push(trace[field.name]); + } + } + frame = sortDataFrame(frame, 0); + + return [frame]; +} + +export const transformToMetricsData = (data: MetricsSummary) => { + const errorPercentage = data.errorSpanCount + ? ((parseInt(data.errorSpanCount, 10) / parseInt(data.spanCount, 10)) * 100).toString() + : '0%'; + + const metricsData: MetricsData = { + kind: 'server', // so the user knows all results are of kind = server + spanCount: data.spanCount, + errorPercentage, + p50: data.p50, + p90: data.p90, + p95: data.p95, + p99: data.p99, + }; + + data.series.forEach((series: Series) => { + metricsData[`${series.key}`] = getMetricValue(series) || ''; + }); + + return metricsData; +}; + +export const getConfigQuery = (series: Series[], targetQuery: string) => { + const queryParts = series.map((x: Series) => { + const isNumber = x.value.type === 3 || x.value.type === 4; + const isIntrinsic = x.value.type === 8 || x.value.type === 9; + const surround = isNumber || isIntrinsic ? '' : '"'; + return `${x.key}=${surround}` + '${__data.fields["' + x.key + '"]}' + `${surround}`; + }); + + let configQuery = ''; + const closingBracketIndex = targetQuery.indexOf('}'); + + if (closingBracketIndex !== -1) { + const queryAfterClosingBracket = targetQuery.substring(closingBracketIndex + 1); + configQuery = targetQuery.substring(0, closingBracketIndex); + if (queryParts.length > 0) { + configQuery += targetQuery.replace(/\s/g, '').includes('{}') ? '' : ' && '; + configQuery += `${queryParts.join(' && ')} && kind=server`; + configQuery += `}`; + } + configQuery += `${queryAfterClosingBracket}`; + } else { + configQuery = `{${queryParts.join(' && ')} && kind=server} | ${targetQuery}`; + } + + return configQuery; +}; + +const getConfig = (series: Series, query: string, instanceSettings: DataSourceInstanceSettings) => { + const commonConfig = { + displayNameFromDS: series.key, + links: [ + { + title: 'Query in explore', + url: '', + internal: { + datasourceUid: instanceSettings.uid, + datasourceName: instanceSettings.name, + query: { + query, + queryType: 'traceql', + }, + }, + }, + ], + }; + + if (series.value.type === 7) { + return { + ...commonConfig, + unit: 'ns', + }; + } + return { ...commonConfig }; +}; + +const NO_VALUE = ''; + +const getMetricValue = (series: Series) => { + if (!series.value.type) { + return NO_VALUE; + } + + switch (series.value.type) { + case 3: + return series.value.n; + case 4: + return series.value.f; + case 5: + return series.value.s; + case 6: + return series.value.b; + case 7: + return series.value.d; + case 8: + return getSpanStatusCode(series.value.status); + case 9: + return getSpanKind(series.value.kind); + default: + return NO_VALUE; + } +}; + +// Values set according to Tempo enum: https://github.com/grafana/tempo/blob/main/pkg/traceql/enum_statics.go +const getSpanStatusCode = (statusCode: number | undefined) => { + if (!statusCode) { + return NO_VALUE; + } + + switch (statusCode) { + case 0: + return 'error'; + case 1: + return 'ok'; + default: + return 'unset'; + } +}; + +// Values set according to Tempo enum: https://github.com/grafana/tempo/blob/main/pkg/traceql/enum_statics.go +const getSpanKind = (kind: number | undefined) => { + if (!kind) { + return NO_VALUE; + } + + switch (kind) { + case 1: + return 'internal'; + case 2: + return 'client'; + case 3: + return 'server'; + case 4: + return 'producer'; + case 5: + return 'consumer'; + default: + return 'unspecified'; + } +}; + +const getPercentileRow = (name: string) => { + return { + name: name, + type: FieldType.number, + config: { + displayNameFromDS: name, + unit: 'ns', + custom: { + width: 150, + }, + }, + }; +}; + +export const emptyResponse = new MutableDataFrame({ + name: 'Metrics Summary', + refId: 'metrics-summary', + fields: [], + meta: { + preferredVisualisationType: 'table', + }, +}); diff --git a/public/app/plugins/datasource/tempo/resultTransformer.ts b/public/app/plugins/datasource/tempo/resultTransformer.ts index c8f2eaca11e..8b32d6b24aa 100644 --- a/public/app/plugins/datasource/tempo/resultTransformer.ts +++ b/public/app/plugins/datasource/tempo/resultTransformer.ts @@ -506,6 +506,8 @@ export function transformTrace(response: DataQueryResponse, nodeGraph = false): export function createTableFrameFromSearch(data: TraceSearchMetadata[], instanceSettings: DataSourceInstanceSettings) { const frame = new MutableDataFrame({ + name: 'Traces', + refId: 'traces', fields: [ { name: 'traceID', @@ -572,6 +574,7 @@ export function createTableFrameFromTraceQlQuery( ): DataFrame[] { const frame = createDataFrame({ name: 'Traces', + refId: 'traces', fields: [ { name: 'traceID',