mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
6742be0c6d
commit
59e4c257bb
@ -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]
|
||||
|
@ -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 |
|
||||
|
@ -108,6 +108,7 @@ export interface FeatureToggles {
|
||||
toggleLabelsInLogsUI?: boolean;
|
||||
mlExpressions?: boolean;
|
||||
traceQLStreaming?: boolean;
|
||||
metricsSummary?: boolean;
|
||||
grafanaAPIServer?: boolean;
|
||||
featureToggleAdminPage?: boolean;
|
||||
awsAsyncQueryCaching?: boolean;
|
||||
|
@ -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',
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
@ -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)
|
||||
|
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
@ -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)};
|
||||
`,
|
||||
});
|
@ -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 = (
|
||||
|
@ -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);
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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' }} />
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
|
@ -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) {
|
||||
|
312
public/app/plugins/datasource/tempo/metricsSummary.test.ts
Normal file
312
public/app/plugins/datasource/tempo/metricsSummary.test.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
];
|
272
public/app/plugins/datasource/tempo/metricsSummary.ts
Normal file
272
public/app/plugins/datasource/tempo/metricsSummary.ts
Normal 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',
|
||||
},
|
||||
});
|
@ -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',
|
||||
|
Loading…
Reference in New Issue
Block a user