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
This commit is contained in:
Joey 2023-08-28 15:02:12 +01:00 committed by GitHub
parent 6742be0c6d
commit 59e4c257bb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1099 additions and 65 deletions

View File

@ -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]

View File

@ -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 |

View File

@ -108,6 +108,7 @@ export interface FeatureToggles {
toggleLabelsInLogsUI?: boolean;
mlExpressions?: boolean;
traceQLStreaming?: boolean;
metricsSummary?: boolean;
grafanaAPIServer?: boolean;
featureToggleAdminPage?: boolean;
awsAsyncQueryCaching?: boolean;

View File

@ -15,6 +15,10 @@ export const pluginVersion = "10.2.0-pre";
export interface TempoQuery extends common.DataQuery {
filters: Array<TraceqlFilter>;
/**
* Filters that are used to query the metrics summary
*/
groupBy?: Array<TraceqlFilter>;
/**
* 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<TempoQuery> = {
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',

View File

@ -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",

View File

@ -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

1 Name Stage Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
89 toggleLabelsInLogsUI experimental @grafana/observability-logs false false false true
90 mlExpressions experimental @grafana/alerting-squad false false false false
91 traceQLStreaming experimental @grafana/observability-traces-and-profiling false false false true
92 metricsSummary experimental @grafana/observability-traces-and-profiling false false false true
93 grafanaAPIServer experimental @grafana/grafana-app-platform-squad false false false false
94 featureToggleAdminPage experimental @grafana/grafana-operator-experience-squad false false true false
95 awsAsyncQueryCaching experimental @grafana/aws-datasources false false false false

View File

@ -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"

View File

@ -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)

View File

@ -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<typeof userEvent.setup>;
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(
<GroupByField datasource={datasource} query={query} onChange={onChange} isTagsLoading={false} />
);
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(
<GroupByField datasource={datasource} query={query} onChange={onChange} isTagsLoading={false} />
);
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');
}
});
});

View File

@ -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> & 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 (
<InlineSearchField
label="Group By Metrics"
tooltip="Select one or more tags to see the metrics summary. Note: the metrics summary API only considers spans of kind = server."
>
<>
{query.groupBy?.map((f, i) => (
<div key={f.id}>
<HorizontalGroup spacing={'none'} width={'auto'}>
<Select
options={scopeOptions}
value={f.scope}
onChange={(v) => {
updateFilter({ ...f, scope: v?.value });
}}
placeholder="Select scope"
aria-label={`Select scope for filter ${i + 1}`}
/>
<Select
options={getTags(f)?.map((t) => ({
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
/>
<AccessoryButton
variant="secondary"
icon="times"
onClick={() => removeFilter(f)}
tooltip="Remove tag"
aria-label={`Remove tag for filter ${i + 1}`}
/>
{i === (query.groupBy?.length ?? 0) - 1 && (
<span className={styles.addFilter}>
<AccessoryButton
variant="secondary"
icon="plus"
onClick={() => addFilter()}
tooltip="Add tag"
aria-label="Add tag"
/>
</span>
)}
</HorizontalGroup>
</div>
))}
</>
</InlineSearchField>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
addFilter: css`
margin-left: ${theme.spacing(2)};
`,
});

View File

@ -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 = (

View File

@ -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);

View File

@ -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(<TraceQLSearch datasource={datasource} query={query} onChange={onChange} />);
// 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(<TraceQLSearch datasource={datasource} query={query} onChange={onChange} />);
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(<TraceQLSearch datasource={datasource} query={query} onChange={onChange} />);
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(<TraceQLSearch datasource={datasource} query={query} onChange={onChange} />);
const groupBy = screen.queryByText('Group By Metrics');
expect(groupBy).not.toBeNull();
expect(groupBy).toBeInTheDocument();
});
});
});

View File

@ -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}
/>
</InlineSearchField>
{config.featureToggles.metricsSummary && (
<GroupByField datasource={datasource} onChange={onChange} query={query} isTagsLoading={isTagsLoading} />
)}
</div>
<EditorRow>
<RawQuery query={templateSrv.replace(traceQlQuery)} lang={{ grammar: traceqlGrammar, name: 'traceql' }} />

View File

@ -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

View File

@ -12,6 +12,10 @@ import * as common from '@grafana/schema';
export interface TempoQuery extends common.DataQuery {
filters: Array<TraceqlFilter>;
/**
* Filters that are used to query the metrics summary
*/
groupBy?: Array<TraceqlFilter>;
/**
* 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<TempoQuery> = {
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',

View File

@ -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<TempoJsonData> = {
export const defaultSettings: DataSourceInstanceSettings<TempoJsonData> = {
id: 0,
uid: 'gdev-tempo',
type: 'tracing',

View File

@ -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<TempoQuery, TempoJson
return of({ error: { message: error instanceof Error ? error.message : 'Unknown error occurred' }, data: [] });
}
}
if (targets.traceql?.length) {
try {
const appliedQuery = this.applyVariables(targets.traceql[0], options.scopedVars);
@ -253,41 +255,54 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
return of({ error: { message: error instanceof Error ? error.message : 'Unknown error occurred' }, data: [] });
}
}
if (targets.traceqlSearch?.length) {
try {
const queryValueFromFilters = generateQueryFromFilters(targets.traceqlSearch[0].filters);
if (config.featureToggles.metricsSummary) {
const groupBy = targets.traceqlSearch.find((t) => 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<TempoQuery, TempoJson
};
}
handleMetricsSummary = (target: TempoQuery, query: string, options: DataQueryRequest<TempoQuery>) => {
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

View File

@ -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);

View File

@ -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) {

View File

@ -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,
},
},
];

View File

@ -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<string, FieldDTO> = {};
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',
},
});

View File

@ -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',