Tempo: Remove aggregate by (#98474)

* Remove aggregate by

* Update betterer

* Run toggles test

* Add back group by in schema so it can be checked for

* Show error if group by

* Update error message

* Allow user to remove the group by from the query

* Fix assertion

---------

Co-authored-by: Piotr Jamróz <pm.jamroz@gmail.com>
This commit is contained in:
Joey
2025-03-24 09:17:32 +00:00
committed by GitHub
co-authored by Piotr Jamróz
parent cb532cafef
commit dfe2af9559
23 changed files with 61 additions and 1175 deletions
-3
View File
@@ -6557,9 +6557,6 @@ exports[`better eslint`] = {
"public/app/plugins/datasource/tempo/SearchTraceQLEditor/DurationInput.tsx:5381": [
[0, 0, 0, "\'HorizontalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"]
],
"public/app/plugins/datasource/tempo/SearchTraceQLEditor/GroupByField.tsx:5381": [
[0, 0, 0, "\'HorizontalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"]
],
"public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.tsx:5381": [
[0, 0, 0, "\'HorizontalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"]
],
@@ -150,7 +150,6 @@ Experimental features might be changed or removed without prior notice.
| `lokiPredefinedOperations` | Adds predefined query operations to Loki query editor |
| `awsDatasourcesTempCredentials` | Support temporary security credentials in AWS plugins for Grafana Cloud customers |
| `mlExpressions` | Enable support for Machine Learning in server-side expressions |
| `metricsSummary` | Enables metrics summary queries in the Tempo data source |
| `datasourceAPIServers` | Expose some datasources as apiservers. |
| `provisioning` | Next generation provisioning... and git |
| `permissionsFilterRemoveSubquery` | Alternative permission filter implementation that does not use subqueries for fetching the dashboard folder |
@@ -271,10 +271,6 @@ export interface FeatureToggles {
*/
traceQLStreaming?: boolean;
/**
* Enables metrics summary queries in the Tempo data source
*/
metricsSummary?: boolean;
/**
* Expose some datasources as apiservers.
*/
datasourceAPIServers?: boolean;
@@ -19,7 +19,7 @@ export interface TempoQuery extends common.DataQuery {
exemplars?: number;
filters: Array<TraceqlFilter>;
/**
* Filters that are used to query the metrics summary
* deprecated Filters that are used to query the metrics summary
*/
groupBy?: Array<TraceqlFilter>;
/**
-7
View File
@@ -441,13 +441,6 @@ var (
Owner: grafanaObservabilityTracesAndProfilingSquad,
Expression: "false",
},
{
Name: "metricsSummary",
Description: "Enables metrics summary queries in the Tempo data source",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaObservabilityTracesAndProfilingSquad,
},
{
Name: "datasourceAPIServers",
Description: "Expose some datasources as apiservers.",
-1
View File
@@ -58,7 +58,6 @@ awsDatasourcesTempCredentials,experimental,@grafana/aws-datasources,false,false,
transformationsRedesign,GA,@grafana/observability-metrics,false,false,true
mlExpressions,experimental,@grafana/alerting-squad,false,false,false
traceQLStreaming,GA,@grafana/observability-traces-and-profiling,false,false,true
metricsSummary,experimental,@grafana/observability-traces-and-profiling,false,false,true
datasourceAPIServers,experimental,@grafana/grafana-app-platform-squad,false,true,false
grafanaAPIServerWithExperimentalAPIs,experimental,@grafana/grafana-app-platform-squad,true,true,false
provisioning,experimental,@grafana/grafana-app-platform-squad,false,true,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
58 transformationsRedesign GA @grafana/observability-metrics false false true
59 mlExpressions experimental @grafana/alerting-squad false false false
60 traceQLStreaming GA @grafana/observability-traces-and-profiling false false true
metricsSummary experimental @grafana/observability-traces-and-profiling false false true
61 datasourceAPIServers experimental @grafana/grafana-app-platform-squad false true false
62 grafanaAPIServerWithExperimentalAPIs experimental @grafana/grafana-app-platform-squad true true false
63 provisioning experimental @grafana/grafana-app-platform-squad false true false
-4
View File
@@ -243,10 +243,6 @@ 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"
// FlagDatasourceAPIServers
// Expose some datasources as apiservers.
FlagDatasourceAPIServers = "datasourceAPIServers"
+2 -1
View File
@@ -2768,7 +2768,8 @@
"metadata": {
"name": "metricsSummary",
"resourceVersion": "1718727528075",
"creationTimestamp": "2023-08-28T14:02:12Z"
"creationTimestamp": "2023-08-28T14:02:12Z",
"deletionTimestamp": "2024-11-25T10:47:18Z"
},
"spec": {
"description": "Enables metrics summary queries in the Tempo data source",
@@ -48,7 +48,7 @@ type TempoQuery struct {
// Defines the maximum number of spans per spanset that are returned from Tempo
Spss *int64 `json:"spss,omitempty"`
Filters []TraceqlFilter `json:"filters"`
// Filters that are used to query the metrics summary
// 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"`
@@ -0,0 +1,20 @@
import React from 'react';
import { Alert, Button } from '@grafana/ui';
import { 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 Drildown app instead. &nbsp;
<Button onClick={onChange}>Remove aggregate by from this query</Button>
</Alert>
) : null;
}
@@ -1,162 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { TraceqlSearchScope } from '../dataquery.gen';
import { TempoDatasource } from '../datasource';
import TempoLanguageProvider from '../language_provider';
import { initTemplateSrv } from '../test/test_utils';
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']);
jest.spyOn(lp, 'getTags').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 });
initTemplateSrv([{ name: 'templateVariable1' }, { name: 'templateVariable2' }], {});
});
afterEach(() => {
jest.useRealTimers();
});
it('should only show add/remove tag when necessary', async () => {
const GroupByWithProps = () => {
const [query, setQuery] = useState<TempoQuery>({
refId: 'A',
queryType: 'traceqlSearch',
key: 'Q-595a9bbc-2a25-49a7-9249-a52a0a475d83-0',
filters: [],
groupBy: [{ id: 'group-by-id', scope: TraceqlSearchScope.Span }],
});
return (
<GroupByField
datasource={datasource}
query={query}
onChange={(q: TempoQuery) => setQuery(q)}
isTagsLoading={false}
/>
);
};
render(<GroupByWithProps />);
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'));
jest.advanceTimersByTime(1000);
await user.click(screen.getByText('http.method'));
jest.advanceTimersByTime(1000);
expect(screen.getAllByLabelText('Add tag').length).toBe(1);
expect(screen.getAllByLabelText(/Remove tag/).length).toBe(1);
await user.click(screen.getByLabelText('Add tag'));
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]);
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]);
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 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');
expect(groupByFilter?.tag).toBe('');
}
});
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');
}
});
it('should allow selecting template variables', async () => {
const { container } = render(
<GroupByField
datasource={datasource}
query={query}
onChange={onChange}
isTagsLoading={false}
addVariablesToOptions={true}
/>
);
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);
expect(await screen.findByText('$templateVariable1')).toBeInTheDocument();
expect(await screen.findByText('$templateVariable2')).toBeInTheDocument();
}
});
});
@@ -1,193 +0,0 @@
import { css } from '@emotion/css';
import { useEffect, useMemo, useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { GrafanaTheme2 } from '@grafana/data';
import { AccessoryButton } from '@grafana/plugin-ui';
import { Alert, HorizontalGroup, InputActionMeta, Select, useStyles2 } from '@grafana/ui';
import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen';
import { TempoDatasource } from '../datasource';
import { OPTIONS_LIMIT } from '../language_provider';
import { TempoQuery } from '../types';
import InlineSearchField from './InlineSearchField';
import { withTemplateVariableOptions } from './SearchField';
import { replaceAt } from './utils';
interface Props {
datasource: TempoDatasource;
onChange: (value: TempoQuery) => void;
query: Partial<TempoQuery> & TempoQuery;
isTagsLoading: boolean;
addVariablesToOptions?: boolean;
}
export const GroupByField = (props: Props) => {
const { datasource, onChange, query, isTagsLoading, addVariablesToOptions } = props;
const styles = useStyles2(getStyles);
const generateId = () => uuidv4().slice(0, 8);
const [tagQuery, setTagQuery] = useState<string>('');
useEffect(() => {
if (!query.groupBy || query.groupBy.length === 0) {
onChange({
...query,
groupBy: [
{
id: generateId(),
scope: TraceqlSearchScope.Span,
},
],
});
}
}, [onChange, query]);
const tagOptions = useMemo(
() => (f: TraceqlFilter) => {
const tags = datasource!.languageProvider.getMetricsSummaryTags(f.scope);
if (tagQuery.length === 0) {
return tags.slice(0, OPTIONS_LIMIT);
}
const queryLowerCase = tagQuery.toLowerCase();
return tags.filter((tag) => tag.toLowerCase().includes(queryLowerCase)).slice(0, OPTIONS_LIMIT);
},
[datasource, tagQuery]
);
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)
.filter((s) => {
// only add scope if it has tags
return datasource.languageProvider.getTags(s).length > 0;
})
.map((t) => ({ label: t, value: t }));
return (
<InlineSearchField
label="Aggregate by"
tooltip={`Note: We recommend using Explore Traces instead. Select one or more tags to see the metrics summary.`}
>
<>
{query.groupBy?.map((f, i) => {
const tags = tagOptions(f)
?.concat(f.tag !== undefined && f.tag !== '' && !tagOptions(f)?.includes(f.tag) ? [f.tag] : [])
.map((t) => ({
label: t,
value: t,
}));
return (
<div key={f.id}>
<HorizontalGroup spacing={'none'} width={'auto'}>
<Select
aria-label={`Select scope for filter ${i + 1}`}
onChange={(v) => {
updateFilter({ ...f, scope: v?.value, tag: '' });
}}
options={scopeOptions}
placeholder="Select scope"
value={f.scope}
/>
<Select
aria-label={`Select tag for filter ${i + 1}`}
isClearable
allowCustomValue
isLoading={isTagsLoading}
key={f.tag}
onChange={(v) => {
updateFilter({ ...f, tag: v?.value });
}}
options={addVariablesToOptions ? withTemplateVariableOptions(tags) : tags}
onInputChange={(value: string, { action }: InputActionMeta) => {
if (action === 'input-change') {
setTagQuery(value);
}
}}
onCloseMenu={() => setTagQuery('')}
placeholder="Select tag"
value={f.tag || ''}
/>
{(f.tag || (query.groupBy?.length ?? 0) > 1) && (
<AccessoryButton
aria-label={`Remove tag for filter ${i + 1}`}
icon="times"
onClick={() => removeFilter(f)}
tooltip="Remove tag"
title={`Remove tag for filter ${i + 1}`}
variant="secondary"
/>
)}
{f.tag && i === (query.groupBy?.length ?? 0) - 1 && (
<span className={styles.addTag}>
<AccessoryButton
aria-label="Add tag"
icon="plus"
onClick={() => addFilter()}
tooltip="Add tag"
variant="secondary"
/>
</span>
)}
</HorizontalGroup>
</div>
);
})}
{query.groupBy && query.groupBy.length > 0 && query.groupBy[0].tag && (
<Alert title="" severity="warning" className={styles.notice}>
The aggregate by feature is deprecated. We recommend using Explore Traces instead. If you want to write your
own TraceQL queries to replicate this API, please check
<a
href={
'https://grafana.com/docs/tempo/latest/api_docs/metrics-summary/#deprecation-in-favor-of-traceql-metrics'
}
className={styles.noticeLink}
target="_blank"
rel="noreferrer noopener"
>
this page
</a>
.
</Alert>
)}
</>
</InlineSearchField>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
addTag: css({
marginLeft: theme.spacing(1),
}),
notice: css({
width: '500px',
marginTop: theme.spacing(0.75),
}),
noticeLink: css({
color: theme.colors.text.link,
textDecoration: 'underline',
marginLeft: '5px',
}),
});
@@ -2,8 +2,6 @@ import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { config } from '@grafana/runtime';
import { TraceqlSearchScope } from '../dataquery.gen';
import { TempoDatasource } from '../datasource';
import TempoLanguageProvider from '../language_provider';
@@ -238,26 +236,28 @@ describe('TraceQLSearch', () => {
});
});
it('should not render group by when feature toggle is not enabled', async () => {
await waitFor(() => {
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} />
);
const groupBy = screen.queryByText('Aggregate by');
expect(groupBy).toBeNull();
expect(groupBy).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Remove aggregate by from this query' })).not.toBeInTheDocument();
});
});
it('should render group by when feature toggle enabled', async () => {
config.featureToggles.metricsSummary = true;
await waitFor(() => {
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} onChange={onChange} onClearResults={onClearResults} />
<TraceQLSearch
datasource={datasource}
query={{ ...query, groupBy: [] }}
onChange={onChange}
onClearResults={onClearResults}
/>
);
const groupBy = screen.queryByText('Aggregate by');
expect(groupBy).not.toBeNull();
expect(groupBy).toBeInTheDocument();
const button = screen.queryByRole('button', { name: 'Remove aggregate by from this query' });
expect(button).toBeInTheDocument();
});
});
});
@@ -13,8 +13,8 @@ import { TempoQueryBuilderOptions } from '../traceql/TempoQueryBuilderOptions';
import { traceqlGrammar } from '../traceql/traceql';
import { TempoQuery } from '../types';
import { AggregateByAlert } from './AggregateByAlert';
import DurationInput from './DurationInput';
import { GroupByField } from './GroupByField';
import InlineSearchField from './InlineSearchField';
import SearchField from './SearchField';
import TagsInput from './TagsInput';
@@ -237,15 +237,15 @@ const TraceQLSearch = ({ datasource, query, onChange, onClearResults, app, addVa
addVariablesToOptions={addVariablesToOptions}
/>
</InlineSearchField>
{config.featureToggles.metricsSummary && (
<GroupByField
datasource={datasource}
onChange={onChange}
query={query}
isTagsLoading={isTagsLoading}
addVariablesToOptions={addVariablesToOptions}
/>
)}
<AggregateByAlert
query={query}
onChange={() => {
delete query.groupBy;
onChange({
...query,
});
}}
/>
</div>
<div className={styles.rawQueryContainer}>
<RawQuery query={templateSrv.replace(traceQlQuery)} lang={{ grammar: traceqlGrammar, name: 'traceql' }} />
@@ -47,7 +47,7 @@ composableKinds: DataQuery: {
// Defines the maximum number of spans per spanset that are returned from Tempo
spss?: int64
filters: [...#TraceqlFilter]
// Filters that are used to query the metrics summary
// deprecated Filters that are used to query the metrics summary
groupBy?: [...#TraceqlFilter]
// The type of the table that is used to display the search results
tableType?: #SearchTableType
@@ -17,7 +17,7 @@ export interface TempoQuery extends common.DataQuery {
exemplars?: number;
filters: Array<TraceqlFilter>;
/**
* Filters that are used to query the metrics summary
* deprecated Filters that are used to query the metrics summary
*/
groupBy?: Array<TraceqlFilter>;
/**
@@ -172,16 +172,6 @@ describe('Tempo data source', () => {
valueType: 'string',
},
],
groupBy: [
{
id: 'groupBy1',
tag: '$interpolationVar',
},
{
id: 'groupBy2',
tag: '$interpolationVar',
},
],
};
}
let templateSrv: TemplateSrv;
@@ -214,8 +204,6 @@ describe('Tempo data source', () => {
expect(queries[0].filters[0].value).toBe(textWithPipe);
expect(queries[0].filters[1].value).toBe(text);
expect(queries[0].filters[1].tag).toBe(text);
expect(queries[0].groupBy?.[0].tag).toBe(text);
expect(queries[0].groupBy?.[1].tag).toBe(text);
});
it('when applying template variables', async () => {
@@ -228,8 +216,6 @@ describe('Tempo data source', () => {
expect(resp.filters[0].value).toBe(textWithPipe);
expect(resp.filters[1].value).toBe(scopedText);
expect(resp.filters[1].tag).toBe(scopedText);
expect(resp.groupBy?.[0].tag).toBe(scopedText);
expect(resp.groupBy?.[1].tag).toBe(scopedText);
});
it('when serviceMapQuery is an array', async () => {
@@ -352,18 +338,6 @@ describe('Tempo data source', () => {
expect(edgesFrame.meta?.preferredVisualisationType).toBe('nodeGraph');
});
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');
});
describe('test the testDatasource function', () => {
it('should return a success msg if response.ok is true', async () => {
mockObservable = () => of({ ok: true });
+11 -127
View File
@@ -53,7 +53,6 @@ import {
totalsMetric,
} from './graphTransform';
import TempoLanguageProvider from './language_provider';
import { createTableFrameFromMetricsSummaryQuery, emptyResponse, MetricsSummary } from './metricsSummary';
import {
enhanceTraceQlMetricsResponse,
formatTraceQLResponse,
@@ -405,19 +404,18 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
}
if (targets.traceqlSearch?.length) {
try {
if (config.featureToggles.metricsSummary) {
const target = targets.traceqlSearch.find((t) => this.hasGroupBy(t));
if (target) {
const appliedQuery = this.applyVariables(target, options.scopedVars);
const queryFromFilters = this.languageProvider.generateQueryFromFilters(appliedQuery.filters);
subQueries.push(this.handleMetricsSummaryQuery(appliedQuery, queryFromFilters, options));
}
}
if (targets.traceqlSearch[0].groupBy) {
return of({
error: {
message:
'The aggregate by query is deprecated. Please remove the current query and create a new one. Alternatively, you can use Traces Drilldown.',
},
data: [],
});
}
const traceqlSearchTargets = config.featureToggles.metricsSummary
? targets.traceqlSearch.filter((t) => !this.hasGroupBy(t))
: targets.traceqlSearch;
try {
const traceqlSearchTargets = targets.traceqlSearch;
if (traceqlSearchTargets.length > 0) {
const appliedQuery = this.applyVariables(traceqlSearchTargets[0], options.scopedVars);
const queryFromFilters = this.languageProvider.generateQueryFromFilters(appliedQuery.filters);
@@ -552,17 +550,6 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
expandedQuery.filters = interpolateFilters(query.filters, scopedVars);
}
if (query.groupBy) {
expandedQuery.groupBy = query.groupBy.map((filter) => {
const updatedFilter = {
...filter,
tag: this.templateSrv.replace(filter.tag ?? '', scopedVars),
};
return updatedFilter;
});
}
return {
...expandedQuery,
query: this.templateSrv.replace(query.query ?? '', scopedVars, VariableFormatID.Pipe),
@@ -572,22 +559,6 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
};
}
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
@@ -731,93 +702,6 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
);
}
handleMetricsSummaryQuery = (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 startTime = performance.now();
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) {
reportTempoQueryMetrics('grafana_traces_metrics_summary_response', options, {
success: false,
streaming: false,
latencyMs: Math.round(performance.now() - startTime), // rounded to nearest millisecond
query: query ?? '',
error: getErrorMessage(`No summary data for '${groupBy}'.`),
});
return {
error: {
message: getErrorMessage(`No summary data for '${groupBy}'.`),
},
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) {
reportTempoQueryMetrics('grafana_traces_metrics_summary_response', options, {
success: false,
streaming: false,
latencyMs: Math.round(performance.now() - startTime), // rounded to nearest millisecond
query: query ?? '',
error: getErrorMessage(`No series data. Ensure you are using an up to date version of Tempo`),
});
return {
error: {
message: getErrorMessage(`No series data. Ensure you are using an up to date version of Tempo`),
},
data: emptyResponse,
};
}
reportTempoQueryMetrics('grafana_traces_metrics_summary_response', options, {
success: true,
streaming: false,
latencyMs: Math.round(performance.now() - startTime), // rounded to nearest millisecond
query: query ?? '',
});
return {
data: createTableFrameFromMetricsSummaryQuery(response.data.summaries, query, this.instanceSettings),
};
}),
catchError((error) => {
reportTempoQueryMetrics('grafana_traces_metrics_summary_response', options, {
success: false,
streaming: false,
latencyMs: Math.round(performance.now() - startTime), // rounded to nearest millisecond
query: query ?? '',
error: getErrorMessage(error.data.message),
statusCode: error.status,
statusText: error.statusText,
});
return of({
error: { message: getErrorMessage(error.data.message) },
data: emptyResponse,
});
})
);
};
// This function can probably be simplified by avoiding passing both `targets` and `query`,
// since `query` is built from `targets`, if you look at how this function is currently called
handleStreamingQuery(
@@ -8,38 +8,6 @@ import { intrinsics } from './traceql/traceql';
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(uniq(['duration', 'kind', 'name', 'status'].concat(intrinsics)));
});
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);
@@ -97,18 +97,6 @@ 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) {
@@ -1,300 +0,0 @@
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"]}} | by(resource.service.name)",
"queryType": "traceql",
},
},
"title": "Query in explore",
"url": "",
},
],
"noValue": "<no value>",
},
"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"]}} | by(resource.service.name)",
"queryType": "traceql",
},
},
"title": "Query in explore",
"url": "",
},
],
"noValue": "<no value>",
},
"name": "temperature",
"type": "string",
"values": [
38.1,
],
},
{
"config": {
"custom": {
"width": 150,
},
"displayNameFromDS": "Span count",
},
"name": "spanCount",
"type": "number",
"values": [
10,
],
},
{
"config": {
"custom": {
"width": 150,
},
"displayNameFromDS": "Error",
"unit": "percent",
},
"name": "errorPercentage",
"type": "number",
"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,
"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"]}}'
);
});
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"]}} | 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"]}} | 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,
},
},
];
@@ -1,273 +0,0 @@
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: number;
errorPercentage: number | string;
p50: number;
p90: number;
p95: number;
p99: number;
[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),
values: [],
};
});
});
frame = createDataFrame({
name: 'Metrics Summary',
refId: 'metrics-summary',
fields: [
...Object.values(dynamicMetrics).sort((a, b) => a.name.localeCompare(b.name)),
{
name: 'spanCount',
type: FieldType.number,
config: { displayNameFromDS: 'Span count', custom: { width: 150 } },
},
{
name: 'errorPercentage',
type: FieldType.number,
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
? (getNumberForMetric(data.errorSpanCount) / getNumberForMetric(data.spanCount)) * 100
: '0%';
const metricsData: MetricsData = {
spanCount: getNumberForMetric(data.spanCount),
errorPercentage,
p50: getNumberForMetric(data.p50),
p90: getNumberForMetric(data.p90),
p95: getNumberForMetric(data.p95),
p99: getNumberForMetric(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(' && ')}`;
configQuery += `}`;
}
configQuery += `${queryAfterClosingBracket}`;
} else {
configQuery = `{${queryParts.join(' && ')}} | ${targetQuery}`;
}
return configQuery;
};
const getConfig = (series: Series, query: string, instanceSettings: DataSourceInstanceSettings) => {
const commonConfig = {
displayNameFromDS: series.key,
noValue: '<no value>',
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,
},
},
};
};
const getNumberForMetric = (metric: string) => {
const number = parseInt(metric, 10);
return isNaN(number) ? 0 : number;
};
export const emptyResponse = new MutableDataFrame({
name: 'Metrics Summary',
refId: 'metrics-summary',
fields: [],
meta: {
preferredVisualisationType: 'table',
},
});
@@ -91,7 +91,6 @@ export const migrateFromSearchToTraceQLSearch = (query: TempoQuery) => {
const migratedQuery: TempoQuery = {
datasource: query.datasource,
filters,
groupBy: query.groupBy,
limit: query.limit,
query: query.query,
queryType: 'traceqlSearch',